Apply the finding engine to schematic and PCB reviews.

FACT, REQUIREMENT, and INFERENCE are separate fields; datasheet provenance
lives in the rule DB so Recommended cannot stay ERROR; LLM output is REVIEW.
Designer decisions persist across rescans. Schematic and PCB share one library
and the same finding object in the report UI.
This commit is contained in:
2026-09-20 08:38:59 +02:00
parent 8f4ebc645c
commit dc65e96a22
16 changed files with 797 additions and 211 deletions
+288
View File
@@ -0,0 +1,288 @@
"""Shared finding engine for schematic and PCB.
FACT / REQUIREMENT / INFERENCE stay in separate fields. Datasheet
provenance lives in the rule catalog, not in LLM prose. Recommended
never becomes ERROR. LLM output is REVIEW, never RULE.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Iterable, Literal
from pydantic import BaseModel
from backend.periscopex.models import Finding
Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"]
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
_LLM_SOURCES = frozenset({None, "review", "pcb_review"})
_SOFT_PROVENANCE = frozenset({"RECOMMENDED", "TYPICAL", "EXAMPLE"})
class RuleRecord(BaseModel):
"""One row in the rule DB (not free-text from the model)."""
rule_id: str
provenance: Provenance
finding_class: FindingClass
domain: Literal["schema", "pcb", "shared"] = "shared"
source: str | None = None
requirement: str = ""
class DesignerDecision(BaseModel):
"""Stored intent so a rescan does not re-nag the same FACT."""
decision_id: str
fingerprint: str
rule_id: str | None = None
designator: str = ""
net: str | None = None
aspect: str | None = None
intent: str = ""
reason: str = ""
user_id: str = ""
created_at: str = ""
# rule_id → catalog. Optional override: (rule_id, source).
_RULES: dict[str, RuleRecord] = {}
_RULES_BY_SOURCE: dict[tuple[str, str], RuleRecord] = {}
def _add(rule_id: str, provenance: Provenance, cls: FindingClass, *,
domain: Literal["schema", "pcb", "shared"] = "shared",
source: str | None = None, requirement: str = "") -> None:
rec = RuleRecord(
rule_id=rule_id, provenance=provenance, finding_class=cls,
domain=domain, source=source, requirement=requirement,
)
_RULES[rule_id] = rec
if source:
_RULES_BY_SOURCE[(rule_id, source)] = rec
def _seed() -> None:
if _RULES:
return
# Schematic
for rid, req in (
("PE-MUX-001", "Pin function must match the mux / datasheet pin table."),
("PE-NC-001", "NC pins must not be connected."),
("PE-DEC-001", "Supply pin requires a decoupling capacitor on that net."),
("PE-DEC-002", "Decoupling capacitor value vs datasheet."),
("PE-I2C-001", "I2C bus requires pull-ups."),
("PE-I2C-002", "I2C pull-up value vs datasheet."),
("PE-RST-001", "Reset pin requires the specified pull."),
("PE-RST-002", "Reset pull value vs datasheet."),
("PE-BOM-001", "BOM MPN must match the netlist part."),
("PE-BOM-002", "BOM line missing from the netlist."),
("PE-DNP-001", "DNP / fitted state vs netlist."),
("PE-DRT-001", "Operating voltage must not exceed the capacitor rating."),
):
_add(rid, "MANDATORY", "RULE", domain="schema", requirement=req)
for rid in ("PE-TH-001", "PE-TH-003"):
_add(rid, "TYPICAL", "RISK", domain="schema",
requirement="Thermal estimate only with I_load and θJA from specs.")
_add("PE-PWR-001", "MANDATORY", "RISK", domain="shared",
source="power_margin_check",
requirement="I_load vs regulator capability (not Iout_max).")
_add("PE-PWR-001", "MANDATORY", "RULE", domain="pcb",
source="pcb_power_thermal",
requirement="Trace width × copper thickness vs I_load (IPC-2221 when ΔT known).")
for rid, prov, cls, req in (
("PE-SEQ-001", "RECOMMENDED", "RISK", "Power sequencing from datasheet notes."),
("PE-FLT-001", "RECOMMENDED", "RISK", "Filter topology vs datasheet."),
("PE-FLT-002", "RECOMMENDED", "RISK", "Filter component vs datasheet."),
("PE-FLT-003", "RECOMMENDED", "RISK", "Filter cutoff vs datasheet."),
("PE-XTAL-001", "MANDATORY", "RULE", "Crystal load capacitance vs Cl."),
("PE-XTAL-002", "RECOMMENDED", "RISK", "Crystal layout / load-cap note."),
("PE-XTAL-003", "RECOMMENDED", "RISK", "Crystal drive / Cl note."),
("PE-LF-001", "TYPICAL", "INFO", "Lifecycle / NRND."),
("PE-LF-002", "TYPICAL", "INFO", "Lifecycle / last-time-buy."),
("PE-LF-003", "TYPICAL", "INFO", "Lifecycle / obsolete."),
("PE-INT-001", "TYPICAL", "REVIEW", "Internal feature (ESD/pull-up) vs net."),
("PE-ESR-001", "RECOMMENDED", "RISK", "HF / ESR coverage."),
("PE-ERRATA-001", "MANDATORY", "REVIEW", "Published errata."),
):
_add(rid, prov, cls, domain="schema", requirement=req)
# PCB geometry
_add("PE-LAY-001", "MANDATORY", "RULE", domain="pcb",
requirement="PCB pad net must match the schematic pin net.")
_add("PE-LAY-002", "MANDATORY", "RULE", domain="pcb",
requirement="Schematic ref must have a footprint (except #PWR).")
_add("PE-LAY-003", "TYPICAL", "INFO", domain="pcb",
requirement="KiCad /net vs flattened schematic net is the same net.")
for rid, req in (
("PE-PLC-001", "Decoupling proximity max_distance_mm from layout_rules."),
("PE-PLC-002", "Thermal via min_via_count from layout_rules."),
("PE-PLC-003", "same_layer decoupling from layout_rules."),
("PE-PLC-004", "Keepout from layout_rules."),
("PE-SI-001", "Differential skew vs length_match mm."),
):
_add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req)
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
requirement="Via current share from I_load and via count (no invented table).")
_add("PE-THM-001", "RECOMMENDED", "RISK", domain="pcb",
requirement="Dissipation vs courtyard pour/vias when P is known.")
_add("PE-KEL-001", "RECOMMENDED", "RISK", domain="pcb",
requirement="Kelvin/sense pin should not share the load net.")
_add("PE-STCH-001", "TYPICAL", "INFO", domain="pcb",
requirement="GND stitch via in the bbox of a signal over a GND pour.")
_seed()
def lookup_rule(rule_id: str | None, source: str | None = None) -> RuleRecord | None:
if not rule_id:
return None
if source and (rule_id, source) in _RULES_BY_SOURCE:
return _RULES_BY_SOURCE[(rule_id, source)]
return _RULES.get(rule_id)
def finding_fingerprint(f: Finding) -> str:
return "|".join([
f.rule_id or f.source or "",
f.designator or "",
f.net or "",
f.aspect or "",
])
def complete_finding(f: Finding) -> Finding:
"""Fill FACT/REQUIREMENT/INFERENCE, clamp provenance and evidence."""
rec = lookup_rule(f.rule_id, f.source)
if not (f.facts or "").strip():
f.facts = f.finding
if not (f.requirement or "").strip():
f.requirement = (rec.requirement if rec and rec.requirement else None) or f.why
if not (f.action or "").strip():
f.action = f.recommendation or ""
if not (f.recommendation or "").strip() and f.action:
f.recommendation = f.action
llm = (f.source in {None, "review", "pcb_review"}) and not f.rule_id
if rec:
if not f.provenance:
f.provenance = rec.provenance
if not f.finding_class:
f.finding_class = rec.finding_class
elif llm or f.source in {"review", "pcb_review"}:
f.finding_class = f.finding_class or "REVIEW"
elif f.source:
f.finding_class = f.finding_class or "RULE"
else:
f.finding_class = f.finding_class or "REVIEW"
if f.source in {"review", "pcb_review"}:
f.finding_class = "REVIEW"
quote = (f.source_quote or "").strip()
if f.evidence_status == "INSUFFICIENT":
pass
elif llm and not quote:
f.evidence_status = "INSUFFICIENT"
elif not (f.facts or "").strip() or not (f.requirement or "").strip():
f.evidence_status = "INSUFFICIENT"
else:
f.evidence_status = f.evidence_status or "SUFFICIENT"
if f.evidence_status == "INSUFFICIENT":
if f.status == "ERROR":
f.status = "WARNING" if quote or rec else "INFO"
if f.finding_class == "RULE":
f.finding_class = "INFO"
if not (f.inference or "").strip():
f.inference = (
"Insufficient evidence — not inventing a violation or default "
"(no 1 oz / 10 °C / 50 Ω / IEC)."
)
prov = f.provenance or (rec.provenance if rec else None)
if prov in _SOFT_PROVENANCE and f.status == "ERROR":
f.status = "WARNING"
if f.finding_class == "RULE":
f.finding_class = "RISK"
if f.finding_class == "RULE" and f.confidence is None:
f.confidence = 0.9 if f.evidence_status == "SUFFICIENT" else 0.3
elif f.finding_class == "REVIEW" and f.confidence is None:
f.confidence = 0.55 if quote else 0.35
elif f.confidence is None:
f.confidence = 0.5 if f.evidence_status == "SUFFICIENT" else 0.25
if not (f.why or "").strip():
f.why = f.requirement
if not (f.finding or "").strip():
f.finding = f.facts
return f
def complete_findings(findings: list[Finding]) -> None:
for f in findings:
complete_finding(f)
def apply_decisions(
findings: list[Finding],
decisions: Iterable[DesignerDecision] | Iterable[dict[str, Any]],
) -> None:
"""Rescan must not re-nag a stored designer decision as ERROR."""
by_fp: dict[str, DesignerDecision] = {}
for raw in decisions:
d = raw if isinstance(raw, DesignerDecision) else DesignerDecision.model_validate(raw)
by_fp[d.fingerprint] = d
if not by_fp:
return
for f in findings:
fp = finding_fingerprint(f)
d = by_fp.get(fp)
if not d:
continue
f.suppressed = True
f.decision_id = d.decision_id
f.status = "INFO"
f.finding_class = "INFO"
extra = f"Designer decision {d.decision_id}: {d.intent}"
if d.reason:
extra = f"{extra}{d.reason}"
if extra not in (f.inference or ""):
f.inference = ((f.inference or "") + " " + extra).strip()
if not (f.action or "").strip():
f.action = "Keep the stored intent; no layout/schematic change required."
f.recommendation = f.action
def decision_from_review(
finding: Finding,
*,
state: str,
reason: str,
user_id: str,
) -> DesignerDecision | None:
if state not in {"wontfix", "false_positive"}:
return None
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
fp = finding_fingerprint(finding)
return DesignerDecision(
decision_id=f"DEC-{finding.designator}-{ts}",
fingerprint=fp,
rule_id=finding.rule_id,
designator=finding.designator,
net=finding.net,
aspect=finding.aspect,
intent=state,
reason=reason,
user_id=user_id,
created_at=datetime.now(timezone.utc).isoformat(),
)
def upsert_decision(store: list[dict[str, Any]], decision: DesignerDecision) -> list[dict[str, Any]]:
out = [row for row in store if row.get("fingerprint") != decision.fingerprint]
out.append(decision.model_dump())
return out
+13
View File
@@ -356,6 +356,19 @@ class Finding(BaseModel):
cad_sheet: str | None = None # schematic sheet filename for plugin sync
cad_uuid: str | None = None # KiCad symbol/pin uuid
variant: str | None = None # DNP / ECO / assembly variant
# Finding engine (docs/motore-finding.md) — optional for legacy JSON.
facts: str = ""
requirement: str = ""
inference: str = ""
provenance: Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"] | None = None
finding_class: Literal["RULE", "RISK", "REVIEW", "INFO"] | None = None
confidence: float | None = None
evidence_status: Literal["SUFFICIENT", "INSUFFICIENT"] | None = None
calculation: str = ""
assumptions: list[str] = []
action: str = ""
decision_id: str | None = None
suppressed: bool = False
class ValidationReport(BaseModel):
+2 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from collections import Counter
from backend.periscopex.derating import build_derating_table
from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.pcb_power_thermal import (
@@ -31,6 +31,7 @@ def assign_pcb_finding_ids(findings: list[Finding]) -> None:
f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}"
if not (f.recommendation or "").strip():
f.recommendation = _FALLBACK_FIX
complete_findings(findings)
def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
+9
View File
@@ -141,6 +141,9 @@ def check_pcb_power_traces(
),
source="pcb_power_thermal",
rule_id="PE-PWR-001",
evidence_status="INSUFFICIENT",
calculation="",
assumptions=["IPC-2221 not applied without copper thickness."],
net=net,
pins=[],
))
@@ -162,6 +165,8 @@ def check_pcb_power_traces(
recommendation="Add Tjmax to the shared library extraction, then re-run PCB review.",
source="pcb_power_thermal",
rule_id="PE-PWR-001",
evidence_status="INSUFFICIENT",
assumptions=["ΔT is Tjmax 25 °C from the datasheet, never a default 10 °C."],
net=net,
pins=[],
))
@@ -189,6 +194,10 @@ def check_pcb_power_traces(
),
source="pcb_power_thermal",
rule_id="PE-PWR-001",
calculation=(
f"I = k·ΔT^0.44·A^0.725 → {i_allow:.3g} A; I_load={i_load:.3g} A"
),
evidence_status="SUFFICIENT",
net=net,
pins=[],
))
+5
View File
@@ -30,6 +30,11 @@ are in the context. Never assume 1 oz or 50 Ω.
- Creepage/clearance only if the extraction or IEC number is in context.
### Findings
This is a **design review** of geometry vs the library JSON, not a second \
datasheet exam. Put board measurements in `finding` (FACT), library text in \
`why` (REQUIREMENT). Recommended layout notes are never ERROR. Missing \
width/thickness/I_load → say insufficient evidence, do not assume 1 oz.
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
recommendation. Call submit_review. Empty findings with checked_areas is \
valid when the layout matches the extraction.
+16 -1
View File
@@ -20,6 +20,7 @@ from dotenv import load_dotenv
load_dotenv()
from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
@@ -149,6 +150,12 @@ neighbor's datasheet that you fetched via `get_datasheet_excerpt` — this \
links the page number to the right datasheet.
- **recommendation**: What to change (for ERROR/WARNING only).
This is a **design review**, not a design rule. Put observations in \
`finding` (FACT), datasheet text in `why` (REQUIREMENT), and judgment \
only there — do not invent millimetres, IEC numbers, or typical values. \
Recommended datasheet notes are never ERROR. If evidence is missing, say \
so (Unverified) instead of guessing.
### Calibration
ERROR only for clear violations: required pin floating, voltage exceeding \
absolute max, required external component completely missing, wrong \
@@ -922,12 +929,19 @@ def _parse_review(
mpn=mpn,
source_designator=src_designator,
finding=item["finding"],
facts=str(item.get("finding") or ""),
requirement=why,
inference=str(item.get("inference") or ""),
why=why,
status=status,
source_page=page,
source_quote=item.get("source_quote", ""),
recommendation=item.get("recommendation", ""),
action=str(item.get("recommendation") or ""),
reference=f"{src_mpn} datasheet p.{page if page is not None else '?'}",
source="review",
finding_class="REVIEW",
evidence_status="SUFFICIENT" if quote else "INSUFFICIENT",
))
except (KeyError, TypeError, ValueError) as exc:
print(f"Skipping malformed finding for {ic_ref}: {exc}", file=sys.stderr)
@@ -937,11 +951,12 @@ def _parse_review(
def assign_finding_ids(findings: list[Finding]) -> None:
"""Assign finding_id: {designator}-{001}, {002}, ..."""
"""Assign finding_id: {designator}-{001}, {002}, ... then run the finding engine."""
counter: Counter[str] = Counter()
for f in findings:
counter[f.designator] += 1
f.finding_id = f"{f.designator}-{counter[f.designator]:03d}"
complete_findings(findings)
# ---------------------------------------------------------------------------
+47 -1
View File
@@ -11,6 +11,12 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from backend.periscopex.finding_engine import (
apply_decisions,
complete_findings,
decision_from_review,
upsert_decision,
)
from backend.periscopex.models import Finding
from backend.periscopex.review_workflow import (
ReviewError,
@@ -49,6 +55,20 @@ async def get_report(project_id: str, request: Request):
merged = merge_schema_pcb_reports(schema, pcb)
if merged is None:
raise HTTPException(404, "Report not found — run the pipeline first")
findings = _findings_from_report(merged)
complete_findings(findings)
dec_key = f"{prefix}/decisions.json"
if storage.exists(dec_key):
try:
apply_decisions(findings, storage.read_json(dec_key) or [])
except Exception:
pass
merged["findings"] = [json.loads(f.model_dump_json()) for f in findings]
summary = {"ERROR": 0, "WARNING": 0, "INFO": 0, "total": len(findings)}
for f in findings:
if f.status in summary:
summary[f.status] += 1
merged["summary"] = summary
return JSONResponse(merged)
@@ -148,7 +168,17 @@ async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody,
owner_user_id, _ = await resolve_or_404(request, project_id)
user_id = get_user_id(request)
key, report_data = _load_report(storage, owner_user_id, project_id)
ids = {f.finding_id for f in _findings_from_report(report_data) if f.finding_id}
prefix = proj_svc.project_prefix(owner_user_id, project_id)
findings = _findings_from_report(report_data)
ids = {f.finding_id for f in findings if f.finding_id}
if finding_id not in ids:
pcb_key = f"{prefix}/pcb_report.json"
if storage.exists(pcb_key):
pcb_data = storage.read_json(pcb_key)
pcb_findings = _findings_from_report(pcb_data)
if finding_id in {f.finding_id for f in pcb_findings if f.finding_id}:
key, report_data, findings = pcb_key, pcb_data, pcb_findings
ids = {f.finding_id for f in findings if f.finding_id}
if finding_id not in ids:
raise HTTPException(404, "Finding not found")
try:
@@ -164,6 +194,22 @@ async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody,
raise HTTPException(400, str(exc)) from exc
report_data["review_states"] = states
storage.write_json(key, report_data)
if body.state in {"wontfix", "false_positive"}:
found = next(
(f for f in _findings_from_report(report_data) if f.finding_id == finding_id),
None,
)
if found is not None:
dec = decision_from_review(
found, state=body.state, reason=body.reason, user_id=user_id,
)
if dec is not None:
prefix = proj_svc.project_prefix(owner_user_id, project_id)
dkey = f"{prefix}/decisions.json"
existing = storage.read_json(dkey) if storage.exists(dkey) else []
if not isinstance(existing, list):
existing = []
storage.write_json(dkey, upsert_decision(existing, dec))
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
+8 -1
View File
@@ -7,6 +7,7 @@ untouched. Does not write ``.kicad_pcb`` or packing coordinates.
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
@@ -17,7 +18,7 @@ from backend.periscopex.cad_bridge import (
cad_index_from_graph,
write_cad_bridge,
)
from backend.periscopex.functional_groups import build_placement_plan
from backend.periscopex.finding_engine import apply_decisions
from backend.periscopex.graph import build_graph
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
@@ -227,6 +228,12 @@ async def run_pcb_pipeline(
_step(project_id, "write_report", "running")
assign_pcb_finding_ids(findings)
annotate_findings_cad(findings, cad_index_from_graph(graph))
dec_path = ws.local_path("decisions.json")
if dec_path.is_file():
try:
apply_decisions(findings, json.loads(dec_path.read_text()))
except Exception:
logger.exception("decisions.json apply failed")
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
summary[f.status] = summary.get(f.status, 0) + 1
+7
View File
@@ -21,6 +21,7 @@ from typing import Awaitable, Callable
log = logging.getLogger(__name__)
from backend.periscopex.finding_engine import apply_decisions
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
@@ -789,6 +790,12 @@ async def validate_design_async(
def _write_report(paused: bool = False) -> ValidationReport:
annotate_findings_cad(all_findings, graph.cad_index)
assign_finding_ids(all_findings)
dec_path = existing_path.with_name("decisions.json")
if dec_path.is_file():
try:
apply_decisions(all_findings, json.loads(dec_path.read_text()))
except Exception:
log.exception("decisions.json apply failed")
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
for f in all_findings:
summary[f.status] = summary.get(f.status, 0) + 1
+114
View File
@@ -0,0 +1,114 @@
# Motore finding — schema e PCB (stesso oggetto)
Fonte: specifica di Michele (Project chat). Vale per **analizzatore schematico e PCB**. Un finding è un oggetto riproducibile, non un paragrafo LLM.
Libreria datasheet: **una** (`library/extracted` + extracted/ di progetto). Lesame PDF profondo gira **una volta** (pipeline schema). Il job PCB consuma quella cache e aggiunge solo geometria.
---
## 1. Tre strati — non mescolare
Ogni finding ha **tre campi distinti**:
| Strato | Nome campo | Chi lo produce | Cosa può contenere |
| --- | --- | --- | --- |
| **FACT** | `facts` | Motore deterministico (netlist, BOM, `.kicad_pcb`) | Solo osservabili: pin, net, mm, V, A, width, thickness |
| **REQUIREMENT** | `requirement` | Rule DB + estrazione libreria (citazione, pagina, MPN) | Testo **sourced**. Provenance nel DB, non nella prosa del modello |
| **INFERENCE** | `inference` | Ingegnere / LLM **solo** qui | Giudizio. Mai copiare uninferenza in `facts` o `requirement` |
Non mescolare: un check non scrive “probabilmente sottodimensionato” dentro `facts`. Se manca un parametro, `evidence_status=INSUFFICIENT` e si **dice** che manca — non si inventa 1 oz, 10 °C, 50 Ω, IEC.
`finding` / `why` restano per compatibilità UI (copia di facts / requirement).
---
## 2. Provenance datasheet (rule DB)
Sul **record di regola**, non nel testo LLM:
| `provenance` | Significato | Classe / severity |
| --- | --- | --- |
| **MANDATORY** | Shall / abs-max / pin NC | Può essere **RULE** + ERROR |
| **RECOMMENDED** | Should / layout note | **Mai ERROR**. RISK o INFO |
| **TYPICAL** | Typical / default application | INFO o RISK, mai RULE ERROR |
| **EXAMPLE** | Figure / typical application circuit | REVIEW o INFO |
**Recommended ≠ ERROR.** Il clamp è nel motore (`complete_finding`).
---
## 3. Classi — Design Rule ≠ Design Review
| `finding_class` | Ruolo |
| --- | --- | --- |
| **RULE** | Violazione di un requisito MANDATORY misurato (FACT vs REQUIREMENT). Deterministico. |
| **RISK** | Margine / derating / inferenza ingegneristica con evidenza parziale. |
| **REVIEW** | Esame AI (schema o PCB). Non è una design rule. |
| **INFO** | Inventario, evidenza insufficiente, decisione del progettista, skip onesto. |
LLM `source=review` / `pcb_review` → sempre **REVIEW**, mai RULE.
---
## 4. Intent + memoria DECISION
Il progettista può dichiarare un intent (es. **PSEL tied low on purpose**). Persistenza: `decisions.json` nel progetto, fingerprint:
`{rule_id\|source}|{designator}|{net}|{aspect}`
`wontfix` / `false_positive` sul report **scrive** una DECISION. Al rescan il motore **non ri-naga** come ERROR: finding `suppressed=true`, `finding_class=INFO`, `decision_id` valorizzato.
---
## 5. Severity, confidence, evidence — indipendenti
- `status` (severity): ERROR | WARNING | INFO
- `confidence`: 01 (quanto è solido il FACT+REQUIREMENT)
- `evidence_status`: SUFFICIENT | INSUFFICIENT
Alto confidence + INFO è legale (misura chiara, non è un fault). Bassa confidence + ERROR **non** è legale se evidence è INSUFFICIENT: si declassa.
---
## 6. Oggetto finding (riproducibile)
Campi obbligatori del motore (oltre a designator / MPN / ids esistenti):
```
facts, requirement, inference
source (pagina/MPN/rule_id)
calculation
assumptions[]
confidence
status (severity)
action # cosa fare; UI usa anche recommendation
provenance
finding_class
evidence_status
decision_id? suppressed?
```
---
## 7. Libreria unica / esame unico
| Store | Path |
| --- | --- |
| Estrazioni IC | `library/extracted/{safe_mpn}.json` (shared) `extracted/` progetto |
| PDF blob | `library/datasheets/blobs/{md5}.pdf` |
PCB: `review_ic_async(..., pdf_path=None)` se c’è pintable. Skip: `no library extraction — run schematic review first`.
---
## 8. Cosa è solo PCB
Geometria board: width/thickness, via, pour courtyard, placement mm, return/stitch, pad-net vs schema (`PE-LAY-*` gerarchia `/net`). **Non** duplicare check schema (mux, LED, filtri netlist).
---
## 9. Fasi (non fingere completezza)
**Fase A (questa slice):** oggetto finding + clamp provenance/class + decisions + UI schema/PCB + libreria condivisa + check geometria già gated.
**Fase B (pianificata):** gerarchia component→system; timing; energy/thermal solo con θJA/P/I_load; ratings; derating PASS/MARGIN/RISK; PI; SI oltre skew mm; return path oltre stitch INFO; ESD/EMI; thermal FEM; BOM↔PCB↔datasheet; SPOF come REVIEW.
+46 -205
View File
@@ -1,238 +1,79 @@
# Piano — PCB review (esame AI + check deterministici)
# Piano — PCB review (esame geometria + stesso motore finding)
Fonte di verità per **Periscope Layout review**. Non è un auto-placer.
Specifica finding: [`motore-finding.md`](motore-finding.md). Schema e PCB usano **lo stesso oggetto**. Questo file è il job layout.
**Delta vs revisione precedente:** lesame del board è **agentico come la pipeline schema** (datasheet + neighborhood + tool, findings ERROR/WARNING/INFO con **sempre** una `recommendation`). Restano i check numerici gated. Auto-placement / pack / write-back pcbnew restano **fuori**.
**Non** è un auto-placer. PCB-only = geometria (mm, width, copper, via, pour, return stitch, pad-net). Validazione netlist / mux / LED resta schema.
Prodotto: esaminare un `.kicad_pcb` già (parzialmente) sbrogliato — placement, routing, impedenze, lunghezze, decoupling, filtri, derating, compliance datasheet — e dire *cosa è sbagliato e come sistemarlo*. Non si spostano footprint e non si scrive rame.
Contratto:
Contratto rispetto a [`docs/piano-implementazione.md`](piano-implementazione.md):
| | Schema (già in produzione) | PCB review (questo piano) | Placement F2 pack (fuori) |
| | Schema | PCB review | Placement F2 (fuori) |
| --- | --- | --- | --- |
| Promessa | Lo schema rispetta il datasheet | Il **layout esistente** rispetta datasheet + geometria | Proporre xy nuovi |
| Metodo | Check deterministici + **review LLM per-IC** | Stesso spirito: check + **review LLM per-IC sul PCB** | Skeleton `placement_pack.json` |
| Trigger | BOM + netlist / `.kicad_sch` | + `.kicad_pcb` | PCB + `layout_rules` numerici |
| Output | Finding pin/net, derating, review | Finding mm / net / Z0 / skew / via EP / layout AI; `PE-PLC*` `PE-SI*` `PE-LAY*` `PE-Z0*` | Proposte xy |
| Job | `MODE=run` (`status`) | `MODE=pcb` (`pcb_status`) | `MODE=placement` (`placement_status`) |
| Promessa | Netlist vs libreria datasheet | Layout vs libreria + geometria | xy nuovi |
| Motore finding | FACT / REQUIREMENT / INFERENCE | uguale | — |
| Deep exam PDF | **Una volta**`library/extracted` | Consuma cache, `pdf_path=None` | — |
| Job | `MODE=run` | `MODE=pcb` | `MODE=placement` |
| Output | `report.json` | `pcb_report.json` (merge in GET /report) | pack json |
Senza `.kicad_pcb` il progetto resta uno schema completo. Il report schema **non** si riempie di finding layout.
Senza `.kicad_pcb` il progetto è schema-completo. Il report schema non si riempie di PE-PLC/PE-SI.
---
## 0. Postura: esame AI, non progettazione
## Motore (schema + PCB)
Stesso spirito della Direct Datasheet Review: il modello legge datasheet + contesto del circuito **e** geometria del footprint/net, usa tool sul grafo, sottomette finding. Non progetta il board.
Vedi `motore-finding.md`. Invarianti:
### Domains e functional Groups
1. Non mescolare FACT / REQUIREMENT / INFERENCE.
2. Provenance **MANDATORY | RECOMMENDED | TYPICAL | EXAMPLE** nel rule DB. **Recommended ≠ ERROR**.
3. Classi **RULE | RISK | REVIEW | INFO**. LLM = REVIEW, mai RULE.
4. `decisions.json` + fingerprint: rescan non ri-naga (PSEL low intenzionale, ecc.).
5. `status`, `confidence`, `evidence_status` indipendenti.
6. Evidenza insufficiente → lo si dice, niente 1 oz / 10 °C / 50 Ω / IEC inventati.
7. Oggetto riproducibile: facts, requirement, source, calculation, assumptions, confidence, severity, action.
Obbligatori come contesto dellesame (non come packing):
UI report (schema e layout): stessa `FindingCard` — Fact / Requirement / Inference, badge classe e provenance.
- **Domains** = isole sulle power net (`functional_groups.py` / `placement_plan.json`).
- **Groups** = IC + satelliti con `role_hint` (`decoupling`, `bulk`, `filter`, `crystal`, …).
Il job PCB **classifica** (riusa `build_placement_plan`) e passa domains/groups al reviewer e allinventory. Non propone coordinate.
La pipeline Placement (`POST …/placement/start`) resta topologia routing-first. F2 pack/export **parcheggiati**.
### Placement = verifica (deterministico + AI)
Misura ciò che è **già** sul board: distanza pad-cap, layer, via EP, keepout, crystal load caps. `placement_check` (`PE-PLC-001``004`) è il nucleo numerico. LAI copre note di layout del datasheet **senza mm strutturato** (citazione + `Unverified:` se la quote non verifica), sempre con recommendation.
### Routing = la stessa postura
Lunghezze, coppie, bus, Z0, pad-net vs schema. `si_check` (`PE-SI-001`) solo con `length_match` mm. LAI può flaggare crossing/loop evidenti **solo** se il contesto geometria lo mostra; niente 3W/IEC inventati.
### Findings (come parte 1 / schema)
Ogni finding PCB:
- `status`: **ERROR** | **WARNING** | **INFO**
- `recommendation` **obbligatoria** anche per INFO (cosa fare: spostare, allungare/accorciare, cambiare rating, verificare stackup, …)
- `rule_id` sui check automatici; review AI: `source=pcb_review`
- IDs `PCB-{designator}-{001}` per non collidere con lo schema
Normalization resta **downgrade-only**.
Libreria: `library/extracted/{safe_mpn}.json` extracted/ progetto. PDF in `library/datasheets/blobs/`.
---
## 1. Job parallelo (riuso stack)
## Job PCB (invariato nello stack)
```
┌─ MODE=run → status → report.json (schema)
┌─ MODE=run → status → report.json
Project files ───┼─ MODE=placement → placement_status → placement_plan.json
└─ MODE=pcb → pcb_status → pcb_report.json
```
### Riuso
Stages: `ensure_graph``parse_pcb``classify``inventory``checks``ai_review` (layout vs library, no PDF) → `write_report`.
| Pezzo | Path | Ruolo PCB |
| --- | --- | --- |
| Workspace / SSE | `pipeline.py` | `PipelineWorkspace`, broker |
| Dispatch | `job_runner.py`, `pipeline_worker.py` | `MODE=pcb` |
| API | `routers/pipeline.py` | `pcb/start`, `cancel`, `events`, inventory |
| Meta | `projects.py` | `pcb_status`, `pcb_state`, `pcb_execution_name`, `pcb_cancel_requested` |
| Parser | `parsers_kicad_pcb.py` | `LayoutGraph` |
| Groups | `functional_groups.py` | domains + groups |
| Placement | `placement_check.py` | PE-PLC-* |
| SI | `si_check.py` | PE-SI-001 |
| Filtri | `filter_check.py` | contesto; proximity solo con mm |
| Derating | `derating.py` | Vop vs Vrated se entrambi noti |
| Impedenza | `impedance_traces.py` | inventory Z0; finding se target numerico |
| Review LLM | `services/validation.py` + prompt PCB | per-IC, stesso loop tool/`submit_review` |
| Cad bridge | `cad_bridge.py` | `target: pcb` |
| UI | report `finding-card`, sidebar, `types.ts`, `api.ts` | tab Layout, merge findings |
Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net` vs net schema: **PE-LAY-003** una volta, non per-pad (`kicad_nets_match`).
### Contratto job
### Check PCB (geometria) — gated
- Non tocca `status` schema.
- Soft-cancel `pcb_cancel_requested`.
- Mutex vs analisi e vs placement (event log condiviso).
- SSE `pcb_step_update` / `pcb_complete` / `pcb_error` / `pcb_cancelled`.
- Locale: `local/pcb/{id}`.
- Heal zombie come placement.
- LLM: stesso provider DeepSeek della review schema; `ApiLogger` stage `pcb_review`; billing via `get_billing()` (NullBilling in OSS).
- Senza API key in test: skip AI, check deterministici restano.
### Stages
1. `ensure_graph``design_graph.json` o graph_build.
2. `parse_pcb``layout_graph.json` / `parse_kicad_pcb`. Fail → error.
3. `classify` — domains + groups (`build_placement_plan`); scrive `functional_groups.json` se manca.
4. `inventory` — tracce, bus, lunghezze, Z0 → `pcb_inventory.json`.
5. `checks` — deterministici fail-soft (`run_pcb_checks`).
6. `ai_review` — per-IC come schema, contesto layout + group/domain + inventory del vicinato.
7. `write_report``pcb_report.json`, cad-bridge, IDs `PCB-*`.
---
## 2. Ingressi
| Ingresso | Obbligatorio v1 |
| ID | Gate |
| --- | --- |
| `.kicad_pcb` (`has_pcb`) | Sì |
| `design_graph.json` | Sì |
| `extracted/` + `layout_rules` / pintable | Per check numerici e AI |
| **Shared library** `library/extracted/{safe_mpn}.json` | **Unico** store schema+PCB. Deep exam PDF una volta (pipeline schema). |
| PDF datasheet | Solo per estrazione / review **schema**. PCB **non** riattacca il PDF. |
| Stackup nel PCB | Per Z0 e ampacity; skip se assente |
| PE-LAY-001…003 | pad/ref/hierarchy nets |
| PE-PLC-001…004 | `layout_rules` numerici |
| PE-SI-001 | `length_match` mm |
| PE-DRT-001 | Vop e Vrated |
| PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax |
| PE-VIA-001 | via count + I_load (INFO, no tabella inventata) |
| PE-THM-001 | P=I_load×drop, courtyard senza pour/via |
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
| PE-STCH-001 | pour GND + segnale senza via GND nel bbox |
| Creepage | **skip** senza numero datasheet/IEC |
| Crystal keepout | PE-PLC-004 se kind=keepout |
AI PCB: skip IC senza pintable (`run schematic review first`).
---
## 3. Check (deterministici) + AI
## Fasi
### 3.1 Inventario (non finding)
**A — shipped (questa slice):** oggetto finding condiviso, clamp provenance/class, decisions, UI, libreria unica, geometria gated, net-slash.
Net con rame: lunghezza, layer, width min/max, via, coppia, bus, Z0 se stackup. Domains/groups elencati.
**B — pianificata (non finta):** gerarchia component→system; timing; energy/thermal solo con parametri; ratings; derating PASS/MARGIN/RISK; PI; SI oltre skew; return path oltre stitch INFO; ESD/EMI; thermal FEM; BOM↔PCB↔datasheet; SPOF come REVIEW.
### 3.2 Deterministici
**C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys.
| ID | Cosa | Gate |
| --- | --- | --- |
| `PE-LAY-001` | Pad net ≠ pin net schema | entrambi noti |
| `PE-LAY-002` | Ref schema senza footprint (skip `#PWR`) | graph + PCB |
| `PE-PLC-001``004` | decoupling / via / same_layer / keepout | `layout_rules` numerici |
| `PE-SI-001` | skew coppia | `length_match` mm |
| `PE-DRT-001` | Vop > Vrated sul cap (derating.py) | entrambe le tensioni |
| `PE-Z0-001` | Z0 fuori target | target in regola o netclass; mai 50 Ω default |
| `PE-PWR-001` | Larghezza × spessore rame vs I_load | I_load + width; IPC-2221 solo con `copper_thickness_mm` e Tjmax (ΔT=Tjmax25 °C) |
| `PE-VIA-001` | I_load / N via | geometria via + I_load; INFO (niente tabella via inventata) |
| `PE-THM-001` | P=I_load×(VinVout) senza pour/via courtyard | I_load, Vin/Vout, courtyard |
| `PE-KEL-001` | Pin sense/Kelvin su net di carico | nome pintable + ≥2 altri sul net |
| `PE-STCH-001` | Segnale sopra pour GND senza via GND nel bbox | zone GND + segmenti; INFO |
| Creepage | — | **skip** senza numero datasheet/IEC |
| Crystal keepout | `PE-PLC-004` | `layout_rules` kind=keepout |
Filtri: `check_filters` **non** duplicato nel report PCB (resta schema). LAI e i groups usano `role_hint=filter` per proximity se c’è mm.
### 3.3 AI exam (layout-only, shared library)
Per ogni IC con **pintable in `library/extracted` o extracted/ di progetto** (stesso JSON):
- **Non** si ri-legge il PDF. Lesame datasheet profondo è quello della pipeline schema.
- System prompt **PCB**: layout vs JSON di libreria.
- User: `format_library_extraction` + `build_pcb_layout_context`.
- `review_ic_async(..., pdf_path=None)` — niente attach, niente quote-verify PDF.
- Skip: `no library extraction — run schematic review first`.
- Recommendation obbligatoria; isolation per-IC.
UI: sezione report **PCB exam** (`pcb-exam-section.tsx`) — findings `source=pcb_review` e PE-PWR/THM/VIA/KEL/STCH. Nascosta con `?domain=schema`.
### 3.4 Skip (no folklore)
3W, creepage IEC senza numero, CPWG, HV isolation, length-match USB spec, confronto foto TI vs gerber, 1 oz / 10 °C / 50 Ω di default.
---
## 4. UI
| Superficie | Comportamento |
| --- | --- |
| Hub | **Run PCB review** se `hasPcb` |
| `/project/[id]/pcb` | Stepper SSE (include `ai_review`) |
| Sidebar | **Layout**`/pcb` |
| Report | Merge + sezione **PCB exam**; `?domain=layout`; badge Layout; recommendation sempre visibile |
| Inventory | Traces/buses/Z0 + domains/groups |
---
## 5. Fasi di implementazione
Dopo **ogni** fase: test delle funzioni toccate, commit, push. Dopo ogni macro-fase operativa: deploy `ssh periscope` + `scripts/update-periscope.sh` (niente chiavi/sshd).
### Fase 0 — Split schema / layout
Togliere `placement_check` / `si_check` da `_run_deterministic_checks` schema. Eval schema: `layout=None` resta no-op.
**Done when:** schema senza PCB = zero `PE-PLC`/`PE-SI`.
### Fase 1 — Job + deterministici + UI (macro operativa)
Meta `pcb_*`, worker, router, `run_pcb_checks`, inventory, report merge, UI start/progress/filtro.
**Done when:** upload PCB → start → finding nel report. Deploy.
### Fase 2 — AI exam + domains/groups (macro operativa)
`classify` + `ai_review` per-IC, prompt PCB, recommendation obbligatoria, changelog Layout.
**Done when:** con API key, IC con libreria producono finding `source=pcb_review` o coverage vuota; senza key, skip loggato. Deploy.
### Fase 3 — Plugin + eval PCB
Cad-bridge pcbnew; golden fixture. Packing resta fuori.
### Fase 4 — Libreria unica + sezione PCB exam (macro operativa)
Un store `library/extracted` (più extracted/ di progetto). PCB non duplica lesame PDF. Sezione report PCB exam + PE-PWR/THM/VIA/KEL/STCH.
**Done when:** IC senza pintable skipped; con libreria, AI layout-only; check IPC solo con stackup+Tjmax. Deploy.
---
## 6. Fuori scope
- Auto-placement, packing mm, write-back pcbnew.
- Autore di sbroglio / change width.
- SPICE / IBIS / EM.
- Inventare mm, Ω, V/mm.
- EasyEDA, Gerber come ingresso, PADS layout.
- Unire il job PCB nella run schema.
- Toccare ssh keys / `sshd`.
---
## 7. Done (fixture)
1. Decoupling 15 mm vs regola 2 mm → `PE-PLC-001` + recommendation.
2. 1 mm → niente proximity.
3. Skew coppia > `length_match``PE-SI-001`.
4. Pad net mismatch → `PE-LAY-001`.
5. Senza PCB → 400 sul start.
6. AI: contesto contiene domain/group; finding senza recommendation rifiutati/completati dal parser.
---
## 8. Relazione
Wave G di `piano-implementazione.md` = questo job di **esame** (ora anche LLM). F2 pack non entra.
Deploy: `ssh periscope` + `/root/periscope/scripts/update-periscope.sh`.
+9
View File
@@ -2,6 +2,15 @@
What's new in Periscope.
## 2.32.0 — 2026-09-20 — Finding engine (schema + PCB)
Same finding object on schematic analysis and PCB exam: FACT / REQUIREMENT / INFERENCE, datasheet provenance in the rule DB, RULE vs REVIEW, designer decisions, independent severity/confidence/evidence.
- [New] `docs/motore-finding.md` — engine spec. `finding_engine.py` + `decisions.json`.
- [New] Report cards show Fact / Requirement / Inference, class, provenance, insufficient evidence.
- [Changed] Recommended datasheet notes cannot stay ERROR. LLM findings are REVIEW, never RULE.
- [Changed] Schematic and PCB share `library/extracted`; PCB does not re-read the PDF.
## 2.31.0 — 2026-09-20 — Shared library PCB exam (no second PDF pass)
Schematic and PCB share `library/extracted`. PCB AI consumes that cache (pintable required; no PDF attach). Report **PCB exam** section plus power-trace/thermal checks.
@@ -60,6 +60,10 @@ export function FindingCard({
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
const expandable =
!!finding.recommendation ||
!!finding.facts ||
!!finding.requirement ||
!!finding.inference ||
!!finding.calculation ||
(hasCommentSupport && !!finding.finding_id) ||
!!(projectId && finding.finding_id && onReviewSaved);
@@ -187,10 +191,64 @@ export function FindingCard({
{finding.rule_id}
</span>
)}
{finding.finding_class && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{finding.finding_class}
</span>
)}
{finding.provenance && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{finding.provenance}
</span>
)}
{finding.evidence_status === "INSUFFICIENT" && (
<span className="inline-flex items-center rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-300">
Insufficient evidence
</span>
)}
{finding.suppressed && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Decision
</span>
)}
</div>
{open && (
<div onClick={(e) => e.stopPropagation()}>
{(finding.facts || finding.requirement || finding.inference) && (
<dl className="mt-2 space-y-1.5 text-xs text-muted-foreground">
{finding.facts ? (
<div>
<dt className="font-medium text-foreground">Fact</dt>
<dd>{finding.facts}</dd>
</div>
) : null}
{finding.requirement ? (
<div>
<dt className="font-medium text-foreground">Requirement</dt>
<dd>{finding.requirement}</dd>
</div>
) : null}
{finding.inference ? (
<div>
<dt className="font-medium text-foreground">Inference</dt>
<dd>{finding.inference}</dd>
</div>
) : null}
{finding.calculation ? (
<div>
<dt className="font-medium text-foreground">Calculation</dt>
<dd className="font-mono">{finding.calculation}</dd>
</div>
) : null}
{typeof finding.confidence === "number" ? (
<div>
<dt className="font-medium text-foreground">Confidence</dt>
<dd>{Math.round(finding.confidence * 100)}%</dd>
</div>
) : null}
</dl>
)}
{finding.recommendation && (
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
{finding.recommendation}
+15
View File
@@ -1,4 +1,7 @@
export type FindingStatus = "ERROR" | "WARNING" | "INFO";
export type FindingClass = "RULE" | "RISK" | "REVIEW" | "INFO";
export type DatasheetProvenance = "MANDATORY" | "RECOMMENDED" | "TYPICAL" | "EXAMPLE";
export type EvidenceStatus = "SUFFICIENT" | "INSUFFICIENT";
export interface Finding {
finding_id: string | null;
@@ -20,6 +23,18 @@ export interface Finding {
cad_sheet?: string | null;
cad_uuid?: string | null;
variant?: string | null;
facts?: string;
requirement?: string;
inference?: string;
provenance?: DatasheetProvenance | null;
finding_class?: FindingClass | null;
confidence?: number | null;
evidence_status?: EvidenceStatus | null;
calculation?: string;
assumptions?: string[];
action?: string;
decision_id?: string | null;
suppressed?: boolean;
}
export interface FindingComment {
+135
View File
@@ -0,0 +1,135 @@
"""Finding engine — FACT/REQUIREMENT/INFERENCE, provenance clamp, decisions."""
from __future__ import annotations
from backend.periscopex.finding_engine import (
DesignerDecision,
apply_decisions,
complete_finding,
finding_fingerprint,
lookup_rule,
)
from backend.periscopex.models import Finding
from backend.periscopex.validate import assign_finding_ids, _parse_review
def test_recommended_never_error():
f = Finding(
designator="U1",
finding="layout note",
why="place close",
status="ERROR",
rule_id="PE-THM-001",
source="pcb_power_thermal",
evidence_status="SUFFICIENT",
facts="no pour",
requirement="datasheet layout page",
)
complete_finding(f)
assert f.provenance == "RECOMMENDED"
assert f.status != "ERROR"
assert f.finding_class != "RULE"
def test_llm_review_is_never_rule():
result = _parse_review(
{
"findings": [{
"finding": "strap",
"why": "datasheet",
"status": "ERROR",
"source_page": 2,
"source_quote": "PSEL must be high for 3.3 V.",
"recommendation": "Tie PSEL high.",
}],
"checked_areas": [],
},
"U1",
"PART",
)
f = result.findings[0]
complete_finding(f)
assert f.finding_class == "REVIEW"
assert f.facts
assert f.requirement
assert f.status == "ERROR"
def test_insufficient_evidence_does_not_stay_error():
f = Finding(
designator="U1",
finding="narrow trace",
why="",
status="ERROR",
source="pcb_power_thermal",
rule_id="PE-PWR-001",
evidence_status="INSUFFICIENT",
)
complete_finding(f)
assert f.status != "ERROR"
assert "Insufficient evidence" in f.inference
assert f.finding_class != "RULE" or f.evidence_status == "INSUFFICIENT"
def test_decision_suppresses_rescan_nag():
f = Finding(
designator="U1",
finding="PSEL low",
why="must be high",
status="ERROR",
rule_id="PE-MUX-001",
source="pin_mux_check",
net="PSEL",
aspect="config",
facts="PSEL net is GND",
requirement="PSEL high for 3V3",
)
complete_finding(f)
assert f.status == "ERROR"
d = DesignerDecision(
decision_id="DEC-1",
fingerprint=finding_fingerprint(f),
rule_id="PE-MUX-001",
designator="U1",
net="PSEL",
aspect="config",
intent="wontfix",
reason="PSEL low intentional",
)
apply_decisions([f], [d])
assert f.suppressed is True
assert f.status == "INFO"
assert f.finding_class == "INFO"
assert "PSEL low intentional" in f.inference
def test_schematic_assign_ids_fills_engine_fields():
f = Finding(
designator="U3",
finding="NC tied",
why="NC must float",
status="ERROR",
rule_id="PE-NC-001",
source="nc_pin_check",
)
assign_finding_ids([f])
assert f.finding_id == "U3-001"
assert f.facts == "NC tied"
assert f.requirement
assert f.provenance == "MANDATORY"
assert f.finding_class == "RULE"
assert f.confidence is not None
def test_same_object_pcb_and_schema():
assert lookup_rule("PE-NC-001").domain == "schema"
assert lookup_rule("PE-LAY-001").domain == "pcb"
sch = Finding(designator="U1", finding="x", status="INFO", rule_id="PE-NC-001", source="nc_pin_check")
pcb = Finding(designator="U1", finding="y", status="INFO", rule_id="PE-LAY-001", source="pcb_net_match")
complete_finding(sch)
complete_finding(pcb)
for obj in (sch, pcb):
assert obj.facts
assert obj.finding_class
assert obj.provenance
assert obj.evidence_status
+23
View File
@@ -57,6 +57,29 @@ def test_new_fields_round_trip_json():
assert again.variant == "DNP"
def test_engine_fields_round_trip():
f = Finding(
designator="U1",
finding="x",
status="WARNING",
facts="net GND",
requirement="PSEL high",
inference="intentional?",
provenance="RECOMMENDED",
finding_class="RISK",
confidence=0.4,
evidence_status="SUFFICIENT",
calculation="n/a",
assumptions=["firmware unknown"],
action="leave PSEL low",
)
again = Finding.model_validate(json.loads(f.model_dump_json()))
assert again.facts == "net GND"
assert again.provenance == "RECOMMENDED"
assert again.finding_class == "RISK"
assert again.assumptions == ["firmware unknown"]
def test_unknown_extra_keys_do_not_break_legacy_payloads():
f = Finding.model_validate(
{