From 3cc2e5469bf29c677462478e2f24d8dd507ae69b Mon Sep 17 00:00:00 2001
From: Michele Bigi
Date: Tue, 22 Sep 2026 12:12:25 +0200
Subject: [PATCH] Add protocol-certification M2 L0 structural certifier
(2.66.0).
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Presence and connection only. FAIL solely on missing MANDATORY structure.
AXI4 internal is NOT_APPLICABLE, not FAIL. RECOMMENDED never FAIL. No L1–L3
and no invented ohms/mm/ps.
---
.../src/backend/periscopex/finding_engine.py | 4 +
.../src/backend/periscopex/protocol_l0.py | 314 ++++++++++++++++++
.../backend/periscopex/protocol_recognize.py | 20 +-
.../src/backend/services/pcb_pipeline.py | 6 +-
periscope/src/backend/services/validation.py | 8 +-
periscope/src/frontend/content/changelog.md | 7 +
periscope/src/frontend/package.json | 2 +-
.../components/report/protocol-section.tsx | 12 +-
.../src/frontend/src/lib/layout-finding.ts | 2 +
periscope/src/frontend/src/lib/version.ts | 2 +-
tests/pcb/test_protocol_l0_m2.py | 155 +++++++++
tests/pcb/test_protocol_recognize_m1.py | 3 -
12 files changed, 506 insertions(+), 29 deletions(-)
create mode 100644 periscope/src/backend/periscopex/protocol_l0.py
create mode 100644 tests/pcb/test_protocol_l0_m2.py
diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py
index d3518ee..4d399fa 100644
--- a/periscope/src/backend/periscopex/finding_engine.py
+++ b/periscope/src/backend/periscopex/finding_engine.py
@@ -290,6 +290,10 @@ def _seed() -> None:
requirement="Via series inductance from drill, span, and dielectric height.")
_add("PE-AF-061", "RECOMMENDED", "RISK", domain="pcb",
requirement="Via stub length vs λ/20 when span and f are FACT.")
+ _add("PE-PRT-L0-001", "MANDATORY", "RULE", domain="pcb",
+ requirement="Protocol nets required at L0 must exist and connect two components.")
+ _add("PE-PRT-L0-002", "TYPICAL", "INFO", domain="pcb",
+ requirement="Internal interconnect is NOT_APPLICABLE for PCB routing.")
_seed()
diff --git a/periscope/src/backend/periscopex/protocol_l0.py b/periscope/src/backend/periscopex/protocol_l0.py
new file mode 100644
index 0000000..7d36204
--- /dev/null
+++ b/periscope/src/backend/periscopex/protocol_l0.py
@@ -0,0 +1,314 @@
+"""M2: L0 STRUCTURAL protocol certifier. Presence and connection only.
+
+FAIL only when a MANDATORY structural net/group is missing or unconnected.
+Internal interconnects (AXI4, AMBA, HBM DQ) are NOT_APPLICABLE, never FAIL.
+RECOMMENDED never FAIL. No L1–L3. No invented ohms/mm/ps.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+from backend.periscopex.finding_engine import complete_finding
+from backend.periscopex.models import DesignGraph, Finding
+from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
+from backend.periscopex.protocol_catalog import (
+ SCHEMA_VERSION,
+ PhysicalInterface,
+ ProtocolCatalog,
+ ProtocolCertificationSection,
+ ProtocolConstraint,
+ empty_protocol_section,
+ load_catalog,
+ map_protocol_outcome,
+)
+from backend.periscopex.protocol_recognize import (
+ PhysicalBusInstance,
+ recognize_physical_buses,
+)
+
+PACK_MACROPHASE = "M2"
+SOURCE = "protocol_l0"
+LEVEL = "L0"
+
+L0_CHECKS = frozenset({
+ "topology",
+ "byte_lane_mapping",
+ "cmd_dat_grouping",
+ "clk_reference",
+ "cc_rd_rp",
+ "vbus_gnd",
+ "pcb_routing",
+ "pcb_individual_dq_routing",
+ "dq_group",
+ "superspeed_pairs",
+})
+
+_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I)
+_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I)
+_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I)
+_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I)
+
+MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
+
+
+class L0CheckResult(BaseModel):
+ check: str
+ level: Literal["L0"] = "L0"
+ result: Literal[
+ "PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE", "VENDOR_DEPENDENT",
+ ]
+ mandatory: MandatoryClass
+ nets: list[str] = Field(default_factory=list)
+ notes: str = ""
+ finding_class: str = ""
+ status: str = ""
+
+
+def _leaf(name: str) -> str:
+ n = normalize_kicad_hierarchy_net(name)
+ return n.split("/")[-1] if n else ""
+
+
+def _net_present(graph: DesignGraph, name: str) -> bool:
+ return name in graph.nets
+
+
+def _net_connected(graph: DesignGraph, name: str) -> bool:
+ net = graph.nets.get(name)
+ if not net:
+ return False
+ refs = {p.component_ref for p in net.pins if p.component_ref}
+ return len(refs) >= 2
+
+
+def _constraint_for(iface: PhysicalInterface, check: str) -> ProtocolConstraint | None:
+ aliases = {
+ "cc_rd_rp": "cc_termination",
+ "pcb_routing": "pcb_routing",
+ "pcb_individual_dq_routing": "pcb_individual_dq_routing",
+ "superspeed_pairs": "superspeed_pairs",
+ "topology": "topology",
+ "byte_lane_mapping": "topology",
+ }
+ want = aliases.get(check, check)
+ for c in iface.constraints:
+ if c.parameter == want or c.parameter == check:
+ return c
+ return None
+
+
+def _mandatory(iface: PhysicalInterface, check: str) -> MandatoryClass:
+ c = _constraint_for(iface, check)
+ if c:
+ return c.mandatory
+ if check in {"superspeed_pairs", "pcb_routing", "pcb_individual_dq_routing"}:
+ return "INFORMATIONAL"
+ return "MANDATORY"
+
+
+def _pack(check: str, result: str, mandatory: MandatoryClass, *,
+ nets: list[str] | None = None, notes: str = "") -> L0CheckResult:
+ if result == "FAIL" and mandatory != "MANDATORY":
+ result = "WARNING"
+ cls, status, _ev = map_protocol_outcome(result, mandatory) # type: ignore[arg-type]
+ return L0CheckResult(
+ check=check, result=result, mandatory=mandatory, # type: ignore[arg-type]
+ nets=nets or [], notes=notes,
+ finding_class=cls, status=status,
+ )
+
+
+def _structural_nets_ok(graph: DesignGraph, names: list[str]) -> tuple[bool, str]:
+ missing = [n for n in names if not _net_present(graph, n)]
+ if missing:
+ return False, f"missing net(s): {', '.join(missing)}"
+ dangling = [n for n in names if not _net_connected(graph, n)]
+ if dangling:
+ return False, f"unconnected net(s): {', '.join(dangling)}"
+ return True, "present and connected"
+
+
+def certify_instance_l0(
+ graph: DesignGraph,
+ inst: PhysicalBusInstance,
+ iface: PhysicalInterface,
+) -> list[L0CheckResult]:
+ if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
+ return [_pack(
+ "pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
+ notes="Internal interconnect — not PCB routing. Not FAIL.",
+ )]
+ out: list[L0CheckResult] = []
+ checks = [c for c in iface.required_checks if c in L0_CHECKS]
+ if not checks:
+ checks = ["topology"]
+ for check in checks:
+ mand = _mandatory(iface, check)
+ out.append(_run_l0_check(graph, inst, check, mand))
+ return out
+
+
+def _run_l0_check(
+ graph: DesignGraph,
+ inst: PhysicalBusInstance,
+ check: str,
+ mand: MandatoryClass,
+) -> L0CheckResult:
+ if check in {"pcb_routing", "pcb_individual_dq_routing"}:
+ return _pack(check, "NOT_APPLICABLE", mand,
+ notes="Not a PCB-routed individual channel at L0.")
+ if check == "superspeed_pairs":
+ ss = [n for n in graph.nets if _SS_RE.search(_leaf(n))]
+ return _pack(
+ check, "NOT_APPLICABLE", mand, nets=ss,
+ notes="USB2-only physical interface: SuperSpeed pairs not required.",
+ )
+ if check == "topology":
+ names: list[str] = []
+ for g in inst.groups:
+ if g.kind == "DIFFERENTIAL_PAIR":
+ names.extend(g.nets)
+ if not names:
+ names = list(inst.nets)
+ if len(names) < 2:
+ return _pack(
+ check, "FAIL", mand, nets=names,
+ notes="MANDATORY differential pair or bus nets not present.",
+ )
+ ok, msg = _structural_nets_ok(graph, names)
+ return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
+ if check == "byte_lane_mapping" or check == "dq_group":
+ lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
+ if not lanes:
+ return _pack(
+ check, "FAIL", mand,
+ notes="MANDATORY byte-lane / DQ group not reconstructed.",
+ )
+ names = [n for g in lanes for n in g.nets]
+ ok, msg = _structural_nets_ok(graph, names)
+ return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
+ if check == "cmd_dat_grouping":
+ names = [n for g in inst.groups for n in g.nets]
+ if not names:
+ return _pack(check, "FAIL", mand, notes="No CMD/DAT group nets.")
+ ok, msg = _structural_nets_ok(graph, names)
+ return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
+ if check == "clk_reference":
+ clocks = [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets]
+ if not clocks:
+ return _pack(check, "FAIL", mand, notes="Clock reference net missing.")
+ ok, msg = _structural_nets_ok(graph, clocks)
+ return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg)
+ if check == "cc_rd_rp":
+ ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))]
+ if len(ccs) < 1:
+ return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.")
+ ok, msg = _structural_nets_ok(graph, ccs)
+ return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg)
+ if check == "vbus_gnd":
+ vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))]
+ gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))]
+ names = vbus + gnd
+ if not vbus or not gnd:
+ return _pack(
+ check, "FAIL", mand, nets=names,
+ notes="VBUS and/or GND net not present.",
+ )
+ ok, msg = _structural_nets_ok(graph, names)
+ return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
+ return _pack(check, "NOT_APPLICABLE", mand, notes="Not an L0 structural check.")
+
+
+def l0_findings(
+ inst: PhysicalBusInstance,
+ results: list[L0CheckResult],
+) -> list[Finding]:
+ out: list[Finding] = []
+ designator = inst.host_ref or inst.physical_interface_id
+ for r in results:
+ if r.result == "PASS":
+ continue
+ if r.result == "FAIL" and r.mandatory != "MANDATORY":
+ continue
+ status = r.status if r.status in {"ERROR", "WARNING", "INFO"} else "INFO"
+ if r.result == "FAIL":
+ rule_id = "PE-PRT-L0-001"
+ finding = (
+ f"L0 structural: {r.check} {r.notes or 'missing MANDATORY net/connection'}."
+ )
+ else:
+ rule_id = "PE-PRT-L0-002"
+ finding = f"L0 {r.result}: {r.check} — {r.notes}".strip()
+ f = Finding(
+ designator=designator,
+ mpn="",
+ aspect="protocol_l0",
+ finding=finding,
+ why=r.notes,
+ status=status,
+ source=SOURCE,
+ net=r.nets[0] if r.nets else None,
+ rule_id=rule_id,
+ facts=finding,
+ requirement=(
+ "MANDATORY protocol nets must exist and connect at least two components."
+ if r.result == "FAIL"
+ else "Internal protocol is not certified as PCB routing."
+ ),
+ inference=f"{LEVEL} {r.result} (not electrical; no invented Z).",
+ provenance="MANDATORY" if r.mandatory == "MANDATORY" else "TYPICAL",
+ finding_class=r.finding_class if r.finding_class in {"RULE", "RISK", "REVIEW", "INFO"} else "INFO",
+ )
+ out.append(complete_finding(f))
+ return out
+
+
+def certify_l0(
+ graph: DesignGraph,
+ instances: list[PhysicalBusInstance],
+ catalog: ProtocolCatalog | None = None,
+) -> tuple[dict[str, list[L0CheckResult]], list[Finding]]:
+ cat = catalog or load_catalog()
+ ifaces = {p.id: p for p in cat.physical_interfaces}
+ by_id: dict[str, list[L0CheckResult]] = {}
+ findings: list[Finding] = []
+ for inst in instances:
+ iface = ifaces.get(inst.physical_interface_id)
+ if iface is None:
+ continue
+ rows = certify_instance_l0(graph, inst, iface)
+ by_id[inst.instance_id] = rows
+ findings.extend(l0_findings(inst, rows))
+ return by_id, findings
+
+
+def protocol_exam(
+ graph: DesignGraph | None,
+ catalog: ProtocolCatalog | None = None,
+) -> tuple[ProtocolCertificationSection, list[Finding]]:
+ if graph is None:
+ return empty_protocol_section(), []
+ cat = catalog or load_catalog()
+ instances = recognize_physical_buses(graph, cat)
+ if not instances:
+ sec = empty_protocol_section()
+ sec.macrophase = PACK_MACROPHASE
+ return sec, []
+ l0, findings = certify_l0(graph, instances, cat)
+ dumps: list[dict[str, Any]] = []
+ for inst in instances:
+ row = inst.model_dump()
+ row["l0_checks"] = [c.model_dump() for c in l0.get(inst.instance_id, [])]
+ dumps.append(row)
+ sec = ProtocolCertificationSection(
+ schema_version=SCHEMA_VERSION,
+ macrophase=PACK_MACROPHASE,
+ recognized_instances=dumps,
+ message="",
+ max_level_reached=LEVEL,
+ )
+ return sec, findings
diff --git a/periscope/src/backend/periscopex/protocol_recognize.py b/periscope/src/backend/periscopex/protocol_recognize.py
index e6f49c3..aede08b 100644
--- a/periscope/src/backend/periscopex/protocol_recognize.py
+++ b/periscope/src/backend/periscopex/protocol_recognize.py
@@ -14,11 +14,8 @@ from pydantic import BaseModel, Field
from backend.periscopex.models import Component, DesignGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.protocol_catalog import (
- EMPTY_PROTOCOL_MESSAGE,
- SCHEMA_VERSION,
ProtocolCatalog,
ProtocolCertificationSection,
- empty_protocol_section,
load_catalog,
)
@@ -548,17 +545,6 @@ def protocol_section_for_graph(
graph: DesignGraph | None,
catalog: ProtocolCatalog | None = None,
) -> ProtocolCertificationSection:
- if graph is None:
- return empty_protocol_section()
- instances = recognize_physical_buses(graph, catalog)
- if not instances:
- sec = empty_protocol_section()
- sec.macrophase = PACK_MACROPHASE
- return sec
- return ProtocolCertificationSection(
- schema_version=SCHEMA_VERSION,
- macrophase=PACK_MACROPHASE,
- recognized_instances=[i.model_dump() for i in instances],
- message="",
- max_level_reached=None,
- )
+ from backend.periscopex.protocol_l0 import protocol_exam
+
+ return protocol_exam(graph, catalog)[0]
diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py
index ab53eca..ffa66c8 100644
--- a/periscope/src/backend/services/pcb_pipeline.py
+++ b/periscope/src/backend/services/pcb_pipeline.py
@@ -28,7 +28,7 @@ from backend.periscopex.layout_rules import needs_layout_rules_refresh
from backend.periscopex.models import (
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
)
-from backend.periscopex.protocol_recognize import protocol_section_for_graph
+from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows
from backend.periscopex.af_trace_check import check_af_traces
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
@@ -376,6 +376,8 @@ async def run_pcb_pipeline(
return
_step(project_id, "write_report", "running")
+ proto_section, proto_findings = protocol_exam(graph)
+ findings.extend(proto_findings)
assign_pcb_finding_ids(findings)
annotate_findings_cad(findings, cad_index_from_graph(graph))
dec_path = ws.local_path("decisions.json")
@@ -394,7 +396,7 @@ async def run_pcb_pipeline(
summary=summary,
coverage=coverage,
not_reviewed=skipped + si_skip,
- protocol_certification=protocol_section_for_graph(graph).model_dump(),
+ protocol_certification=proto_section.model_dump(),
)
report_path = ws.local_path("pcb_report.json")
report_path.write_text(report.model_dump_json(indent=2) + "\n")
diff --git a/periscope/src/backend/services/validation.py b/periscope/src/backend/services/validation.py
index a89d151..d4c2641 100644
--- a/periscope/src/backend/services/validation.py
+++ b/periscope/src/backend/services/validation.py
@@ -28,7 +28,7 @@ from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.led_current_check import check_led_current
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
from backend.periscopex.models import ComponentType, DesignGraph, Finding, ValidationReport
-from backend.periscopex.protocol_recognize import protocol_section_for_graph
+from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.nc_pin_check import check_nc_pins
from backend.periscopex.parsers import ic_mpn_skip_reason
from backend.periscopex.passive_rail_check import (
@@ -209,6 +209,8 @@ async def validate_design_async(
return clean
def _write_report(paused: bool = False) -> ValidationReport:
+ proto_section, proto_findings = protocol_exam(graph)
+ all_findings.extend(proto_findings)
annotate_findings_cad(all_findings, graph.cad_index)
assign_finding_ids(all_findings)
dec_path = existing_path.with_name("decisions.json")
@@ -229,7 +231,7 @@ async def validate_design_async(
coverage=_sanitize_coverage(all_coverage),
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
- protocol_certification=protocol_section_for_graph(graph).model_dump(),
+ protocol_certification=proto_section.model_dump(),
)
except Exception:
report = ValidationReport(
@@ -240,7 +242,7 @@ async def validate_design_async(
coverage={},
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
- protocol_certification=protocol_section_for_graph(graph).model_dump(),
+ protocol_certification=proto_section.model_dump(),
)
report_dict = json.loads(report.model_dump_json(indent=2))
if preserved_comments is not None:
diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md
index d664b70..34ec252 100644
--- a/periscope/src/frontend/content/changelog.md
+++ b/periscope/src/frontend/content/changelog.md
@@ -2,6 +2,13 @@
What's new in Periscope.
+## 2.66.0 — 2026-09-22 — Protocol certification M2 (L0 structural)
+
+L0 STRUCTURAL certifier: nets must exist and connect at least two components. FAIL only for missing **MANDATORY** structure. AXI4 / internal interconnects are **NOT_APPLICABLE** (not FAIL). RECOMMENDED never FAIL. No L1–L3, no invented ohms/mm/ps.
+
+- [New] `protocol_l0.py`; `PE-PRT-L0-001` / `PE-PRT-L0-002`.
+- [New] pytest `tests/pcb/test_protocol_l0_m2.py`.
+
## 2.65.0 — 2026-09-22 — Protocol certification M1 (instance recognition)
`physical_bus_instance` from declaration / silicon / part / pin / net / pairs. Output: instances, signal groups (DIFF pair, byte lane, clock/address), confidence. Ambiguity is **REVIEW**, never FAIL. AXI4 internal is recognised with `pcb_relevant: NO` and **no PCB nets**. No L0–L3 certifier. No invented ohms.
diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json
index f5c8272..b9b4f64 100644
--- a/periscope/src/frontend/package.json
+++ b/periscope/src/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
- "version": "2.65.0",
+ "version": "2.66.0",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
diff --git a/periscope/src/frontend/src/components/report/protocol-section.tsx b/periscope/src/frontend/src/components/report/protocol-section.tsx
index 6f347be..4994e69 100644
--- a/periscope/src/frontend/src/components/report/protocol-section.tsx
+++ b/periscope/src/frontend/src/components/report/protocol-section.tsx
@@ -13,8 +13,8 @@ export function ProtocolSection({
Protocolli
- Logical protocol vs physical interface. Recognition only (M1) —
- no L0–L3 certifier, no invented impedance.
+ Logical protocol vs physical interface. L0 structural only —
+ presence and connection, no invented impedance.
{instances.length === 0 ? (
{message}
@@ -43,6 +43,14 @@ export function ProtocolSection({
.join(", ")}
) : null}
+ {Array.isArray(row.l0_checks) && row.l0_checks.length > 0 ? (
+
+ L0{" "}
+ {(row.l0_checks as Record[])
+ .map((c) => `${String(c.check)}=${String(c.result)}`)
+ .join("; ")}
+
+ ) : null}
);
})}
diff --git a/periscope/src/frontend/src/lib/layout-finding.ts b/periscope/src/frontend/src/lib/layout-finding.ts
index 7249cec..c43a612 100644
--- a/periscope/src/frontend/src/lib/layout-finding.ts
+++ b/periscope/src/frontend/src/lib/layout-finding.ts
@@ -26,6 +26,7 @@ export function isLayoutFinding(f: {
f.source === "pdn_check" ||
f.source === "af_ai_hf" ||
f.source === "af_trace_check" ||
+ f.source === "protocol_l0" ||
rid.startsWith("PE-PLC") ||
rid.startsWith("PE-LAY") ||
rid.startsWith("PE-SI") ||
@@ -50,6 +51,7 @@ export function isLayoutFinding(f: {
rid.startsWith("PE-ANT") ||
rid.startsWith("PE-PDN") ||
rid.startsWith("PE-AF") ||
+ rid.startsWith("PE-PRT") ||
/^PE-BOM-01[0-4]$/.test(rid)
);
}
diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts
index bb8f7ee..6e343fd 100644
--- a/periscope/src/frontend/src/lib/version.ts
+++ b/periscope/src/frontend/src/lib/version.ts
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
-export const APP_VERSION = "2.65.0";
+export const APP_VERSION = "2.66.0";
export const APP_VERSION_DATE = "2026-09-22";
diff --git a/tests/pcb/test_protocol_l0_m2.py b/tests/pcb/test_protocol_l0_m2.py
new file mode 100644
index 0000000..1ab4ab5
--- /dev/null
+++ b/tests/pcb/test_protocol_l0_m2.py
@@ -0,0 +1,155 @@
+"""M2 L0 structural certifier: presence/connection; AXI N/A; RECOMMENDED not FAIL."""
+
+from __future__ import annotations
+
+import json
+
+from backend.periscopex.models import (
+ Component,
+ ComponentType,
+ DesignGraph,
+ Net,
+ NetType,
+ PinConnection,
+)
+from backend.periscopex.protocol_l0 import (
+ PACK_MACROPHASE,
+ _pack,
+ certify_l0,
+ protocol_exam,
+)
+from backend.periscopex.protocol_recognize import recognize_physical_buses
+
+
+def _ic(ref: str, pins: dict[str, str], *, mpn: str = "", value: str = "") -> Component:
+ return Component(
+ reference=ref, value=value or mpn, footprint="",
+ component_type=ComponentType.IC, mpn=mpn or None, pins=pins,
+ )
+
+
+def _net(name: str, *pairs: tuple[str, str]) -> Net:
+ return Net(
+ name=name, net_type=NetType.SIGNAL,
+ pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
+ )
+
+
+def _usb_connected() -> DesignGraph:
+ return DesignGraph(
+ components={
+ "U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}, mpn="CH340E"),
+ "J2": Component(
+ reference="J2", value="USB2_TypeA",
+ footprint="USB_A",
+ component_type=ComponentType.CONNECTOR,
+ pins={"2": "USB_D+", "3": "USB_D-"},
+ ),
+ },
+ nets={
+ "USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "2")),
+ "USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "3")),
+ },
+ )
+
+
+def _usb_dangling_minus() -> DesignGraph:
+ return DesignGraph(
+ components={
+ "U1": _ic("U1", {"1": "USB_D+"}, mpn="CH340E"),
+ "J2": Component(
+ reference="J2", value="USB2_TypeA",
+ footprint="USB_A",
+ component_type=ComponentType.CONNECTOR,
+ pins={"2": "USB_D+", "3": "USB_D-"},
+ ),
+ },
+ nets={
+ "USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "2")),
+ "USB_D-": _net("USB_D-", ("J2", "3")),
+ },
+ )
+
+
+def _axi_graph() -> DesignGraph:
+ return DesignGraph(
+ components={
+ "U3": _ic("U3", {"1": "AXI_AWVALID"}, mpn="XC7A100T", value="AXI4 interconnect"),
+ },
+ nets={"AXI_AWVALID": _net("AXI_AWVALID", ("U3", "1"))},
+ )
+
+
+def _ddr_one_ended() -> DesignGraph:
+ pins = {str(i): f"DDR4_DQ{i}" for i in range(8)}
+ pins.update({"8": "DDR4_DQS0_P", "9": "DDR4_DQS0_N"})
+ return DesignGraph(
+ components={"U5": _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4")},
+ nets={n: _net(n, ("U5", p)) for p, n in pins.items()},
+ )
+
+
+def _l0_for(graph: DesignGraph, logical_sub: str) -> list:
+ insts = recognize_physical_buses(graph)
+ by, _ = certify_l0(graph, insts)
+ rows = []
+ for inst in insts:
+ if logical_sub in inst.logical_protocol_id:
+ rows.extend(by.get(inst.instance_id, []))
+ return rows
+
+
+def test_usb_connected_pair_l0_topology_pass():
+ rows = _l0_for(_usb_connected(), "usb2")
+ topo = [r for r in rows if r.check == "topology"]
+ assert topo
+ assert topo[0].result == "PASS"
+ assert topo[0].level == "L0"
+ blob = json.dumps(topo[0].model_dump())
+ assert "ohm" not in blob.lower()
+ assert "90" not in blob
+
+
+def test_usb_unconnected_dminus_l0_fail_mandatory():
+ g = _usb_dangling_minus()
+ insts = recognize_physical_buses(g)
+ usb = [i for i in insts if "usb" in i.logical_protocol_id]
+ assert usb
+ by, findings = certify_l0(g, insts)
+ rows = [r for i in usb for r in by[i.instance_id]]
+ topo = [r for r in rows if r.check == "topology"]
+ assert topo
+ assert topo[0].result == "FAIL"
+ assert topo[0].mandatory == "MANDATORY"
+ assert any(f.status == "ERROR" and f.rule_id == "PE-PRT-L0-001" for f in findings)
+
+
+def test_axi_internal_l0_not_applicable_not_fail():
+ rows = _l0_for(_axi_graph(), "axi4")
+ assert rows
+ assert all(r.result == "NOT_APPLICABLE" for r in rows)
+ assert all(r.result != "FAIL" for r in rows)
+ sec, findings = protocol_exam(_axi_graph())
+ assert sec.max_level_reached == "L0"
+ assert sec.macrophase == PACK_MACROPHASE
+ assert all(f.status != "ERROR" for f in findings)
+ assert all("AXI_AWVALID" not in (f.net or "") for f in findings if f.rule_id == "PE-PRT-L0-001")
+
+
+def test_ddr_unconnected_memory_fails_l0_connection():
+ rows = _l0_for(_ddr_one_ended(), "ddr4")
+ mapping = [r for r in rows if r.check == "byte_lane_mapping"]
+ assert mapping
+ assert mapping[0].result == "FAIL"
+ assert "unconnected" in mapping[0].notes
+
+
+def test_recommended_never_parses_as_fail():
+ rec = _pack("return_path", "FAIL", "RECOMMENDED", notes="missing")
+ assert rec.result != "FAIL"
+ assert rec.status != "ERROR"
+
+
+def test_l0_does_not_run_impedance():
+ rows = _l0_for(_usb_connected(), "usb2")
+ assert not any(r.check in {"differential_impedance", "intra_pair_skew", "length"} for r in rows)
diff --git a/tests/pcb/test_protocol_recognize_m1.py b/tests/pcb/test_protocol_recognize_m1.py
index 59d2c98..a0409c4 100644
--- a/tests/pcb/test_protocol_recognize_m1.py
+++ b/tests/pcb/test_protocol_recognize_m1.py
@@ -13,7 +13,6 @@ from backend.periscopex.models import (
PinConnection,
)
from backend.periscopex.protocol_recognize import (
- PACK_MACROPHASE,
protocol_section_for_graph,
recognize_physical_buses,
)
@@ -143,7 +142,6 @@ def test_empty_graph_keeps_nessun_protocollo():
sec = protocol_section_for_graph(DesignGraph())
assert sec.message == "nessun protocollo riconosciuto"
assert sec.recognized_instances == []
- assert sec.macrophase == PACK_MACROPHASE
blob = json.dumps(sec.model_dump())
assert "ohm" not in blob.lower()
@@ -151,7 +149,6 @@ def test_empty_graph_keeps_nessun_protocollo():
def test_report_section_lists_usb_without_z():
sec = protocol_section_for_graph(_usb_graph())
assert sec.recognized_instances
- assert sec.max_level_reached is None
blob = json.dumps(sec.model_dump())
assert "90" not in blob
assert "Zdiff" not in blob