Add protocol-certification M1 instance recognition (2.65.0).

physical_bus_instance from declaration, silicon/part, pin/net names, and
pairs. USB and DDR fixtures emit groups; AXI4 internal attaches no PCB
nets. Ambiguity is REVIEW, not FAIL. No L0–L3 certifier and no invented Z.
This commit is contained in:
2026-09-22 12:04:37 +02:00
parent 36ad979612
commit fc6524a2a9
8 changed files with 766 additions and 16 deletions
@@ -0,0 +1,564 @@
"""M1: physical_bus_instance recognition. No L0L3 certifier. No invented Z.
Priority: declared > silicon/BOM > pinout > net/pin name > pair geometry.
Ambiguity is REVIEW, never FAIL. AXI4 internal does not attach PCB nets.
"""
from __future__ import annotations
import re
from typing import Any, Literal
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,
)
PACK_MACROPHASE = "M1"
EvidenceKind = Literal[
"declared",
"silicon",
"part",
"pinout",
"net_name",
"pair_geometry",
]
GroupKind = Literal[
"DIFFERENTIAL_PAIR",
"BYTE_LANE",
"DATA_LANE",
"CLOCK_GROUP",
"ADDRESS_GROUP",
"COMMAND_GROUP",
"CONTROL_GROUP",
"STROBE_GROUP",
"CHANNEL",
"SUBCHANNEL",
]
RecognitionStatus = Literal["RECOGNIZED", "REVIEW", "NOT_APPLICABLE"]
_EVIDENCE_SCORE: dict[str, float] = {
"declared": 1.0,
"silicon": 0.9,
"part": 0.85,
"pinout": 0.7,
"net_name": 0.55,
"pair_geometry": 0.4,
}
_USB_C_RE = re.compile(r"USB[\s_\-]*C\b|TYPE[\s_\-]*C|USB4085", re.I)
_USB2_RE = re.compile(r"USB[\s_\-]*2|16P", re.I)
_USB3_RE = re.compile(r"USB[\s_\-]*3|SUPER\s*SPEED|\bSS\b", re.I)
_USB_HS_RE = re.compile(r"HIGH\s*SPEED|\b480\b|\bHS\b", re.I)
_USB_LSFS_RE = re.compile(r"LOW\s*SPEED|FULL\s*SPEED|\b12\s*MB|\b1\.5\s*MB", re.I)
_DDR_GEN = [
("ddr5", re.compile(r"\bDDR5\b", re.I)),
("ddr4", re.compile(r"\bDDR4\b|MT41|K4A|IS43TR", re.I)),
("ddr3l", re.compile(r"\bDDR3L\b", re.I)),
("ddr3", re.compile(r"\bDDR3\b|MT41|K4B|IS43TR", re.I)),
("ddr2", re.compile(r"\bDDR2\b|MT47", re.I)),
("ddr1", re.compile(r"\bDDR1\b|\bDDR\b", re.I)),
]
_DDR4_PART = re.compile(r"MT41K|K4A|IS43TR|W631|EDY4016", re.I)
_AXI_RE = re.compile(r"\bAXI4(?:-LITE|-STREAM)?\b|\bAXI\b", re.I)
_AXI_C2C_RE = re.compile(r"CHIP[\s\-]*TO[\s\-]*CHIP|C2C|AXI.*PHY", re.I)
_DECLARE_RE = re.compile(
r"^(?:protocol|logical_protocol|physical_interface|bus)$", re.I,
)
_DQ_RE = re.compile(r"(?:^|[_/.])DQ(\d+)(?:$|[_/.])", re.I)
_DQS_RE = re.compile(r"(?:^|[_/.])DQS(\d*)(?:[_#]?(P|N|PINS|PLUS|MINUS))?(?:$|[_/.])", re.I)
_DM_RE = re.compile(r"(?:^|[_/.])(?:DM|DBI)(\d+)(?:$|[_/.])", re.I)
_CK_RE = re.compile(r"(?:^|[_/.])(?:CK|CLK)(?:[_#]?(P|N))?(?:$|[_/.])", re.I)
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:A|ADDR|BA|BG)(\d+)(?:$|[_/.])", re.I)
_CMD_RE = re.compile(
r"(?:^|[_/.])(?:RAS|CAS|WE|ACT|CS|CKE|ODT|RESET|ZQ)(?:[#_N]*)(?:$|[_/.])",
re.I,
)
_USB_DP_RE = re.compile(
r"(?:^|[_/.])(?:USB[_]?D\+|USB[_]?DP|D\+)$", re.I,
)
_USB_DN_RE = re.compile(
r"(?:^|[_/.])(?:USB[_]?D\-|USB[_]?DM|D\-)$", re.I,
)
class SignalGroup(BaseModel):
kind: GroupKind
id: str
nets: list[str] = Field(default_factory=list)
roles: dict[str, str] = Field(default_factory=dict)
class PhysicalBusInstance(BaseModel):
instance_id: str
logical_protocol_id: str
physical_interface_id: str
pcb_relevant: Literal["YES", "NO", "CONDITIONAL"]
confidence: float
evidence_kind: EvidenceKind
recognition_status: RecognitionStatus
nets: list[str] = Field(default_factory=list)
groups: list[SignalGroup] = Field(default_factory=list)
host_ref: str = ""
peer_refs: list[str] = Field(default_factory=list)
ambiguous_logical_ids: list[str] = Field(default_factory=list)
notes: str = ""
def _leaf(name: str) -> str:
n = normalize_kicad_hierarchy_net(name)
return n.split("/")[-1] if n else ""
def _blob(comp: Component, graph: DesignGraph) -> str:
extra = []
for bag in (
(graph.bom_fields or {}).get(comp.reference) or {},
(graph.schematic_fields or {}).get(comp.reference) or {},
):
extra.extend(str(v) for v in bag.values() if v)
specs = ""
if comp.specs is not None and getattr(comp.specs, "values", None):
specs = " ".join(str(v) for v in comp.specs.values.values() if v)
return " ".join(
str(x or "")
for x in (
comp.mpn, comp.value, comp.footprint, comp.component_subtype, specs, *extra,
)
)
def _declared_ids(graph: DesignGraph) -> list[tuple[str, str, str]]:
"""(ref, logical_or_physical_id, raw)."""
hits: list[tuple[str, str, str]] = []
bags: list[tuple[str, dict]] = []
for ref, row in (graph.bom_fields or {}).items():
bags.append((ref, row or {}))
for ref, row in (graph.schematic_fields or {}).items():
bags.append((ref, row or {}))
for ref, comp in graph.components.items():
if comp.specs is not None and getattr(comp.specs, "values", None):
bags.append((ref, dict(comp.specs.values)))
for ref, row in bags:
for key, val in row.items():
if val is None:
continue
if not _DECLARE_RE.match(str(key)):
continue
hits.append((ref, str(val).strip().lower(), str(val)))
return hits
def _pn_pairs(net_names: list[str]) -> list[tuple[str, str]]:
by_leaf = {_leaf(n): n for n in net_names if n}
pairs: list[tuple[str, str]] = []
used: set[str] = set()
for leaf, full in by_leaf.items():
if full in used:
continue
mate_leaf = None
if leaf.endswith("+"):
mate_leaf = leaf[:-1] + "-"
elif leaf.endswith("_P") or leaf.endswith("_p"):
mate_leaf = leaf[:-2] + "_N"
elif leaf.upper().endswith("DP"):
stem = leaf[:-2]
mate_leaf = stem + ("DM" if leaf[-2].isupper() else "dm")
if mate_leaf and mate_leaf in by_leaf:
other = by_leaf[mate_leaf]
pairs.append((full, other))
used.add(full)
used.add(other)
return pairs
def _usb_pairs(net_names: list[str]) -> list[tuple[str, str]]:
plus = [n for n in net_names if _USB_DP_RE.search(_leaf(n))]
minus = [n for n in net_names if _USB_DN_RE.search(_leaf(n))]
found: list[tuple[str, str]] = []
used: set[str] = set()
generic = _pn_pairs(net_names)
for a, b in generic:
la, lb = _leaf(a).upper(), _leaf(b).upper()
if "USB" in la or "USB" in lb or la.startswith("D") or lb.startswith("D"):
found.append((a, b))
used.add(a)
used.add(b)
for p in plus:
if p in used:
continue
pl = _leaf(p)
for m in minus:
if m in used:
continue
if pl.replace("+", "-").replace("DP", "DM").replace("_P", "_N") == _leaf(m):
found.append((p, m))
used.add(p)
used.add(m)
break
if not found and len(plus) == 1 and len(minus) == 1:
found.append((plus[0], minus[0]))
return found
def _byte_lanes(net_names: list[str]) -> list[SignalGroup]:
dq: dict[int, str] = {}
dqs: dict[int, dict[str, str]] = {}
dm: dict[int, str] = {}
for n in net_names:
leaf = _leaf(n)
m = _DQ_RE.search(leaf)
if m and "DQS" not in leaf.upper():
dq[int(m.group(1))] = n
continue
m = _DQS_RE.search(leaf)
if m:
idx = int(m.group(1) or "0")
pol = (m.group(2) or "P").upper()[:1]
dqs.setdefault(idx, {})[pol] = n
continue
m = _DM_RE.search(leaf)
if m:
dm[int(m.group(1))] = n
lanes: list[SignalGroup] = []
lane_ids = set(i // 8 for i in dq) | set(dqs) | set(dm)
for lane in sorted(lane_ids):
roles: dict[str, str] = {}
nets: list[str] = []
for bit in range(lane * 8, lane * 8 + 8):
if bit in dq:
roles[f"DQ{bit}"] = dq[bit]
nets.append(dq[bit])
if lane in dqs:
for pol, net in dqs[lane].items():
roles[f"DQS{lane}_{pol}"] = net
nets.append(net)
if lane in dm:
roles[f"DM{lane}"] = dm[lane]
nets.append(dm[lane])
if nets:
lanes.append(SignalGroup(
kind="BYTE_LANE", id=f"BYTE_LANE_{lane}", nets=nets, roles=roles,
))
return lanes
def _iface_map(catalog: ProtocolCatalog) -> dict[str, Any]:
return {p.id: p for p in catalog.physical_interfaces}
def _logical_ids(catalog: ProtocolCatalog) -> set[str]:
return {p.id for p in catalog.logical_protocols}
def recognize_physical_buses(
graph: DesignGraph,
catalog: ProtocolCatalog | None = None,
) -> list[PhysicalBusInstance]:
cat = catalog or load_catalog()
ifaces = _iface_map(cat)
logical = _logical_ids(cat)
out: list[PhysicalBusInstance] = []
out.extend(_recognize_declared(graph, ifaces, logical))
out.extend(_recognize_usb(graph, ifaces))
out.extend(_recognize_ddr(graph, ifaces))
out.extend(_recognize_axi(graph, ifaces))
return _dedupe_instances(out)
def _recognize_declared(
graph: DesignGraph,
ifaces: dict,
logical: set[str],
) -> list[PhysicalBusInstance]:
out: list[PhysicalBusInstance] = []
for ref, ident, raw in _declared_ids(graph):
ident = ident.replace("_", "-")
phys_id = None
log_id = None
if ident in ifaces:
phys_id = ident
log_id = ifaces[ident].logical_protocol_id
elif ident in logical:
log_id = ident
for pid, iface in ifaces.items():
if iface.logical_protocol_id == ident and iface.pcb_relevant == "NO":
phys_id = pid
break
if phys_id is None:
for pid, iface in ifaces.items():
if iface.logical_protocol_id == ident:
phys_id = pid
break
if not phys_id or not log_id:
continue
iface = ifaces[phys_id]
nets: list[str] = []
if iface.pcb_relevant != "NO":
comp = graph.components.get(ref)
if comp:
nets = sorted({n for n in comp.pins.values() if n})
out.append(PhysicalBusInstance(
instance_id=f"{phys_id}:{ref}",
logical_protocol_id=log_id,
physical_interface_id=phys_id,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["declared"],
evidence_kind="declared",
recognition_status=(
"NOT_APPLICABLE" if iface.pcb_relevant == "NO" else "RECOGNIZED"
),
nets=nets,
host_ref=ref,
notes=f"declared {raw}",
))
return out
def _recognize_usb(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
instances: list[PhysicalBusInstance] = []
connectors = []
for comp in graph.components.values():
blob = _blob(comp, graph)
if _USB_C_RE.search(blob) or _USB_C_RE.search(comp.footprint):
connectors.append(comp)
pairs = _usb_pairs(list(graph.nets))
if not connectors and not pairs:
return []
usb3 = any(_USB3_RE.search(_blob(c, graph)) for c in connectors)
usb2_mark = any(_USB2_RE.search(_blob(c, graph)) for c in connectors)
hs = any(_USB_HS_RE.search(_blob(c, graph)) for c in connectors)
lsfs = any(_USB_LSFS_RE.search(_blob(c, graph)) for c in connectors)
ambiguous = False
notes = ""
if connectors and usb3 and usb2_mark:
ambiguous = True
notes = "USB2 vs USB3 on the same receptacle — REVIEW, not FAIL."
if not hs and not lsfs and pairs:
ambiguous = True
notes = (notes + " " if notes else "") + "USB2 speed not declared; HS vs LS/FS REVIEW."
if hs and not lsfs:
logical, phys = "usb2-hs", "usb2-hs-dpair"
elif lsfs and not hs:
logical, phys = "usb2-ls-fs", "usb2-ls-fs-dpair"
else:
logical, phys = "usb2-hs", "usb2-hs-dpair"
ambiguous = True
groups = [
SignalGroup(
kind="DIFFERENTIAL_PAIR",
id=f"USB2_D_{i}",
nets=[a, b],
roles={"D+": a, "D-": b},
)
for i, (a, b) in enumerate(pairs)
]
nets = sorted({n for g in groups for n in g.nets})
host = connectors[0].reference if connectors else (nets[0] if nets else "usb")
evidence: EvidenceKind = "part" if connectors else "net_name"
if connectors and any(_USB_C_RE.search(_blob(c, graph)) for c in connectors):
cphys = "usb-c-usb2-receptacle"
ciface = ifaces[cphys]
status: RecognitionStatus = "REVIEW" if ambiguous or usb3 else "RECOGNIZED"
instances.append(PhysicalBusInstance(
instance_id=f"{cphys}:{host}",
logical_protocol_id="usb-c-usb2",
physical_interface_id=cphys,
pcb_relevant=ciface.pcb_relevant,
confidence=_EVIDENCE_SCORE[evidence],
evidence_kind=evidence,
recognition_status=status,
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in connectors],
ambiguous_logical_ids=["usb-c-usb2", "usb2-hs"] if ambiguous else [],
notes=notes,
))
return instances
iface = ifaces[phys]
status = "REVIEW" if ambiguous else "RECOGNIZED"
amb = ["usb2-ls-fs", "usb2-hs"] if ambiguous else []
instances.append(PhysicalBusInstance(
instance_id=f"{phys}:{host}",
logical_protocol_id=logical,
physical_interface_id=phys,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE[evidence],
evidence_kind=evidence,
recognition_status=status,
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in connectors],
ambiguous_logical_ids=amb,
notes=notes,
))
return instances
def _ddr_gen_from_text(text: str) -> str | None:
if _DDR4_PART.search(text):
return "ddr4"
for gen, cre in _DDR_GEN:
if cre.search(text):
return gen
return None
def _recognize_ddr(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
memories: list[tuple[Component, str, EvidenceKind]] = []
for comp in graph.components.values():
blob = _blob(comp, graph)
gen = _ddr_gen_from_text(blob)
if not gen:
continue
kind: EvidenceKind = "part" if (comp.mpn or _DDR4_PART.search(blob)) else "silicon"
memories.append((comp, gen, kind))
net_gen = None
for n in graph.nets:
g = _ddr_gen_from_text(_leaf(n))
if g:
net_gen = g
break
if not memories and not net_gen:
dq = [n for n in graph.nets if _DQ_RE.search(_leaf(n)) and "DQS" not in _leaf(n).upper()]
dqs = [n for n in graph.nets if "DQS" in _leaf(n).upper()]
if len(dq) >= 8 and dqs:
net_gen = "ddr-family"
else:
return []
gen = memories[0][1] if memories else net_gen or "ddr-family"
phys = f"{gen}-sdram-pcb" if f"{gen}-sdram-pcb" in ifaces else "ddr-family-sdram-pcb"
if gen == "ddr-family":
phys = "ddr-family-sdram-pcb"
iface = ifaces[phys]
host = memories[0][0].reference if memories else ""
evidence: EvidenceKind = memories[0][2] if memories else "net_name"
all_nets = list(graph.nets)
if host:
host_nets = list(graph.components[host].pins.values())
all_nets = host_nets or all_nets
lanes = _byte_lanes(all_nets)
groups = list(lanes)
ck = [n for n in all_nets if _CK_RE.search(_leaf(n))]
if ck:
groups.append(SignalGroup(kind="CLOCK_GROUP", id="CK", nets=ck, roles={}))
addr = [n for n in all_nets if _ADDR_RE.search(_leaf(n))]
if addr:
groups.append(SignalGroup(kind="ADDRESS_GROUP", id="ADDRESS", nets=addr, roles={}))
cmd = [n for n in all_nets if _CMD_RE.search(_leaf(n))]
if cmd:
groups.append(SignalGroup(kind="COMMAND_GROUP", id="COMMAND_CONTROL", nets=cmd, roles={}))
nets = sorted({n for g in groups for n in g.nets})
incomplete = bool(memories) and not lanes
status: RecognitionStatus = "REVIEW" if incomplete or gen == "ddr-family" else "RECOGNIZED"
notes = ""
if incomplete:
notes = "DDR device present but byte-lane grouping incomplete — REVIEW, not FAIL."
if gen == "ddr-family":
notes = (notes + " " if notes else "") + "DDR generation not unique."
return [PhysicalBusInstance(
instance_id=f"{phys}:{host or 'nets'}",
logical_protocol_id=gen,
physical_interface_id=phys,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE[evidence],
evidence_kind=evidence,
recognition_status=status,
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c, _, _ in memories],
ambiguous_logical_ids=["ddr-family"] if gen == "ddr-family" else [],
notes=notes,
)]
def _recognize_axi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
hits: list[Component] = []
chip_to_chip = False
for comp in graph.components.values():
blob = _blob(comp, graph)
if _AXI_RE.search(blob):
hits.append(comp)
if _AXI_C2C_RE.search(blob):
chip_to_chip = True
declared = any(i == "axi4" or i.startswith("axi4") for _, i, _ in _declared_ids(graph))
if not hits and not declared:
# Net names alone (AXI_AWVALID) do not invent a PCB AXI bus.
return []
if chip_to_chip:
iface = ifaces["axi4-chip-to-chip-phy"]
host = hits[0].reference if hits else "axi4"
return [PhysicalBusInstance(
instance_id=f"{iface.id}:{host}",
logical_protocol_id="axi4",
physical_interface_id=iface.id,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["silicon"],
evidence_kind="silicon",
recognition_status="REVIEW",
nets=[],
host_ref=host,
notes="AXI chip-to-chip is CONDITIONAL; PHY docs required. No AXI nets assumed.",
)]
iface = ifaces["axi4-internal"]
host = hits[0].reference if hits else "axi4"
return [PhysicalBusInstance(
instance_id=f"{iface.id}:{host}",
logical_protocol_id="axi4",
physical_interface_id=iface.id,
pcb_relevant="NO",
confidence=_EVIDENCE_SCORE["silicon"],
evidence_kind="silicon",
recognition_status="NOT_APPLICABLE",
nets=[],
groups=[],
host_ref=host,
peer_refs=[c.reference for c in hits],
notes="AXI4 is an on-chip interconnect. No PCB nets.",
)]
def _dedupe_instances(items: list[PhysicalBusInstance]) -> list[PhysicalBusInstance]:
by_id: dict[str, PhysicalBusInstance] = {}
for inst in items:
prev = by_id.get(inst.instance_id)
if prev is None or inst.confidence > prev.confidence:
by_id[inst.instance_id] = inst
return sorted(by_id.values(), key=lambda i: (-i.confidence, i.instance_id))
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,
)
@@ -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_catalog import empty_protocol_section
from backend.periscopex.protocol_recognize import protocol_section_for_graph
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
@@ -394,7 +394,7 @@ async def run_pcb_pipeline(
summary=summary,
coverage=coverage,
not_reviewed=skipped + si_skip,
protocol_certification=empty_protocol_section().model_dump(),
protocol_certification=protocol_section_for_graph(graph).model_dump(),
)
report_path = ws.local_path("pcb_report.json")
report_path.write_text(report.model_dump_json(indent=2) + "\n")
+3 -3
View File
@@ -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_catalog import empty_protocol_section
from backend.periscopex.protocol_recognize import protocol_section_for_graph
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 (
@@ -229,7 +229,7 @@ async def validate_design_async(
coverage=_sanitize_coverage(all_coverage),
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
protocol_certification=empty_protocol_section().model_dump(),
protocol_certification=protocol_section_for_graph(graph).model_dump(),
)
except Exception:
report = ValidationReport(
@@ -240,7 +240,7 @@ async def validate_design_async(
coverage={},
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
protocol_certification=empty_protocol_section().model_dump(),
protocol_certification=protocol_section_for_graph(graph).model_dump(),
)
report_dict = json.loads(report.model_dump_json(indent=2))
if preserved_comments is not None:
@@ -2,6 +2,13 @@
What's new in Periscope.
## 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 L0L3 certifier. No invented ohms.
- [New] `protocol_recognize.py`; report Protocolli lists recognised instances or stays empty.
- [New] pytest `tests/pcb/test_protocol_recognize_m1.py` (USB/DDR fixtures; AXI has no nets).
## 2.64.0 — 2026-09-22 — Protocol certification M0 (catalog, no numbers)
Versioned JSON protocol pack + Python loader/validator. Catalog skeletons for USB2 / USB-C-USB2 / 100BASE-TX / HDMI (connector distinct) / DDR / LPDDR / HBM / HyperBus / QSPI-octal / eMMC / UFS / AMBA / Wishbone / Avalon / TileLink / PCIe-CXL. Constraints without an official cite are UNKNOWN / CONTROLLER_DEPENDENT / PHY_DEPENDENT / MISSING_SOURCE — **no invented 90/100/50 Ω**. `logical_protocol_id``physical_interface_id`. `pcb_relevant` is YES|NO|CONDITIONAL. RECOMMENDED is not FAIL-mandatory. Typical-as-standard is rejected by the parser. Report section **Protocolli** is empty (`nessun protocollo riconosciuto`). Recognition and L0L3 certifiers are not in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.64.0",
"version": "2.65.0",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
@@ -7,24 +7,45 @@ export function ProtocolSection({
}: {
section?: ProtocolCertificationSection | null;
}) {
const message = section?.message || "nessun protocollo riconosciuto";
const instances = section?.recognized_instances ?? [];
const message = section?.message || "nessun protocollo riconosciuto";
return (
<section className="rounded-lg border border-border bg-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Protocolli</h2>
<p className="text-xs text-muted-foreground">
Logical protocol vs physical interface. M0 loads the catalog only
no recognition, no invented impedance.
Logical protocol vs physical interface. Recognition only (M1)
no L0L3 certifier, no invented impedance.
</p>
{instances.length === 0 ? (
<p className="text-sm">{message}</p>
) : (
<ul className="text-sm space-y-1">
{instances.map((row, i) => (
<li key={i} className="font-mono text-xs">
{String(row.physical_interface_id ?? row.logical_protocol_id ?? i)}
</li>
))}
<ul className="text-sm space-y-2">
{instances.map((row, i) => {
const phys = String(row.physical_interface_id ?? "");
const log = String(row.logical_protocol_id ?? "");
const status = String(row.recognition_status ?? "");
const conf = row.confidence;
const groups = Array.isArray(row.groups) ? row.groups : [];
return (
<li key={String(row.instance_id ?? i)} className="space-y-0.5">
<p className="font-mono text-xs">
{log} {phys}
{status ? ` · ${status}` : ""}
{typeof conf === "number" ? ` · ${conf.toFixed(2)}` : ""}
</p>
{groups.length > 0 ? (
<p className="text-xs text-muted-foreground">
{groups
.map((g) => {
const rec = g as Record<string, unknown>;
return String(rec.kind ?? rec.id ?? "group");
})
.join(", ")}
</p>
) : null}
</li>
);
})}
</ul>
)}
</section>
+1 -1
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.64.0";
export const APP_VERSION = "2.65.0";
export const APP_VERSION_DATE = "2026-09-22";
+158
View File
@@ -0,0 +1,158 @@
"""M1 physical_bus_instance recognition: USB/DDR fixtures; AXI has no PCB nets."""
from __future__ import annotations
import json
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
from backend.periscopex.protocol_recognize import (
PACK_MACROPHASE,
protocol_section_for_graph,
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_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}, mpn="CH340E"),
"J2": Component(
reference="J2", value="USB_C_Receptacle_USB2.0_16P",
footprint="USB_C_Receptacle_USB2.0_16P",
component_type=ComponentType.CONNECTOR,
pins={"A6": "USB_D+", "A7": "USB_D-"},
),
},
nets={
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
},
)
def _ddr_graph() -> DesignGraph:
pins = {str(i): f"DDR4_DQ{i}" for i in range(8)}
pins.update({
"8": "DDR4_DQS0_P", "9": "DDR4_DQS0_N", "10": "DDR4_DM0",
"11": "DDR4_CK_P", "12": "DDR4_CK_N",
"13": "DDR4_A0", "14": "DDR4_RAS",
})
u_mem = _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4")
nets = {n: _net(n, ("U5", p)) for p, n in pins.items()}
return DesignGraph(components={"U5": u_mem}, nets=nets)
def _axi_graph() -> DesignGraph:
pins = {
"1": "AXI_AWVALID",
"2": "AXI_WDATA",
"3": "AXI_RDATA",
"4": "VCCINT",
}
return DesignGraph(
components={
"U3": _ic("U3", pins, mpn="XC7A100T", value="AXI4 interconnect"),
},
nets={
"AXI_AWVALID": _net("AXI_AWVALID", ("U3", "1")),
"AXI_WDATA": _net("AXI_WDATA", ("U3", "2")),
"AXI_RDATA": _net("AXI_RDATA", ("U3", "3")),
"VCCINT": _net("VCCINT", ("U3", "4")),
},
)
def test_usb_net_names_make_diff_pair_instance():
insts = recognize_physical_buses(_usb_graph())
usb = [i for i in insts if "usb" in i.logical_protocol_id]
assert usb, insts
one = usb[0]
assert one.physical_interface_id != one.logical_protocol_id
assert one.pcb_relevant in {"YES", "CONDITIONAL"}
pairs = [g for g in one.groups if g.kind == "DIFFERENTIAL_PAIR"]
assert pairs
nets = set(pairs[0].nets)
assert "USB_D+" in nets and "USB_D-" in nets
assert one.recognition_status != "FAIL"
assert one.evidence_kind in {"part", "net_name", "silicon"}
def test_usb_speed_ambiguity_is_review_not_fail():
insts = recognize_physical_buses(_usb_graph())
usb = [i for i in insts if "usb" in i.logical_protocol_id][0]
assert usb.recognition_status == "REVIEW"
assert "FAIL" not in usb.recognition_status
assert usb.ambiguous_logical_ids
def test_ddr_net_names_rebuild_byte_lane():
insts = recognize_physical_buses(_ddr_graph())
ddr = [i for i in insts if i.logical_protocol_id.startswith("ddr")]
assert ddr
one = ddr[0]
assert one.physical_interface_id == "ddr4-sdram-pcb"
assert one.logical_protocol_id == "ddr4"
lanes = [g for g in one.groups if g.kind == "BYTE_LANE"]
assert lanes
assert "DDR4_DQ0" in lanes[0].nets
assert "DDR4_DQS0_P" in lanes[0].nets
clocks = [g for g in one.groups if g.kind == "CLOCK_GROUP"]
assert clocks
assert one.recognition_status != "FAIL"
blob = json.dumps(one.model_dump())
assert "90" not in blob
assert "ohm" not in blob.lower()
def test_axi4_internal_does_not_create_pcb_nets():
insts = recognize_physical_buses(_axi_graph())
axi = [i for i in insts if i.logical_protocol_id == "axi4"]
assert axi
one = axi[0]
assert one.physical_interface_id == "axi4-internal"
assert one.pcb_relevant == "NO"
assert one.nets == []
assert one.groups == []
assert one.recognition_status == "NOT_APPLICABLE"
assert "AXI_AWVALID" not in one.nets
assert "AXI_WDATA" not in one.nets
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()
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
assert all(row.get("recognition_status") != "FAIL" for row in sec.recognized_instances)