diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py
index 4d399fa..c385d2f 100644
--- a/periscope/src/backend/periscopex/finding_engine.py
+++ b/periscope/src/backend/periscopex/finding_engine.py
@@ -294,6 +294,12 @@ def _seed() -> None:
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.")
+ _add("PE-PRT-L1-001", "TYPICAL", "REVIEW", domain="pcb",
+ requirement="L1 geometric skip: missing PCB geometry or NUMERIC/DESIGN millimetre limit.")
+ _add("PE-PRT-L1-002", "TYPICAL", "REVIEW", domain="pcb",
+ requirement="L1 grouping (DQ/DQS / byte-lane) cannot be reconstructed — not PASS.")
+ _add("PE-PRT-L1-003", "MANDATORY", "RULE", domain="pcb",
+ requirement="L1 geometric length/via vs NUMERIC or DESIGN millimetre limit.")
_seed()
diff --git a/periscope/src/backend/periscopex/protocol_l0.py b/periscope/src/backend/periscopex/protocol_l0.py
index 7d36204..f0ef665 100644
--- a/periscope/src/backend/periscopex/protocol_l0.py
+++ b/periscope/src/backend/periscopex/protocol_l0.py
@@ -13,7 +13,7 @@ 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.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.protocol_catalog import (
SCHEMA_VERSION,
@@ -93,6 +93,12 @@ def _constraint_for(iface: PhysicalInterface, check: str) -> ProtocolConstraint
"superspeed_pairs": "superspeed_pairs",
"topology": "topology",
"byte_lane_mapping": "topology",
+ "byte_lane_skew": "byte_to_byte_skew",
+ "length": "max_length",
+ "clock_length": "max_length",
+ "data_length": "max_length",
+ "via_count": "vias",
+ "stub_length": "stub_length",
}
want = aliases.get(check, check)
for c in iface.constraints:
@@ -289,26 +295,36 @@ def certify_l0(
def protocol_exam(
graph: DesignGraph | None,
catalog: ProtocolCatalog | None = None,
+ layout: LayoutGraph | None = None,
) -> tuple[ProtocolCertificationSection, list[Finding]]:
+ from backend.periscopex.protocol_l1 import (
+ PACK_MACROPHASE as L1_MACRO,
+ LEVEL as L1_LEVEL,
+ certify_l1,
+ )
+
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
+ sec.macrophase = L1_MACRO
return sec, []
l0, findings = certify_l0(graph, instances, cat)
+ l1, l1_findings_out = certify_l1(graph, instances, layout, cat)
+ findings.extend(l1_findings_out)
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, [])]
+ row["l1_checks"] = [c.model_dump() for c in l1.get(inst.instance_id, [])]
dumps.append(row)
sec = ProtocolCertificationSection(
schema_version=SCHEMA_VERSION,
- macrophase=PACK_MACROPHASE,
+ macrophase=L1_MACRO,
recognized_instances=dumps,
message="",
- max_level_reached=LEVEL,
+ max_level_reached=L1_LEVEL,
)
return sec, findings
diff --git a/periscope/src/backend/periscopex/protocol_l1.py b/periscope/src/backend/periscopex/protocol_l1.py
new file mode 100644
index 0000000..3adab4c
--- /dev/null
+++ b/periscope/src/backend/periscopex/protocol_l1.py
@@ -0,0 +1,486 @@
+"""M3: L1 GEOMETRIC protocol certifier. Length, vias, layer, grouping, skew.
+
+Skew is compared only when the constraint is NUMERIC (length unit) or an
+explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
+PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
+RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
+Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from backend.periscopex.finding_engine import complete_finding
+from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
+from backend.periscopex.protocol_catalog import (
+ PhysicalInterface,
+ ProtocolCatalog,
+ ProtocolConstraint,
+ load_catalog,
+ map_protocol_outcome,
+)
+from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
+from backend.periscopex.protocol_recognize import PhysicalBusInstance, SignalGroup
+from backend.periscopex.si_check import net_length_mm
+
+PACK_MACROPHASE = "M3"
+SOURCE = "protocol_l1"
+LEVEL = "L1"
+
+L1_CHECKS = frozenset({
+ "length",
+ "clock_length",
+ "data_length",
+ "intra_pair_skew",
+ "pair_skew",
+ "dq_to_dqs_skew",
+ "byte_lane_skew",
+ "ck_to_command_skew",
+ "clock_to_data_skew",
+ "vias",
+ "via_count",
+ "stubs",
+ "stub_length",
+ "topology",
+ "byte_lane_mapping",
+ "dq_group",
+ "dqs_relationship_if_present",
+ "cmd_dat_grouping",
+ "lane_relationships",
+})
+
+_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
+
+MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
+L1Result = Literal[
+ "PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
+ "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
+]
+
+
+class L1CheckResult(BaseModel):
+ check: str
+ level: Literal["L1"] = "L1"
+ result: L1Result
+ mandatory: MandatoryClass
+ nets: list[str] = Field(default_factory=list)
+ measured_mm: float | None = None
+ limit_mm: float | None = None
+ margin_mm: float | None = None
+ value_kind: str = ""
+ notes: str = ""
+ finding_class: str = ""
+ status: str = ""
+
+
+def _pack(
+ check: str,
+ result: str,
+ mandatory: MandatoryClass,
+ *,
+ nets: list[str] | None = None,
+ measured_mm: float | None = None,
+ limit_mm: float | None = None,
+ value_kind: str = "",
+ notes: str = "",
+) -> L1CheckResult:
+ if result == "FAIL" and mandatory != "MANDATORY":
+ result = "WARNING"
+ mapped = result
+ if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}:
+ mapped = "VENDOR_DEPENDENT"
+ cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type]
+ margin = None
+ if measured_mm is not None and limit_mm is not None:
+ margin = limit_mm - measured_mm
+ return L1CheckResult(
+ check=check,
+ result=result, # type: ignore[arg-type]
+ mandatory=mandatory,
+ nets=nets or [],
+ measured_mm=measured_mm,
+ limit_mm=limit_mm,
+ margin_mm=margin,
+ value_kind=value_kind,
+ notes=notes,
+ finding_class=cls,
+ status=status,
+ )
+
+
+def geometric_verdict(constraint: ProtocolConstraint | None, measured_mm: float | None) -> str:
+ """PASS/FAIL only with NUMERIC length limit or DESIGN LIMIT. Never invent ps."""
+ if constraint is None:
+ return "UNKNOWN"
+ kind = constraint.value_kind
+ unit = (constraint.unit or "").strip().lower()
+ design = constraint.limit_kind == "DESIGN" and constraint.value is not None
+ numeric = kind == "NUMERIC" and constraint.value is not None
+ if numeric or design:
+ if unit in _TIME_UNITS:
+ return "UNKNOWN"
+ if measured_mm is None:
+ return "UNKNOWN"
+ if measured_mm <= constraint.value:
+ return "PASS"
+ return "FAIL"
+ if kind in {
+ "UNKNOWN", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
+ "VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE",
+ }:
+ return kind
+ return "UNKNOWN"
+
+
+def _len(layout: LayoutGraph, net: str) -> float:
+ return net_length_mm(layout, net)
+
+
+def _via_count(layout: LayoutGraph, net: str) -> int:
+ return sum(1 for v in layout.vias if v.net == net)
+
+
+def _layers(layout: LayoutGraph, net: str) -> list[str]:
+ return sorted({s.layer for s in layout.segments if s.net == net and s.layer})
+
+
+def _instance_nets(inst: PhysicalBusInstance) -> list[str]:
+ names: list[str] = []
+ for g in inst.groups:
+ names.extend(g.nets)
+ names.extend(inst.nets)
+ # preserve order, unique
+ seen: set[str] = set()
+ out: list[str] = []
+ for n in names:
+ if n not in seen:
+ seen.add(n)
+ out.append(n)
+ return out
+
+
+def _skip_no_geometry(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult:
+ return _pack(
+ check, "UNKNOWN", mand,
+ notes=(
+ f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
+ f"per mancanza di geometria PCB (piste). L1 is geometric, not electrical."
+ ),
+ )
+
+
+def _skip_grouping(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult:
+ return _pack(
+ check, "UNKNOWN", mand,
+ notes=(
+ f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
+ f"per mancanza di grouping (DQ/DQS). Not PASS."
+ ),
+ )
+
+
+def certify_instance_l1(
+ graph: DesignGraph,
+ inst: PhysicalBusInstance,
+ iface: PhysicalInterface,
+ layout: LayoutGraph | None,
+) -> list[L1CheckResult]:
+ if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
+ return [_pack(
+ "pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
+ notes="Internal interconnect — L1 geometric N/A, not FAIL, not electrical.",
+ )]
+ checks = [c for c in iface.required_checks if c in L1_CHECKS]
+ if not checks:
+ checks = ["length", "topology"]
+ return [_run_l1_check(graph, inst, iface, layout, c) for c in checks]
+
+
+def _run_l1_check(
+ graph: DesignGraph,
+ inst: PhysicalBusInstance,
+ iface: PhysicalInterface,
+ layout: LayoutGraph | None,
+ check: str,
+) -> L1CheckResult:
+ mand = _mandatory(iface, check)
+ cons = _constraint_for(iface, check)
+ kind = cons.value_kind if cons else "UNKNOWN"
+ if layout is None:
+ return _skip_no_geometry(check, mand, inst)
+
+ grouping_names = {
+ "byte_lane_mapping", "dq_group", "dqs_relationship_if_present",
+ "dq_to_dqs_skew", "byte_lane_skew",
+ }
+ lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
+ if check in grouping_names and not lanes:
+ return _skip_grouping(check, mand, inst)
+ if check == "cmd_dat_grouping":
+ cmd = [g for g in inst.groups if g.kind in {"COMMAND_GROUP", "ADDRESS_GROUP", "CLOCK_GROUP"}]
+ if not cmd:
+ return _skip_grouping(check, mand, inst)
+ return _pack(
+ check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
+ notes="CMD/DAT grouping present. L1 is not electrical certification.",
+ )
+
+ if check in {"byte_lane_mapping", "dq_group", "dqs_relationship_if_present"}:
+ return _pack(
+ check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
+ notes="Geometric grouping present. L1 is not electrical certification.",
+ )
+
+ if check == "topology":
+ nets = _instance_nets(inst)
+ routed = [n for n in nets if _len(layout, n) > 0]
+ if not routed:
+ return _skip_no_geometry(check, mand, inst)
+ layers = sorted({ly for n in routed for ly in _layers(layout, n)})
+ return _pack(
+ check, "PASS", mand, nets=routed, value_kind=kind,
+ notes=f"Routed on layers {layers}. Geometric topology only, not electrical.",
+ )
+
+ if check in {"length", "clock_length", "data_length"}:
+ nets = _clock_or_data_nets(inst, check)
+ if not nets:
+ nets = _instance_nets(inst)
+ if not nets:
+ return _skip_no_geometry(check, mand, inst)
+ measured = max(_len(layout, n) for n in nets)
+ limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
+ verdict = geometric_verdict(cons, measured)
+ return _pack(
+ check, verdict, mand, nets=nets, measured_mm=measured,
+ limit_mm=limit, value_kind=kind,
+ notes="Geometric length (mm). Not delay; not electrical Z.",
+ )
+
+ if check in {"vias", "via_count"}:
+ nets = _instance_nets(inst)
+ count = sum(_via_count(layout, n) for n in nets)
+ measured = float(count)
+ verdict = geometric_verdict(cons, measured)
+ return _pack(
+ check, verdict, mand, nets=nets, measured_mm=measured,
+ limit_mm=cons.value if cons and cons.value_kind == "NUMERIC" else None,
+ value_kind=kind,
+ notes=f"Via count {count} (geometric). No invented via ampacity.",
+ )
+
+ if check in {"stubs", "stub_length"}:
+ verdict = geometric_verdict(cons, None)
+ if verdict == "FAIL":
+ verdict = "UNKNOWN"
+ return _pack(
+ check, verdict if verdict != "PASS" else "UNKNOWN", mand,
+ nets=_instance_nets(inst), value_kind=kind,
+ notes="Stub length not certified without a NUMERIC/DESIGN millimetre limit. Not invented.",
+ )
+
+ if check in {"intra_pair_skew", "pair_skew"}:
+ return _pair_skew(inst, layout, cons, mand, check, kind)
+
+ if check == "dq_to_dqs_skew":
+ return _dq_dqs_skew(inst, lanes, layout, cons, mand, kind)
+
+ if check == "byte_lane_skew":
+ return _lane_spread(lanes, layout, cons, mand, kind, "byte_lane_skew")
+
+ if check in {"ck_to_command_skew", "clock_to_data_skew", "lane_relationships"}:
+ measured = _group_spread(inst, layout)
+ verdict = geometric_verdict(cons, measured)
+ return _pack(
+ check, verdict, mand, nets=_instance_nets(inst),
+ measured_mm=measured, value_kind=kind,
+ notes="Geometric length spread. No invented ps. Not electrical.",
+ )
+ return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L1 geometric check.")
+
+
+def _clock_or_data_nets(inst: PhysicalBusInstance, check: str) -> list[str]:
+ if check == "clock_length":
+ return [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets]
+ if check == "data_length":
+ return [
+ n for g in inst.groups
+ if g.kind in {"BYTE_LANE", "DATA_LANE", "DIFFERENTIAL_PAIR"}
+ for n in g.nets
+ ]
+ return _instance_nets(inst)
+
+
+def _pair_skew(
+ inst: PhysicalBusInstance,
+ layout: LayoutGraph,
+ cons: ProtocolConstraint | None,
+ mand: MandatoryClass,
+ check: str,
+ kind: str,
+) -> L1CheckResult:
+ pairs = [g for g in inst.groups if g.kind == "DIFFERENTIAL_PAIR"]
+ if not pairs:
+ return _pack(
+ check, "UNKNOWN", mand, value_kind=kind,
+ notes="No differential pair group for geometric skew.",
+ )
+ deltas: list[float] = []
+ nets: list[str] = []
+ for g in pairs:
+ if len(g.nets) < 2:
+ continue
+ a, b = g.nets[0], g.nets[1]
+ deltas.append(abs(_len(layout, a) - _len(layout, b)))
+ nets.extend([a, b])
+ measured = max(deltas) if deltas else None
+ verdict = geometric_verdict(cons, measured)
+ limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
+ return _pack(
+ check, verdict, mand, nets=nets, measured_mm=measured,
+ limit_mm=limit, value_kind=kind,
+ notes="Intra-pair geometric length delta (mm). Not ps, not electrical Z.",
+ )
+
+
+def _dq_dqs_skew(
+ inst: PhysicalBusInstance,
+ lanes: list[SignalGroup],
+ layout: LayoutGraph,
+ cons: ProtocolConstraint | None,
+ mand: MandatoryClass,
+ kind: str,
+) -> L1CheckResult:
+ deltas: list[float] = []
+ nets: list[str] = []
+ for lane in lanes:
+ dqs = [n for k, n in lane.roles.items() if k.upper().startswith("DQS")]
+ dqs_len = [_len(layout, n) for n in dqs]
+ ref = sum(dqs_len) / len(dqs_len) if dqs_len else None
+ if ref is None:
+ continue
+ for key, net in lane.roles.items():
+ if key.upper().startswith("DQ") and not key.upper().startswith("DQS"):
+ deltas.append(abs(_len(layout, net) - ref))
+ nets.append(net)
+ nets.extend(dqs)
+ measured = max(deltas) if deltas else None
+ if measured is None:
+ return _skip_grouping("dq_to_dqs_skew", mand, inst)
+ verdict = geometric_verdict(cons, measured)
+ limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
+ return _pack(
+ "dq_to_dqs_skew", verdict, mand, nets=nets, measured_mm=measured,
+ limit_mm=limit, value_kind=kind,
+ notes="DQ-to-DQS geometric length delta (mm). No invented ps.",
+ )
+
+
+def _lane_spread(
+ lanes: list[SignalGroup],
+ layout: LayoutGraph,
+ cons: ProtocolConstraint | None,
+ mand: MandatoryClass,
+ kind: str,
+ check: str,
+) -> L1CheckResult:
+ spreads: list[float] = []
+ nets: list[str] = []
+ for lane in lanes:
+ lens = [_len(layout, n) for n in lane.nets]
+ if not lens:
+ continue
+ spreads.append(max(lens) - min(lens))
+ nets.extend(lane.nets)
+ measured = max(spreads) if spreads else None
+ verdict = geometric_verdict(cons, measured)
+ limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
+ return _pack(
+ check, verdict, mand, nets=nets, measured_mm=measured,
+ limit_mm=limit, value_kind=kind,
+ notes="Byte-lane geometric length spread (mm). Not a global length-match PASS.",
+ )
+
+
+def _group_spread(inst: PhysicalBusInstance, layout: LayoutGraph) -> float | None:
+ nets = _instance_nets(inst)
+ lens = [_len(layout, n) for n in nets if _len(layout, n) > 0]
+ if len(lens) < 2:
+ return None
+ return max(lens) - min(lens)
+
+
+def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> 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
+ skip_family = r.result in {
+ "UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
+ "PHY_DEPENDENT", "MISSING_SOURCE",
+ }
+ fail = r.result == "FAIL" and r.mandatory == "MANDATORY"
+ na = r.result == "NOT_APPLICABLE"
+ if not skip_family and not fail and not na:
+ continue
+ if fail:
+ rule_id = "PE-PRT-L1-003"
+ status = "ERROR"
+ finding = f"L1 geometric {r.check}: {r.notes}"
+ elif skip_family:
+ grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
+ rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
+ status = "WARNING"
+ finding = r.notes or (
+ f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
+ f"per {r.check} ({r.result}). Not electrical."
+ )
+ else:
+ rule_id = "PE-PRT-L0-002"
+ status = "INFO"
+ finding = r.notes
+ f = Finding(
+ designator=designator,
+ mpn="",
+ aspect="protocol_l1",
+ 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="L1 geometric vs NUMERIC/DESIGN length only; never invent mm/ps.",
+ inference="L1 is geometric, not electrical certification.",
+ provenance="MANDATORY" if fail else "TYPICAL",
+ finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"),
+ evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT",
+ )
+ out.append(complete_finding(f))
+ return out
+
+
+def certify_l1(
+ graph: DesignGraph,
+ instances: list[PhysicalBusInstance],
+ layout: LayoutGraph | None,
+ catalog: ProtocolCatalog | None = None,
+) -> tuple[dict[str, list[L1CheckResult]], list[Finding]]:
+ cat = catalog or load_catalog()
+ ifaces = {p.id: p for p in cat.physical_interfaces}
+ by_id: dict[str, list[L1CheckResult]] = {}
+ findings: list[Finding] = []
+ for inst in instances:
+ iface = ifaces.get(inst.physical_interface_id)
+ if iface is None:
+ continue
+ rows = certify_instance_l1(graph, inst, iface, layout)
+ by_id[inst.instance_id] = rows
+ findings.extend(l1_findings(inst, rows))
+ return by_id, findings
diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py
index ffa66c8..33e67c8 100644
--- a/periscope/src/backend/services/pcb_pipeline.py
+++ b/periscope/src/backend/services/pcb_pipeline.py
@@ -376,7 +376,7 @@ async def run_pcb_pipeline(
return
_step(project_id, "write_report", "running")
- proto_section, proto_findings = protocol_exam(graph)
+ proto_section, proto_findings = protocol_exam(graph, layout=layout)
findings.extend(proto_findings)
assign_pcb_finding_ids(findings)
annotate_findings_cad(findings, cad_index_from_graph(graph))
diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md
index 34ec252..dca8b3e 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.67.0 — 2026-09-22 — Protocol certification M3 (L1 geometric)
+
+L1 GEOMETRIC certifier: length, topology, vias, stubs, layer, grouping. Geometric skew only vs **NUMERIC** millimetre or an explicit **DESIGN LIMIT**. Otherwise UNKNOWN / VENDOR_DEPENDENT / CONTROLLER_DEPENDENT / PHY_DEPENDENT — never invent mm/ps. L1 is **not** electrical. No L2/L3. RECOMMENDED never FAIL. Internal interfaces stay NOT_APPLICABLE. Missing DQ/DQS grouping is a visible skip, not PASS.
+
+- [New] `protocol_l1.py`; `PE-PRT-L1-001` / `PE-PRT-L1-002` / `PE-PRT-L1-003`.
+- [New] pytest `tests/pcb/test_protocol_l1_m3.py`.
+
## 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.
diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json
index b9b4f64..86cdd10 100644
--- a/periscope/src/frontend/package.json
+++ b/periscope/src/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
- "version": "2.66.0",
+ "version": "2.67.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 4994e69..5f2529e 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({
- Logical protocol vs physical interface. L0 structural only —
- presence and connection, no invented impedance.
+ Logical protocol vs physical interface. L0 structural, L1 geometric
+ (length, vias, layer, grouping). Not electrical; no invented ohms/mm/ps.
{message}Protocolli
+ L1{" "}
+ {(row.l1_checks as Record