diff --git a/periscope/dependency/simple_project/eval_golden.json b/periscope/dependency/simple_project/eval_golden.json index 6f5a4e3..3411c0a 100644 --- a/periscope/dependency/simple_project/eval_golden.json +++ b/periscope/dependency/simple_project/eval_golden.json @@ -5,6 +5,13 @@ "deterministic_keys": [ "PE-I2C-001|U3|/I2C0.SDA", "PE-I2C-001|U3|/I2C0.SCL", - "PE-ESR-001|U1|+3V3" + "PE-ESR-001|U1|+3V3", + "PE-USBC-001|J1|", + "PE-USBC-002|J1|/USBC.CC1", + "PE-USBC-003|J1|", + "PE-USBC-004|J1|", + "PE-USBC-005|J1|", + "PE-ETH-001|PCB|", + "PE-POE-001|PCB|" ] } diff --git a/periscope/src/backend/periscopex/eval_report.py b/periscope/src/backend/periscopex/eval_report.py index 50754c6..6ad2a66 100644 --- a/periscope/src/backend/periscopex/eval_report.py +++ b/periscope/src/backend/periscopex/eval_report.py @@ -30,6 +30,7 @@ from backend.periscopex.dnp_check import check_dnp_enables from backend.periscopex.lifecycle import check_lifecycle from backend.periscopex.errata_check import check_errata from backend.periscopex.internal_features_check import check_internal_features +from backend.periscopex.interface_class_check import check_interface_classes from backend.periscopex.placement_check import check_placement from backend.periscopex.si_check import check_si @@ -101,6 +102,7 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]: out.extend(check_lifecycle(graph, {})) out.extend(check_errata(graph, cmap)) out.extend(check_internal_features(graph, cmap)) + out.extend(check_interface_classes(graph)) out.extend(check_placement(graph, cmap, None)) out.extend(check_si(graph, cmap, None)) return out diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index e645208..cd1fa83 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -193,6 +193,31 @@ def _seed() -> None: requirement="Single regulator or crystal feeding many ICs is REVIEW, not ERROR.") _add("PE-EMI-001", "RECOMMENDED", "REVIEW", domain="pcb", requirement="Datasheet EMI/filter FACT vs ferrite/common-mode on the net.") + # Interface class (USB-C / Ethernet / PoE) — connection integrity, not SI. + _add("PE-USBC-001", "TYPICAL", "INFO", domain="shared", + requirement="USB-C class from BOM/footprint/MPN/nets (USB2 vs USB3).") + _add("PE-USBC-002", "MANDATORY", "RULE", domain="shared", + requirement="USB-C receptacle shall present CC1 and CC2.") + _add("PE-USBC-003", "MANDATORY", "RULE", domain="shared", + requirement="USB Type-C Rd 5.1 kΩ ±10% to GND, or Rp 56/22/10 kΩ to VBUS.") + _add("PE-USBC-004", "MANDATORY", "RULE", domain="shared", + requirement="USB-C receptacle shall connect VBUS and GND.") + _add("PE-USBC-005", "MANDATORY", "RULE", domain="shared", + requirement="SuperSpeed TX/RX pairs only when class is USB3; USB2 is N/A.") + _add("PE-ETH-001", "TYPICAL", "INFO", domain="shared", + requirement="Ethernet class 10/100 vs GbE from BOM/footprint/MPN/MDI pairs.") + _add("PE-ETH-002", "MANDATORY", "RULE", domain="shared", + requirement="10/100 needs TX/RX; GbE needs four MDI pairs.") + _add("PE-ETH-003", "MANDATORY", "RULE", domain="shared", + requirement="1000BASE-T requires magnetics (transformer or MagJack).") + _add("PE-ETH-004", "RECOMMENDED", "REVIEW", domain="shared", + requirement="Bob Smith 75 Ω is typical for GbE; often inside MagJack.") + _add("PE-POE-001", "TYPICAL", "INFO", domain="shared", + requirement="PoE certifier runs only with PoE evidence; bare RJ45 is N/A.") + _add("PE-POE-002", "TYPICAL", "INFO", domain="shared", + requirement="PoE class/type from 802.3af/at/bt or Class n — never invented.") + _add("PE-POE-003", "MANDATORY", "RULE", domain="shared", + requirement="PoE requires magnetics/isolation (MagJack or LAN transformer).") _seed() diff --git a/periscope/src/backend/periscopex/interface_class_check.py b/periscope/src/backend/periscopex/interface_class_check.py new file mode 100644 index 0000000..4facbde --- /dev/null +++ b/periscope/src/backend/periscopex/interface_class_check.py @@ -0,0 +1,814 @@ +"""Dedicated interface class certifiers (USB-C, Ethernet, PoE). + +Not generic SI. Not via ampacity. Not thermal FEM. Graph connectivity only: +class from BOM / footprint / net FACT, then connection integrity. Missing +numbers are INSUFFICIENT_EVIDENCE. A bare RJ45 is not PoE. +""" + +from __future__ import annotations + +import re + +from backend.periscopex.models import ( + Component, + ComponentType, + DesignGraph, + Finding, + NetType, + ResistorSpecs, +) +from backend.periscopex.pcb_net_match import ( + is_no_connect_net, + normalize_kicad_hierarchy_net, + refs_on_matched_net, +) + +SOURCE = "interface_class_check" + +# USB Type-C Cable and Connector Specification: Rd = 5.1 kΩ ±10% (UFP). +USB_C_RD_OHM = 5100.0 +USB_C_RD_TOL = 0.10 +# Rp advertisement windows (DFP to VBUS). ±20% so we do not invent tighter bands. +USB_C_RP_OHM = (56000.0, 22000.0, 10000.0) +USB_C_RP_TOL = 0.20 +BOB_SMITH_OHM = 75.0 +BOB_SMITH_TOL = 0.10 + +_USB_C_RE = re.compile(r"USB[\s_\-]*C\b|TYPE[\s_\-]*C|USB4085|USB_C", re.I) +_USB2_RE = re.compile(r"USB[\s_\-]*2|16P", re.I) +_USB3_RE = re.compile(r"USB[\s_\-]*3|SUPER\s*SPEED", re.I) +_CC1_RE = re.compile(r"(?:^|.*[._\-])CC1$", re.I) +_CC2_RE = re.compile(r"(?:^|.*[._\-])CC2$", re.I) +_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS|VCC_USB", re.I) +_SS_NET_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|SS_R[XR]|USB3|USB_SS", re.I) +_USBC_SS_PINS = frozenset({ + "A2", "A3", "A10", "A11", "B2", "B3", "B10", "B11", +}) +_USBC_VBUS_PINS = frozenset({"A4", "A9", "B4", "B9"}) +_USBC_GND_PINS = frozenset({"A1", "A12", "B1", "B12", "S1"}) + +_RJ45_RE = re.compile(r"RJ[\s\-]?45|8P8C|MAGJACK", re.I) +_ETH_10_100_RE = re.compile(r"10\s*/\s*100|100BASE|10BASE|BASE[\s\-]?TX", re.I) +_ETH_GBE_RE = re.compile(r"1000\s*BASE|1000BASE|\bGBE\b|GIGABIT", re.I) +_MAG_RE = re.compile(r"MAGNETIC|MAGJACK|MAG[\s\-]?JACK|LAN\s*TRANSFORMER", re.I) +_POE_RE = re.compile( + r"\bPOE\b|802\.3A[FT]|802\.3BT|POWER\s*OVER\s*ETHERNET|VPOE|48V_POE", + re.I, +) +_POE_CLASS_RE = re.compile( + r"802\.3A[FT]|802\.3BT|CLASS\s*([0-8])|TYPE\s*([1-4])", + re.I, +) +_CT_RE = re.compile(r"(?:^|.*[._\-])(?:T?CT|RCT|CENTER\s*TAP)$", re.I) + +_OHM_EMBEDDED = re.compile(r"^(\d+)([RKMG])(\d+)$", re.I) +_OHM_SUFFIX = re.compile(r"^(\d*\.?\d+)([RKMG])$", re.I) + + +def check_interface_classes(graph: DesignGraph) -> list[Finding]: + """USB-C, Ethernet, and PoE class + connection integrity.""" + out: list[Finding] = [] + out.extend(_check_usb_c(graph)) + out.extend(_check_ethernet(graph)) + out.extend(_check_poe(graph)) + return out + + +def format_interface_class_context(graph: DesignGraph) -> str: + """Plain FACT block for AI review — not the verification engine.""" + lines = [ + "### Interface class (deterministic certifiers — do not invent)", + "USB-C: CC1/CC2, Rp/Rd 5.1 kΩ, VBUS/GND, SuperSpeed only if class is USB3.", + "Ethernet: 10/100 vs GbE; magnetics required for GbE. Bob Smith is recommended.", + "PoE: only with PoE evidence. Never invent PoE on a bare RJ45. No Z/I/mm.", + ] + findings = check_interface_classes(graph) + if not findings: + lines.append("(no interface-class findings)") + return "\n".join(lines) + for f in findings: + lines.append( + f"- {f.rule_id} {f.designator}: {f.finding} " + f"[{f.status}/{f.finding_class}/{f.evidence_status}]" + ) + return "\n".join(lines) + + +def _check_usb_c(graph: DesignGraph) -> list[Finding]: + connectors = [c for c in graph.components.values() if _is_usb_c(c)] + if not connectors: + return [_na("PCB", "PE-USBC-001", "USB-C", + "No USB-C receptacle in the netlist (footprint/value/MPN/nets).")] + out: list[Finding] = [] + for comp in sorted(connectors, key=lambda c: c.reference): + out.extend(_usb_c_one(graph, comp)) + return out + + +def _usb_c_one(graph: DesignGraph, comp: Component) -> list[Finding]: + gen = _usb_c_generation(graph, comp) + roles = _usb_c_roles(comp) + out = [_usb_c_class_finding(comp, gen)] + out.append(_usb_c_cc_finding(comp, roles)) + out.append(_usb_c_rd_finding(graph, comp, roles)) + out.append(_usb_c_vbus_gnd_finding(comp, roles)) + out.append(_usb_c_ss_finding(comp, roles, gen)) + return out + + +def _usb_c_class_finding(comp: Component, gen: str) -> Finding: + ref = comp.reference + if gen == "usb2": + text = f"{ref} class USB-C USB2 (Type-C receptacle; SuperSpeed not required)." + return _info(ref, "PE-USBC-001", text, text, + "USB-C class from BOM/footprint/MPN/nets (USB2 token).", + mpn=comp.mpn or "") + if gen == "usb3": + text = f"{ref} class USB-C USB3 (SuperSpeed required)." + return _info(ref, "PE-USBC-001", text, text, + "USB-C class from BOM/footprint/MPN/nets (USB3/SuperSpeed token).", + mpn=comp.mpn or "") + return _insufficient( + ref, "PE-USBC-001", + f"{ref} is USB-C; generation USB2 vs USB3 is not evidenced.", + f"blob={_blob(None, comp)[:180]!r}; no USB2.0/16P and no USB3/SuperSpeed token.", + "USB-C class needs a USB2 or USB3 FACT; SuperSpeed is not assumed.", + mpn=comp.mpn or "", + ) + + +def _usb_c_cc_finding(comp: Component, roles: dict[str, list[str]]) -> Finding: + ref = comp.reference + has1 = bool(roles.get("CC1")) + has2 = bool(roles.get("CC2")) + facts = f"CC1 nets={roles.get('CC1') or []}; CC2 nets={roles.get('CC2') or []}." + req = "USB-C receptacle shall present CC1 and CC2 (USB Type-C spec)." + if has1 and has2: + return _info(ref, "PE-USBC-002", + f"{ref} CC1 and CC2 are connected.", + facts, req, mpn=comp.mpn or "", + net=(roles["CC1"][0] if roles.get("CC1") else None), + pins=[f"{ref}.A5", f"{ref}.B5"]) + missing = [n for n, ok in (("CC1", has1), ("CC2", has2)) if not ok] + return _error(ref, "PE-USBC-002", + f"{ref} missing USB-C {', '.join(missing)}.", + facts, req, + f"Connect {', '.join(missing)} on {ref} (not NC).", + mpn=comp.mpn or "") + + +def _usb_c_rd_finding( + graph: DesignGraph, comp: Component, roles: dict[str, list[str]], +) -> Finding: + ref = comp.reference + req = ( + "USB Type-C Rd = 5.1 kΩ ±10% CC to GND (UFP), or Rp 56/22/10 kΩ to " + "VBUS (DFP). A CC/PD IC may provide them — that is not a discrete FACT." + ) + reports: list[str] = [] + worst = "pass" + for name in ("CC1", "CC2"): + nets = roles.get(name) or [] + if not nets: + continue + kind, detail = _cc_termination(graph, comp.reference, nets[0]) + reports.append(f"{name}:{kind}:{detail}") + worst = _worse_term(worst, kind) + facts = "; ".join(reports) or "no CC nets." + if worst == "pass": + return _info(ref, "PE-USBC-003", + f"{ref} CC Rp/Rd matches USB Type-C (Rd 5.1 kΩ or Rp window).", + facts, req, mpn=comp.mpn or "") + if worst == "fail": + return _error(ref, "PE-USBC-003", + f"{ref} CC termination is not USB Type-C Rd 5.1 kΩ / Rp.", + facts, req, + f"Fit 5.1 kΩ Rd to GND on both CC, or a USB-C CC controller.", + mpn=comp.mpn or "") + return _insufficient( + ref, "PE-USBC-003", + f"{ref} CC Rp/Rd is not a measured discrete FACT.", + facts, req, mpn=comp.mpn or "", + ) + + +def _usb_c_vbus_gnd_finding(comp: Component, roles: dict[str, list[str]]) -> Finding: + ref = comp.reference + has_v = bool(roles.get("VBUS")) + has_g = bool(roles.get("GND")) + facts = f"VBUS nets={roles.get('VBUS') or []}; GND nets={roles.get('GND') or []}." + req = "USB-C receptacle shall connect VBUS and GND." + if has_v and has_g: + return _info(ref, "PE-USBC-004", + f"{ref} VBUS and GND are connected.", + facts, req, mpn=comp.mpn or "") + missing = [n for n, ok in (("VBUS", has_v), ("GND", has_g)) if not ok] + return _error(ref, "PE-USBC-004", + f"{ref} missing USB-C {', '.join(missing)}.", + facts, req, + f"Connect {', '.join(missing)} on {ref}.", + mpn=comp.mpn or "") + + +def _usb_c_ss_finding( + comp: Component, roles: dict[str, list[str]], gen: str, +) -> Finding: + ref = comp.reference + ss_nets = roles.get("SS") or [] + facts = f"generation={gen}; SuperSpeed nets={ss_nets}." + if gen == "usb2": + return _info(ref, "PE-USBC-005", + f"{ref} SuperSpeed N/A (USB2 Type-C class).", + facts, + "SuperSpeed pairs are required only when the class is USB3.", + mpn=comp.mpn or "") + if gen != "usb3": + return _insufficient( + ref, "PE-USBC-005", + f"{ref} SuperSpeed not certified — USB2 vs USB3 class is not evidenced.", + facts, + "Do not invent SuperSpeed on a Type-C jack without a USB3 FACT.", + mpn=comp.mpn or "", + ) + if len(ss_nets) >= 4: + return _info(ref, "PE-USBC-005", + f"{ref} SuperSpeed pairs are present.", + facts, + "USB3 Type-C requires SuperSpeed TX/RX pairs.", + mpn=comp.mpn or "") + return _error(ref, "PE-USBC-005", + f"{ref} class USB3 but SuperSpeed pairs are missing.", + facts, + "USB3 Type-C requires SuperSpeed TX/RX pairs.", + f"Route SSTX/SSRX pairs on {ref} or declare USB2 Type-C.", + mpn=comp.mpn or "") + + +def _check_ethernet(graph: DesignGraph) -> list[Finding]: + jacks = [c for c in graph.components.values() if _is_ethernet_jack(c)] + if not jacks: + return [_na("PCB", "PE-ETH-001", "Ethernet", + "No RJ45 / Ethernet jack in the netlist.")] + out: list[Finding] = [] + for comp in sorted(jacks, key=lambda c: c.reference): + out.extend(_eth_one(graph, comp)) + return out + + +def _eth_one(graph: DesignGraph, comp: Component) -> list[Finding]: + speed = _eth_speed(graph, comp) + pairs = _mdi_pair_ids(comp) + mag = _has_magnetics(graph, comp) + out = [_eth_class_finding(comp, speed, pairs)] + out.append(_eth_pair_finding(comp, speed, pairs)) + out.append(_eth_mag_finding(graph, comp, speed, mag)) + out.append(_eth_bob_smith_finding(graph, comp, speed)) + return out + + +def _eth_class_finding(comp: Component, speed: str, pairs: set[str]) -> Finding: + ref = comp.reference + facts = f"speed={speed}; mdi_pairs={sorted(pairs)}; blob={_blob(None, comp)[:160]!r}." + if speed == "10/100": + return _info(ref, "PE-ETH-001", + f"{ref} class Ethernet 10/100 (not GbE).", + facts, "10/100 vs 1000 from BOM/footprint/MPN/named MDI pairs.", + mpn=comp.mpn or "") + if speed == "gbe": + return _info(ref, "PE-ETH-001", + f"{ref} class Ethernet GbE / 1000BASE-T.", + facts, "10/100 vs 1000 from BOM/footprint/MPN/named MDI pairs.", + mpn=comp.mpn or "") + return _insufficient( + ref, "PE-ETH-001", + f"{ref} Ethernet speed 10/100 vs GbE is not evidenced.", + facts, "Do not invent GbE or 10/100 without a FACT.", + mpn=comp.mpn or "", + ) + + +def _eth_pair_finding(comp: Component, speed: str, pairs: set[str]) -> Finding: + ref = comp.reference + facts = f"speed={speed}; pairs={sorted(pairs)}." + req = "10/100 needs TX and RX; GbE needs four MDI pairs (TRD0–3)." + if speed == "gbe": + trd = {p for p in pairs if p in {"0", "1", "2", "3"}} + if len(trd) >= 4 or len(pairs) >= 4: + return _info(ref, "PE-ETH-002", + f"{ref} GbE MDI pairs are present.", + facts, req, mpn=comp.mpn or "") + if pairs: + return _error(ref, "PE-ETH-002", + f"{ref} class GbE but fewer than four MDI pairs are named.", + facts, req, + f"Connect TRD0–TRD3 (or equivalent) on {ref}.", + mpn=comp.mpn or "") + return _insufficient(ref, "PE-ETH-002", + f"{ref} GbE pair names are not in the netlist.", + facts, req, mpn=comp.mpn or "") + if speed == "10/100": + if "tx" in pairs and "rx" in pairs: + return _info(ref, "PE-ETH-002", + f"{ref} 10/100 TX/RX pairs are present.", + facts, req, mpn=comp.mpn or "") + if pairs: + return _error(ref, "PE-ETH-002", + f"{ref} 10/100 missing named TX or RX pair.", + facts, req, + f"Connect TX± and RX± on {ref}.", + mpn=comp.mpn or "") + return _insufficient(ref, "PE-ETH-002", + f"{ref} TX/RX pair names are not in the netlist.", + facts, req, mpn=comp.mpn or "") + return _insufficient(ref, "PE-ETH-002", + f"{ref} MDI pairs not certified — speed class unknown.", + facts, req, mpn=comp.mpn or "") + + +def _eth_mag_finding( + graph: DesignGraph, comp: Component, speed: str, mag: bool, +) -> Finding: + ref = comp.reference + facts = f"speed={speed}; magnetics={mag}; blob={_blob(graph, comp)[:160]!r}." + req = "1000BASE-T requires magnetics (transformer or MagJack). Not a Z0 check." + if speed != "gbe": + return _info(ref, "PE-ETH-003", + f"{ref} GbE magnetics N/A (class is not GbE).", + facts, req, mpn=comp.mpn or "") + if mag: + return _info(ref, "PE-ETH-003", + f"{ref} GbE magnetics are present.", + facts, req, mpn=comp.mpn or "") + return _error(ref, "PE-ETH-003", + f"{ref} class GbE with no magnetics/MagJack FACT.", + facts, req, + f"Use a MagJack or LAN transformer on {ref} MDI.", + mpn=comp.mpn or "") + + +def _eth_bob_smith_finding( + graph: DesignGraph, comp: Component, speed: str, +) -> Finding: + ref = comp.reference + ohms = _bob_smith_ohms(graph, comp) + facts = f"speed={speed}; observed_75ohm={ohms}." + req = ( + "Bob Smith (75 Ω on unused pairs / center taps) is typical for GbE EMI. " + "Often inside a MagJack — not a MANDATORY discrete FACT." + ) + if speed != "gbe": + return _info(ref, "PE-ETH-004", + f"{ref} Bob Smith N/A (not GbE).", + facts, req, mpn=comp.mpn or "", + finding_class="INFO", provenance="RECOMMENDED") + if ohms: + return _info(ref, "PE-ETH-004", + f"{ref} Bob Smith 75 Ω observed ({ohms}).", + facts, req, mpn=comp.mpn or "", + finding_class="INFO", provenance="RECOMMENDED") + return _insufficient( + ref, "PE-ETH-004", + f"{ref} Bob Smith 75 Ω not observed (may be inside MagJack).", + facts, req, mpn=comp.mpn or "", + ) + + +def _check_poe(graph: DesignGraph) -> list[Finding]: + jacks = [c for c in graph.components.values() if _is_ethernet_jack(c)] + if not jacks: + return [_na("PCB", "PE-POE-001", "PoE", + "No RJ45 — PoE is N/A (not invented).")] + out: list[Finding] = [] + for comp in sorted(jacks, key=lambda c: c.reference): + ev = _poe_blob(graph, comp) + if not ev: + out.append(_na(comp.reference, "PE-POE-001", "PoE", + f"{comp.reference} is RJ45 without PoE evidence — " + "PoE not certified (bare jack is not PoE).")) + continue + out.extend(_poe_one(graph, comp, ev)) + return out + + +def _poe_one(graph: DesignGraph, comp: Component, evidence: str) -> list[Finding]: + ref = comp.reference + mag = _has_magnetics(graph, comp) + class_txt = _poe_class_text(evidence) + out = [_info(ref, "PE-POE-001", + f"{ref} has PoE evidence — class certifier applies.", + f"evidence={evidence[:200]!r}.", + "PoE is certified only with a PoE FACT (never a bare RJ45).", + mpn=comp.mpn or "")] + if class_txt: + out.append(_info(ref, "PE-POE-002", + f"{ref} PoE {class_txt}.", + f"matched={class_txt}; evidence={evidence[:160]!r}.", + "PoE class/type from 802.3af/at/bt or Class n FACT.", + mpn=comp.mpn or "")) + else: + out.append(_insufficient( + ref, "PE-POE-002", + f"{ref} PoE class/type is not evidenced (af/at/bt / Class 0–8).", + f"evidence={evidence[:160]!r}.", + "Do not invent IEEE class or wattage.", + mpn=comp.mpn or "", + )) + req = "PoE requires magnetics/isolation (MagJack or LAN transformer). Not FEM." + facts = f"magnetics={mag}; evidence={evidence[:120]!r}." + if mag: + out.append(_info(ref, "PE-POE-003", + f"{ref} PoE magnetics/isolation FACT is present.", + facts, req, mpn=comp.mpn or "")) + else: + out.append(_error(ref, "PE-POE-003", + f"{ref} has PoE evidence but no magnetics/isolation FACT.", + facts, req, + f"Use PoE magnetics / MagJack isolation on {ref}.", + mpn=comp.mpn or "")) + return out + + +def _blob(graph: DesignGraph | None, comp: Component) -> str: + parts = [ + comp.value or "", + comp.footprint or "", + comp.mpn or "", + comp.component_subtype or "", + ] + if graph is not None: + row = (graph.bom_fields or {}).get(comp.reference) or {} + for key in ("description", "Description", "value", "Value"): + if row.get(key): + parts.append(str(row.get(key))) + return " ".join(parts) + + +def _is_usb_c(comp: Component) -> bool: + if comp.component_type != ComponentType.CONNECTOR: + return False + if _USB_C_RE.search(_blob(None, comp)): + return True + return any( + _CC1_RE.search(_leaf(n)) or _CC2_RE.search(_leaf(n)) + for n in comp.pins.values() if n + ) + + +def _usb_c_generation(graph: DesignGraph, comp: Component) -> str: + blob = _blob(graph, comp) + if _USB2_RE.search(blob) and not _USB3_RE.search(blob): + return "usb2" + if _USB3_RE.search(blob): + return "usb3" + roles = _usb_c_roles(comp) + if roles.get("SS"): + return "usb3" + return "unknown" + + +def _usb_c_roles(comp: Component) -> dict[str, list[str]]: + roles: dict[str, list[str]] = { + "CC1": [], "CC2": [], "VBUS": [], "GND": [], "SS": [], + } + for pin, net in comp.pins.items(): + if not net or is_no_connect_net(net): + continue + leaf = _leaf(net) + p = str(pin).upper() + if _CC1_RE.search(leaf) or p == "A5": + roles["CC1"].append(net) + elif _CC2_RE.search(leaf) or p == "B5": + roles["CC2"].append(net) + if _VBUS_RE.search(leaf) or p in _USBC_VBUS_PINS: + roles["VBUS"].append(net) + if _is_gnd(net) or p in _USBC_GND_PINS: + roles["GND"].append(net) + if _SS_NET_RE.search(leaf) or p in _USBC_SS_PINS: + roles["SS"].append(net) + for key, nets in list(roles.items()): + seen: set[str] = set() + uniq: list[str] = [] + for n in nets: + if n not in seen: + seen.add(n) + uniq.append(n) + roles[key] = uniq + return roles + + +def _is_ethernet_jack(comp: Component) -> bool: + if comp.component_type != ComponentType.CONNECTOR: + return False + if _RJ45_RE.search(_blob(None, comp)): + return True + return bool(_mdi_pair_ids(comp)) + + +def _eth_speed(graph: DesignGraph, comp: Component) -> str: + blob = _blob(graph, comp) + pairs = _mdi_pair_ids(comp) + trd = {p for p in pairs if p in {"0", "1", "2", "3"}} + if _ETH_GBE_RE.search(blob) or len(trd) >= 4: + return "gbe" + if _ETH_10_100_RE.search(blob): + return "10/100" + return "unknown" + + +def _mdi_pair_ids(comp: Component) -> set[str]: + polar: dict[str, set[str]] = {} + for net in comp.pins.values(): + if not net or is_no_connect_net(net): + continue + leaf = _leaf(net).upper() + m = re.search(r"(?:TRD|MDI|TP)(\d)", leaf) + if m: + key = m.group(1) + pol = "p" if re.search(r"_P$|\+$", leaf) else "n" if re.search( + r"_N$|_M$|-$", leaf, + ) else "?" + polar.setdefault(key, set()).add(pol) + continue + if re.search(r"(?:TX|TD)(?:\+|_P)$", leaf): + polar.setdefault("tx", set()).add("p") + elif re.search(r"(?:TX|TD)(?:-|_N)$", leaf): + polar.setdefault("tx", set()).add("n") + if re.search(r"(?:RX|RD)(?:\+|_P)$", leaf): + polar.setdefault("rx", set()).add("p") + elif re.search(r"(?:RX|RD)(?:-|_N)$", leaf): + polar.setdefault("rx", set()).add("n") + found: set[str] = set() + for key, pols in polar.items(): + if "p" in pols and "n" in pols: + found.add(key) + return found + + +def _has_magnetics(graph: DesignGraph, comp: Component) -> bool: + if _MAG_RE.search(_blob(graph, comp)): + return True + for net in comp.pins.values(): + if not net: + continue + for r in refs_on_matched_net(graph, net): + other = graph.components.get(r) + if other is None or other.reference == comp.reference: + continue + if other.component_type == ComponentType.TRANSFORMER: + return True + if _MAG_RE.search(_blob(graph, other)): + return True + return False + + +def _poe_blob(graph: DesignGraph, comp: Component) -> str: + blob = _blob(graph, comp) + hits = [m.group(0) for m in _POE_RE.finditer(blob)] + for net in comp.pins.values(): + if net and _POE_RE.search(_leaf(net)): + hits.append(_leaf(net)) + return " ".join(hits) + + +def _poe_class_text(evidence: str) -> str: + m = _POE_CLASS_RE.search(evidence or "") + if not m: + return "" + raw = m.group(0) + if re.search(r"802\.3BT", raw, re.I): + return "802.3bt" + if re.search(r"802\.3AT", raw, re.I): + return "802.3at" + if re.search(r"802\.3AF", raw, re.I): + return "802.3af" + if m.group(1): + return f"Class {m.group(1)}" + if m.group(2): + return f"Type {m.group(2)}" + return raw + + +def _cc_termination( + graph: DesignGraph, jack_ref: str, net: str, +) -> tuple[str, str]: + """Return (pass|fail|insufficient, detail).""" + refs = refs_on_matched_net(graph, net) + r_gnd: list[tuple[str, float | None]] = [] + r_pwr: list[tuple[str, float | None]] = [] + ics: list[str] = [] + for r in refs: + if r == jack_ref: + continue + c = graph.components.get(r) + if c is None: + continue + if c.component_type == ComponentType.IC: + ics.append(r) + continue + if c.component_type != ComponentType.RESISTOR: + continue + others = [n for n in c.pins.values() if n and not _nets_equal(n, net)] + ohms = _resistor_ohms(c) + if any(_is_gnd(n) for n in others): + r_gnd.append((r, ohms)) + elif any(_is_power(graph, n) for n in others): + r_pwr.append((r, ohms)) + if r_gnd: + known = [o for _, o in r_gnd if o is not None] + if any(o is None for _, o in r_gnd) and not known: + return "insufficient", f"Rd candidate {[r for r, _ in r_gnd]} value unknown" + if known and all(_in_band(o, USB_C_RD_OHM, USB_C_RD_TOL) for o in known): + return "pass", f"Rd={[f'{r}={o:g}Ω' for r, o in r_gnd if o is not None]}" + if known: + return "fail", ( + f"Rd not 5.1k {[f'{r}={o:g}Ω' for r, o in r_gnd if o is not None]}" + ) + if r_pwr: + known = [o for _, o in r_pwr if o is not None] + if any(o is None for _, o in r_pwr) and not known: + return "insufficient", f"Rp candidate {[r for r, _ in r_pwr]} value unknown" + if known and all(_in_rp_window(o) for o in known): + return "pass", f"Rp={[f'{r}={o:g}Ω' for r, o in r_pwr if o is not None]}" + if known: + return "fail", ( + f"Rp not 56/22/10k {[f'{r}={o:g}Ω' for r, o in r_pwr if o is not None]}" + ) + if ics: + return "insufficient", f"CC on IC {ics} without discrete Rp/Rd FACT" + return "fail", "CC floating (no Rp/Rd, no CC IC)" + + +def _worse_term(a: str, b: str) -> str: + rank = {"pass": 0, "insufficient": 1, "fail": 2} + return a if rank.get(a, 0) >= rank.get(b, 0) else b + + +def _bob_smith_ohms(graph: DesignGraph, jack: Component) -> list[str]: + found: list[str] = [] + extra = [n for n in graph.nets if _CT_RE.search(_leaf(n))] + for net in list(jack.pins.values()) + extra: + if not net: + continue + for r in refs_on_matched_net(graph, net): + c = graph.components.get(r) + if c is None or c.component_type != ComponentType.RESISTOR: + continue + ohms = _resistor_ohms(c) + if ohms is not None and _in_band(ohms, BOB_SMITH_OHM, BOB_SMITH_TOL): + found.append(f"{c.reference}={ohms:g}Ω") + return found + + +def _resistor_ohms(comp: Component) -> float | None: + specs = comp.specs + if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0: + return float(specs.value_ohms) + return _parse_ohms(comp.value) + + +def _parse_ohms(raw: str | None) -> float | None: + t = (raw or "").strip().upper().replace("OHMS", "").replace("OHM", "") + t = t.replace("Ω", "").replace(" ", "") + if not t: + return None + m = _OHM_EMBEDDED.match(t) + if m: + mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)] + return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult + m = _OHM_SUFFIX.match(t) + if m: + mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)] + return float(m.group(1)) * mult + try: + return float(t) + except ValueError: + return None + + +def _in_band(value: float, center: float, tol: float) -> bool: + return abs(value - center) <= center * tol + + +def _in_rp_window(ohms: float) -> bool: + return any(_in_band(ohms, c, USB_C_RP_TOL) for c in USB_C_RP_OHM) + + +def _leaf(net: str) -> str: + n = normalize_kicad_hierarchy_net(net) + return n.split("/")[-1] if n else "" + + +def _is_gnd(net: str) -> bool: + leaf = _leaf(net).upper() + return leaf in {"GND", "AGND", "DGND", "PGND", "VSS", "GNDA", "GNDD"} or ( + leaf.startswith("GND") or leaf.endswith("_GND") or leaf.endswith("_VSS") + ) + + +def _is_power(graph: DesignGraph, net: str) -> bool: + n = graph.nets.get(net) + if n and n.net_type == NetType.POWER: + return True + leaf = _leaf(net) + if _VBUS_RE.search(leaf): + return True + return bool(re.match(r"^\+?\d+(\.\d+)?V", leaf, re.I)) + + +def _nets_equal(a: str, b: str) -> bool: + if a == b: + return True + return _leaf(a).upper() == _leaf(b).upper() + + +def _na(designator: str, rule_id: str, kind: str, facts: str) -> Finding: + return _info( + designator, rule_id, + f"{kind} N/A — no candidate to certify.", + facts, + f"{kind} certifier runs always; absence is N/A, not ERROR.", + ) + + +def _info( + designator: str, rule_id: str, finding: str, facts: str, requirement: str, + *, mpn: str = "", net: str | None = None, pins: list[str] | None = None, + finding_class: str = "INFO", provenance: str = "TYPICAL", +) -> Finding: + rec = "No connection-integrity change required for this check." + return Finding( + designator=designator, + mpn=mpn, + aspect="interface_class", + finding=finding, + why=requirement, + facts=facts, + requirement=requirement, + inference="Class/integrity FACT from netlist — not SI Z0 and not invented I.", + status="INFO", + recommendation=rec, + action=rec, + source=SOURCE, + rule_id=rule_id, + finding_class=finding_class, # type: ignore[arg-type] + provenance=provenance, # type: ignore[arg-type] + evidence_status="SUFFICIENT", + confidence=0.9, + net=net, + pins=pins or [], + ) + + +def _insufficient( + designator: str, rule_id: str, finding: str, facts: str, requirement: str, + *, mpn: str = "", +) -> Finding: + rec = "Add BOM/net/footprint evidence, then re-run. Do not invent class or values." + return Finding( + designator=designator, + mpn=mpn, + aspect="interface_class", + finding=finding, + why=requirement, + facts=facts, + requirement=requirement, + inference=( + "Insufficient evidence — not inventing PoE, SuperSpeed, Z, I, or mm." + ), + status="INFO", + recommendation=rec, + action=rec, + source=SOURCE, + rule_id=rule_id, + finding_class="INFO", + provenance="TYPICAL", + evidence_status="INSUFFICIENT", + confidence=0.3, + pins=[], + ) + + +def _error( + designator: str, rule_id: str, finding: str, facts: str, requirement: str, + action: str, *, mpn: str = "", +) -> Finding: + return Finding( + designator=designator, + mpn=mpn, + aspect="interface_class", + finding=finding, + why=requirement, + facts=facts, + requirement=requirement, + inference="Measured netlist FACT vs USB-C / IEEE connection requirement.", + status="ERROR", + recommendation=action, + action=action, + source=SOURCE, + rule_id=rule_id, + finding_class="RULE", + provenance="MANDATORY", + evidence_status="SUFFICIENT", + confidence=0.85, + pins=[], + ) diff --git a/periscope/src/backend/periscopex/pcb_checks.py b/periscope/src/backend/periscopex/pcb_checks.py index e99c447..be8272d 100644 --- a/periscope/src/backend/periscopex/pcb_checks.py +++ b/periscope/src/backend/periscopex/pcb_checks.py @@ -28,6 +28,7 @@ from backend.periscopex.pcb_power_thermal import ( ) from backend.periscopex.pi_check import check_power_integrity from backend.periscopex.placement_check import check_placement +from backend.periscopex.interface_class_check import SOURCE as IF_SOURCE, check_interface_classes from backend.periscopex.si_check import check_si from backend.periscopex.spof_check import check_spof from backend.periscopex.timing_check import check_timing @@ -161,7 +162,19 @@ def merge_schema_pcb_reports( f for f in (schema.get("findings") or []) if not str(f.get("finding_id") or "").startswith("PCB-") ] - findings.extend(pcb.get("findings") or []) + schema_if = { + (str(f.get("rule_id") or ""), str(f.get("designator") or "")) + for f in findings + if f.get("source") == IF_SOURCE + } + for f in (pcb.get("findings") or []): + if ( + f.get("source") == IF_SOURCE + and (str(f.get("rule_id") or ""), str(f.get("designator") or "")) + in schema_if + ): + continue + findings.append(f) out = dict(schema) out["findings"] = findings summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0} @@ -272,6 +285,7 @@ def run_pcb_checks( ("spof_check", lambda: check_spof(graph, constraints_map)), ("emi_check", lambda: check_emi(graph, constraints_map, layout)), ("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)), + ("interface_class", lambda: check_interface_classes(graph)), ): try: out.extend(fn()) diff --git a/periscope/src/backend/periscopex/pcb_review.py b/periscope/src/backend/periscopex/pcb_review.py index 3e71863..31b9f73 100644 --- a/periscope/src/backend/periscopex/pcb_review.py +++ b/periscope/src/backend/periscopex/pcb_review.py @@ -12,6 +12,7 @@ from backend.periscopex.pcb_net_match import kicad_nets_match from backend.periscopex.pcb_power_thermal import _footprint_region from backend.periscopex.placement_check import _in_poly from backend.periscopex.si_check import net_length_mm +from backend.periscopex.interface_class_check import format_interface_class_context PCB_SYSTEM_PROMPT = """\ You are an electrical engineer reviewing a PCB layout against the IC \ @@ -35,6 +36,10 @@ notes. Do not claim the via count or size is missing if that block lists them. Do not claim antenna keepout geometry is missing if keepout zones are listed. - Kelvin/sense pins, crystal keepout — only if named in the pintable/layout_rules. - Creepage/clearance only if the extraction or IEC number is in context. +- Interface class: USB-C CC/Rd/VBUS, Ethernet 10/100 vs GbE magnetics, \ +PoE only with evidence. Deterministic certifiers already ran; explain \ +those FACTS. Never invent PoE on a bare RJ45 or SuperSpeed on USB2 Type-C. \ +Never invent millimetres, Z, or I. ### Evidence Cite numbers from "Parsed board geometry". Say insufficient evidence ONLY \ @@ -315,4 +320,5 @@ def build_pcb_layout_context( ) if domain_id: lines.append(f"(domain_id={domain_id})") + lines.append(format_interface_class_context(graph)) return "\n".join(lines) diff --git a/periscope/src/backend/periscopex/review_parse.py b/periscope/src/backend/periscopex/review_parse.py index fdaa0f3..2d06858 100644 --- a/periscope/src/backend/periscopex/review_parse.py +++ b/periscope/src/backend/periscopex/review_parse.py @@ -40,6 +40,10 @@ datasheet's recommended values. - Required external components named by the datasheet (bootstrap, \ compensation, feedback divider, sense resistor). - Unused / no-connect pins. +- Dedicated interface class (USB-C CC1/CC2 Rp/Rd 5.1 kΩ VBUS/GND; Ethernet \ +10/100 vs GbE magnetics; PoE only with evidence). Deterministic certifiers \ +own those FACTS. Do not invent PoE on a bare RJ45, SuperSpeed on USB 2.0 \ +Type-C, or geometry/Z/I. Then work the areas one at a time. For EACH area, don't just confirm a \ part is present — ask what specific failure mode would make it wrong \ diff --git a/periscope/src/backend/services/validation.py b/periscope/src/backend/services/validation.py index 605f969..a053c45 100644 --- a/periscope/src/backend/services/validation.py +++ b/periscope/src/backend/services/validation.py @@ -24,6 +24,7 @@ from backend.periscopex.filter_check import check_filters from backend.periscopex.finding_engine import apply_decisions from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage from backend.periscopex.internal_features_check import check_internal_features +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 @@ -84,6 +85,7 @@ def _run_deterministic_checks( ("internal_features_check", lambda: check_internal_features(graph, constraints_map)), ("crystal_cl_check", lambda: check_crystal_cl(graph)), ("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)), + ("interface_class", lambda: check_interface_classes(graph)), ): try: out.extend(fn()) diff --git a/periscope/src/docs/interface-class-certifiers.md b/periscope/src/docs/interface-class-certifiers.md new file mode 100644 index 0000000..a495870 --- /dev/null +++ b/periscope/src/docs/interface-class-certifiers.md @@ -0,0 +1,34 @@ +# Certifier di classe (interfacce) + +Michele 2026-09-21: per USB-C, Ethernet (10/100 vs GbE), PoE, DDR3 e analoghe, check dedicati (deterministici + IA). Certificare **classe** e **integrità delle connessioni**. FEM termico: no, per ora. + +## Cosa non è + +Non è un unico check SI generico. Non è il DRC KiCad. Non è “via ampacity IPC” (traccia vs corrente è un altro motore). + +## Formato finding + +Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). Se manca evidenza: INSUFFICIENT_EVIDENCE, niente geometria/Z/I inventati. Via ≠ pad ≠ track. + +## Prima fetta (shipped 2.61.0) + +| ID | Cosa certifica | +| --- | --- | +| PE-USBC-001 | Classe USB-C USB2 vs USB3 (BOM/footprint/MPN/net) | +| PE-USBC-002 | CC1 e CC2 connessi, non NC | +| PE-USBC-003 | Rd 5.1 kΩ ±10% verso GND, o Rp 56/22/10 kΩ verso VBUS; IC CC → INSUFFICIENT | +| PE-USBC-004 | VBUS e GND | +| PE-USBC-005 | Coppie SuperSpeed **solo** se la classe è USB3; USB2 = N/A | +| PE-ETH-001 | Classe 10/100 vs GbE | +| PE-ETH-002 | Coppie TX/RX (10/100) o quattro MDI (GbE) | +| PE-ETH-003 | Magnetics se GbE; N/A se non GbE | +| PE-ETH-004 | Bob Smith 75 Ω (RECOMMENDED; spesso nel MagJack) | +| PE-POE-001 | Evidenza PoE, altrimenti N/A (RJ45 nudo ≠ PoE) | +| PE-POE-002 | Classe/tipo 802.3af/at/bt se citati; altrimenti INSUFFICIENT | +| PE-POE-003 | Isolamento/magnetics solo con evidenza PoE | + +Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. L’IA spiega i FACT, non certifica. + +## Dopo + +DDR3 (indirizzo/comando vs DQ, lunghezze, VTT/VREF). Altre periferiche con lo stesso schema. diff --git a/periscope/src/docs/piano-pcb-review.md b/periscope/src/docs/piano-pcb-review.md index 58bd0e8..0368511 100644 --- a/periscope/src/docs/piano-pcb-review.md +++ b/periscope/src/docs/piano-pcb-review.md @@ -74,6 +74,9 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net | PE-EMI-001 | filtro EMI solo se `layout_rules` cita choke/ferrite/shield | | PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net | | PE-STCH-001 | pour GND + segnale senza via GND nel bbox | +| PE-USBC-001…005 | classe USB-C + CC + Rd/Rp + VBUS/GND + SuperSpeed se USB3 | +| PE-ETH-001…004 | 10/100 vs GbE; magnetics se GbE; Bob Smith REVIEW | +| PE-POE-001…003 | PoE solo con evidenza; isolamento/magnetics; RJ45 nudo = N/A | | Creepage | **skip** senza numero datasheet/IEC | | Crystal keepout | PE-PLC-004 se kind=keepout | @@ -122,6 +125,7 @@ Pintable skill **1.13.0** (`net_class` required on SI kinds). Older extracts wit | Gap | Why it stays out | | --- | --- | +| DDR3 class certifier | After USB-C / Ethernet / PoE first slice (2.61.0) | | Thermal FEM / energy | PE-THM-002 is Tj=Ta+P·θJA with measured copper/vias; no spreading/FEM | | CPWG / OpenEMS | Field-solver export excluded from the ImpedenceFinder vendor snapshot | | ImpedenceFinder license | Upstream has **no LICENSE** (UNKNOWN). Not invented in-tree. See `vendor/impedancefinder/SOURCE.md` | diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index ee22c7f..9963030 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,15 @@ What's new in Periscope. +## 2.61.0 — 2026-09-21 — Interface class certifiers (USB-C, Ethernet, PoE) + +Dedicated per-interface certifiers (deterministic + AI explanation). Not generic SI. USB-C: CC1/CC2, Rp/Rd 5.1 kΩ, VBUS/GND, SuperSpeed only if the class is USB3. Ethernet: 10/100 vs GbE; magnetics required for GbE. PoE only with PoE evidence — a bare RJ45 is N/A, never invented PoE. Missing generation/class/ohms → INSUFFICIENT. DDR3 and via IPC ampacity are out of this slice. No FEM. + +- [New] `PE-USBC-001`…`005` USB-C class + CC + Rd/Rp + VBUS/GND + SuperSpeed gate. +- [New] `PE-ETH-001`…`004` Ethernet 10/100 vs GbE, TX/RX pairs, GbE magnetics, Bob Smith REVIEW. +- [New] `PE-POE-001`…`003` PoE evidence / class / isolation; bare RJ45 is N/A. +- [New] Taxonomy `connector.rj45`. AI prompts must not invent PoE or SuperSpeed. + ## 2.60.11 — 2026-09-21 — PCB software false positives KiCad 9/10 boards have no numeric net table (`(net "GND")` on pads/tracks). The net index is filled from those names. PE-LAY-004 counts copper on **tracks, vias, and pours**, skips `unconnected-(…)` NC nets, and matches `/sheet/` prefixes — it must not report ~139 “unrouted” nets when KiCad DRC `unconnected_items` is ~30 (islands on already-named nets). Missing `I_load` is INSUFFICIENT on PE-PWR-001 / PE-VIA-001. Thermal/EP extra pads are not PE-BOM-011 ERROR. Voltage abs-max binds to the named pin. Kelvin ignores CRS/IN±. EMI/PI skip NC nets. LQW18AN `murata_lqw_inductance` decodes nH. FB/BLM beads are ferrite; DCR is not Z. Thinking `reasoning_content` echo unchanged. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index 7b33a72..2ea5a73 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.60.0", + "version": "2.61.0", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts index 87594fe..b615381 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.56.0"; -export const APP_VERSION_DATE = "2026-09-20"; +export const APP_VERSION = "2.61.0"; +export const APP_VERSION_DATE = "2026-09-21"; diff --git a/periscope/src/taxonomy/connector.json b/periscope/src/taxonomy/connector.json index 303edd2..0a1e394 100644 --- a/periscope/src/taxonomy/connector.json +++ b/periscope/src/taxonomy/connector.json @@ -45,6 +45,19 @@ } ] }, + "connector.rj45": { + "description": "RJ45 / 8P8C Ethernet jack (with or without magnetics)", + "extra_specs": [ + { + "name": "speed", + "description": "Ethernet speed class (10/100, 1000) when marked" + }, + { + "name": "poe", + "description": "PoE marking when present (never inferred from a bare jack)" + } + ] + }, "connector.fpc": { "description": "FPC / FFC connector", "extra_specs": [ diff --git a/tests/test_eval_report.py b/tests/test_eval_report.py index 4980734..0ef734b 100644 --- a/tests/test_eval_report.py +++ b/tests/test_eval_report.py @@ -1,7 +1,8 @@ """Eval harness — finding count, citation hit-rate, precision/recall. Favor: perfect golden match; citation rate ignores deterministic findings; -simple_project graph still has U1/U2/U3 and the two I2C pull-up keys. +simple_project graph still has U1/U2/U3, the two I2C pull-up keys, +and USB-C class certifiers on J1 (Ethernet/PoE N/A). Against: extra finding drops precision; missing golden key drops recall; Unverified quotes are citation misses, not hits. """ @@ -87,9 +88,9 @@ def test_simple_project_eval_matches_committed_golden(): assert scores.graph_ok, scores.graph_errors assert scores.precision == 1.0 assert scores.recall == 1.0 - assert scores.finding_count == 3 + assert scores.finding_count == 10 assert scores.by_status["WARNING"] == 2 - assert scores.by_status["INFO"] == 1 + assert scores.by_status["INFO"] == 8 def test_simple_project_eval_rejects_truncated_graph(tmp_path: Path): diff --git a/tests/test_interface_class_check.py b/tests/test_interface_class_check.py new file mode 100644 index 0000000..d5d1956 --- /dev/null +++ b/tests/test_interface_class_check.py @@ -0,0 +1,609 @@ +"""Interface class certifiers — USB-C, Ethernet 10/100 vs GbE, PoE. + +First slice: class + connection integrity. Not generic SI, not via ampacity, +not thermal FEM. Missing evidence is INSUFFICIENT or N/A, never invented +geometry / Z / I / PoE / SuperSpeed. +""" + +from __future__ import annotations + +from backend.periscopex.finding_engine import complete_finding, lookup_rule +from backend.periscopex.interface_class_check import check_interface_classes +from backend.periscopex.models import ( + Component, + ComponentType, + DesignGraph, + Net, + NetType, + PinConnection, + ResistorSpecs, +) +from backend.periscopex.pcb_checks import merge_schema_pcb_reports, run_pcb_checks +from tests.paths import SIMPLE_PROJECT + + +def _comp( + ref: str, + *, + ctype: ComponentType, + value: str = "", + footprint: str = "", + mpn: str = "", + pins: dict[str, str] | None = None, + subtype: str | None = None, + specs=None, +) -> Component: + return Component( + reference=ref, + value=value, + footprint=footprint, + component_type=ctype, + component_subtype=subtype, + mpn=mpn or None, + pins=pins or {}, + specs=specs, + ) + + +def _net(name: str, ntype: NetType, *refs_pins: tuple[str, str]) -> Net: + return Net( + name=name, + net_type=ntype, + pins=[PinConnection(component_ref=r, pin_number=p) for r, p in refs_pins], + ) + + +def _by_rule(findings, rule_id: str, designator: str | None = None): + out = [ + f for f in findings + if f.rule_id == rule_id and (designator is None or f.designator == designator) + ] + return out + + +def _rd(ref: str, cc_net: str) -> Component: + return _comp( + ref, + ctype=ComponentType.RESISTOR, + value="5k1", + pins={"1": cc_net, "2": "GND"}, + specs=ResistorSpecs( + value_ohms=5100.0, value_formatted="5.1k", + ), + ) + + +def _usbc_device_graph(*, ss: bool = False, usb2_value: bool = True) -> DesignGraph: + """USB-C receptacle with Rd 5.1 kΩ on CC1/CC2, VBUS, GND.""" + value = "USB_C_Receptacle_USB2.0_16P" if usb2_value else "USB_C_Receptacle_USB3.1" + pins = { + "A5": "USB_CC1", + "B5": "USB_CC2", + "A4": "VBUS", + "A9": "VBUS", + "A1": "GND", + "A12": "GND", + "A6": "USB_D+", + "A7": "USB_D-", + } + nets = { + "USB_CC1": _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("R10", "1")), + "USB_CC2": _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("R11", "1")), + "VBUS": _net("VBUS", NetType.POWER, ("J2", "A4"), ("J2", "A9")), + "GND": _net("GND", NetType.GROUND, ("J2", "A1"), ("J2", "A12"), ("R10", "2"), ("R11", "2")), + "USB_D+": _net("USB_D+", NetType.SIGNAL, ("J2", "A6")), + "USB_D-": _net("USB_D-", NetType.SIGNAL, ("J2", "A7")), + } + comps = { + "J2": _comp( + "J2", + ctype=ComponentType.CONNECTOR, + value=value, + footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12", + mpn="TYPE-C-31-M-12", + subtype="connector.usb", + pins=pins, + ), + "R10": _rd("R10", "USB_CC1"), + "R11": _rd("R11", "USB_CC2"), + } + if ss: + pins["A2"] = "USB_SSTX_P" + pins["A3"] = "USB_SSTX_N" + pins["B11"] = "USB_SSRX_P" + pins["B10"] = "USB_SSRX_N" + nets["USB_SSTX_P"] = _net("USB_SSTX_P", NetType.SIGNAL, ("J2", "A2")) + nets["USB_SSTX_N"] = _net("USB_SSTX_N", NetType.SIGNAL, ("J2", "A3")) + nets["USB_SSRX_P"] = _net("USB_SSRX_P", NetType.SIGNAL, ("J2", "B11")) + nets["USB_SSRX_N"] = _net("USB_SSRX_N", NetType.SIGNAL, ("J2", "B10")) + comps["J2"] = _comp( + "J2", + ctype=ComponentType.CONNECTOR, + value="USB_C_Receptacle_USB3.1", + footprint="Connector_USB:USB_C_Receptacle_24P", + mpn="USB3-C-24", + subtype="connector.usb", + pins=pins, + ) + return DesignGraph(components=comps, nets=nets) + + +def test_no_connector_is_na_not_error(): + g = DesignGraph( + components={ + "U1": _comp("U1", ctype=ComponentType.IC, value="MCU", pins={"1": "GND"}), + }, + nets={"GND": _net("GND", NetType.GROUND, ("U1", "1"))}, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + assert _by_rule(findings, "PE-USBC-001") + assert _by_rule(findings, "PE-ETH-001") + assert _by_rule(findings, "PE-POE-001") + assert all(f.status != "ERROR" for f in findings) + assert all(f.finding_class == "INFO" for f in findings) + assert "N/A" in _by_rule(findings, "PE-USBC-001")[0].finding + assert "N/A" in _by_rule(findings, "PE-ETH-001")[0].finding + assert "N/A" in _by_rule(findings, "PE-POE-001")[0].finding + + +def test_usbc_rd_vbus_gnd_certified(): + findings = check_interface_classes(_usbc_device_graph()) + for f in findings: + complete_finding(f) + cls = _by_rule(findings, "PE-USBC-001", "J2")[0] + assert cls.status == "INFO" + assert "USB2" in cls.finding + assert _by_rule(findings, "PE-USBC-002", "J2")[0].status == "INFO" + rd = _by_rule(findings, "PE-USBC-003", "J2")[0] + assert rd.status == "INFO" + assert rd.evidence_status == "SUFFICIENT" + assert "5100" in rd.facts or "5.1" in rd.facts + assert _by_rule(findings, "PE-USBC-004", "J2")[0].status == "INFO" + ss = _by_rule(findings, "PE-USBC-005", "J2")[0] + assert ss.status != "ERROR" + assert ss.finding_class == "INFO" + assert "N/A" in ss.finding or "USB2" in ss.finding + assert all(f.status != "ERROR" for f in findings if f.designator == "J2") + + +def test_usbc_missing_cc2_is_error(): + g = _usbc_device_graph() + j2 = g.components["J2"] + pins = dict(j2.pins) + pins.pop("B5") + g.components["J2"] = j2.model_copy(update={"pins": pins}) + del g.nets["USB_CC2"] + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + cc = _by_rule(findings, "PE-USBC-002", "J2")[0] + assert cc.status == "ERROR" + assert cc.finding_class == "RULE" + assert cc.evidence_status == "SUFFICIENT" + + +def test_usbc_wrong_rd_is_error(): + g = _usbc_device_graph() + g.components["R10"] = _comp( + "R10", + ctype=ComponentType.RESISTOR, + value="10k", + pins={"1": "USB_CC1", "2": "GND"}, + specs=ResistorSpecs(value_ohms=10000.0, value_formatted="10k"), + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + rd = _by_rule(findings, "PE-USBC-003", "J2")[0] + assert rd.status == "ERROR" + assert rd.finding_class == "RULE" + + +def test_usbc_cc_to_controller_without_r_is_insufficient_not_error(): + g = _usbc_device_graph() + del g.components["R10"] + del g.components["R11"] + g.components["U5"] = _comp( + "U5", + ctype=ComponentType.IC, + value="CC controller", + mpn="CC-IC", + pins={"1": "USB_CC1", "2": "USB_CC2"}, + ) + g.nets["USB_CC1"] = _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("U5", "1")) + g.nets["USB_CC2"] = _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("U5", "2")) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + rd = _by_rule(findings, "PE-USBC-003", "J2")[0] + assert rd.status != "ERROR" + assert rd.evidence_status == "INSUFFICIENT" + assert rd.finding_class == "INFO" + + +def test_usbc_unknown_r_value_is_insufficient(): + g = _usbc_device_graph() + g.components["R10"] = _comp( + "R10", + ctype=ComponentType.RESISTOR, + value="", + pins={"1": "USB_CC1", "2": "GND"}, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + rd = _by_rule(findings, "PE-USBC-003", "J2")[0] + assert rd.status != "ERROR" + assert rd.evidence_status == "INSUFFICIENT" + + +def test_usb3_class_without_ss_pairs_is_error(): + g = _usbc_device_graph(usb2_value=False) + j2 = g.components["J2"] + g.components["J2"] = j2.model_copy(update={ + "value": "USB_C_Receptacle_USB3.1", + "footprint": "Connector_USB:USB_C_Receptacle_24P", + "mpn": "USB3-C", + }) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + ss = _by_rule(findings, "PE-USBC-005", "J2")[0] + assert ss.status == "ERROR" + assert ss.finding_class == "RULE" + assert "SuperSpeed" in ss.finding or "USB3" in ss.finding + + +def test_usb3_with_ss_pairs_is_certified(): + findings = check_interface_classes(_usbc_device_graph(ss=True, usb2_value=False)) + for f in findings: + complete_finding(f) + ss = _by_rule(findings, "PE-USBC-005", "J2")[0] + assert ss.status == "INFO" + assert ss.evidence_status == "SUFFICIENT" + assert ss.status != "ERROR" + + +def test_missing_vbus_is_error(): + g = _usbc_device_graph() + j2 = g.components["J2"] + pins = {k: v for k, v in j2.pins.items() if v != "VBUS"} + g.components["J2"] = j2.model_copy(update={"pins": pins}) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + pwr = _by_rule(findings, "PE-USBC-004", "J2")[0] + assert pwr.status == "ERROR" + assert pwr.finding_class == "RULE" + + +def _rj45( + *, + value: str, + mpn: str, + pins: dict[str, str], + extra_comps: dict[str, Component] | None = None, + extra_nets: dict[str, Net] | None = None, +) -> DesignGraph: + comps = { + "J1": _comp( + "J1", + ctype=ComponentType.CONNECTOR, + value=value, + footprint="Connector_RJ:RJ45", + mpn=mpn, + pins=pins, + ), + } + nets = {} + for pin, net in pins.items(): + nets.setdefault(net, Net(name=net, net_type=NetType.SIGNAL, pins=[])) + nets[net].pins.append(PinConnection(component_ref="J1", pin_number=pin)) + if net.upper() in {"GND", "AGND"}: + nets[net].net_type = NetType.GROUND + if extra_comps: + comps.update(extra_comps) + if extra_nets: + for n, net in extra_nets.items(): + if n in nets: + nets[n].pins.extend(net.pins) + else: + nets[n] = net + return DesignGraph(components=comps, nets=nets) + + +def test_ethernet_10_100_magjack_no_gbe_magnetics_error(): + g = _rj45( + value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module", + mpn="ARJP11A-MASA-B-A-EMU2", + pins={ + "1": "ETH_TX+", + "2": "ETH_TX-", + "3": "ETH_RX+", + "6": "ETH_RX-", + }, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + cls = _by_rule(findings, "PE-ETH-001", "J1")[0] + assert cls.status == "INFO" + assert "10/100" in cls.finding + assert "GbE" not in cls.finding or "not GbE" in cls.finding.lower() or "10/100" in cls.finding + pairs = _by_rule(findings, "PE-ETH-002", "J1")[0] + assert pairs.status == "INFO" + mag = _by_rule(findings, "PE-ETH-003", "J1")[0] + assert mag.status != "ERROR" + assert mag.finding_class == "INFO" + assert "N/A" in mag.finding or "10/100" in mag.finding + + +def test_gbe_without_magnetics_is_error(): + g = _rj45( + value="RJ45 1000BASE-T", + mpn="RJ45-GBE-BARE", + pins={ + "1": "TRD0_P", + "2": "TRD0_N", + "3": "TRD1_P", + "6": "TRD1_N", + "4": "TRD2_P", + "5": "TRD2_N", + "7": "TRD3_P", + "8": "TRD3_N", + }, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + cls = _by_rule(findings, "PE-ETH-001", "J1")[0] + assert "GbE" in cls.finding or "1000" in cls.finding + mag = _by_rule(findings, "PE-ETH-003", "J1")[0] + assert mag.status == "ERROR" + assert mag.finding_class == "RULE" + assert mag.evidence_status == "SUFFICIENT" + + +def test_gbe_with_magnetics_is_certified(): + g = _rj45( + value="RJ45 1000BASE-T MagJack", + mpn="PULSE-GBE-MAG", + pins={ + "1": "TRD0_P", + "2": "TRD0_N", + "3": "TRD1_P", + "6": "TRD1_N", + "4": "TRD2_P", + "5": "TRD2_N", + "7": "TRD3_P", + "8": "TRD3_N", + }, + extra_comps={ + "T1": _comp( + "T1", + ctype=ComponentType.TRANSFORMER, + value="LAN magnetics", + mpn="HX1198FNL", + subtype="transformer.signal", + pins={"1": "TRD0_P", "2": "TRD0_N"}, + ), + }, + extra_nets={ + "TRD0_P": _net("TRD0_P", NetType.SIGNAL, ("T1", "1")), + }, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + mag = _by_rule(findings, "PE-ETH-003", "J1")[0] + assert mag.status == "INFO" + assert mag.evidence_status == "SUFFICIENT" + assert mag.status != "ERROR" + + +def test_rj45_without_speed_is_insufficient_not_invented_gbe(): + g = _rj45( + value="RJ45", + mpn="8P8C", + pins={"1": "NET1", "2": "NET2", "3": "NET3", "6": "NET6"}, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + cls = _by_rule(findings, "PE-ETH-001", "J1")[0] + assert cls.status != "ERROR" + assert cls.evidence_status == "INSUFFICIENT" + mag = _by_rule(findings, "PE-ETH-003", "J1")[0] + assert mag.status != "ERROR" + assert mag.finding_class == "INFO" + + +def test_bare_rj45_does_not_invent_poe(): + g = _rj45( + value="RJ45", + mpn="8P8C-BARE", + pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"}, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + poe = _by_rule(findings, "PE-POE-001", "J1")[0] + assert poe.status != "ERROR" + assert poe.finding_class == "INFO" + assert "N/A" in poe.finding + assert not _by_rule(findings, "PE-POE-003", "J1") or _by_rule( + findings, "PE-POE-003", "J1", + )[0].status != "ERROR" + + +def test_poe_with_evidence_requires_magnetics_isolation(): + g = _rj45( + value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module", + mpn="ARJP11A-MASA-B-A-EMU2", + pins={ + "1": "ETH_TX+", + "2": "ETH_TX-", + "3": "ETH_RX+", + "6": "ETH_RX-", + }, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + ev = _by_rule(findings, "PE-POE-001", "J1")[0] + assert ev.status == "INFO" + assert ev.evidence_status == "SUFFICIENT" + assert "PoE" in ev.finding + cls = _by_rule(findings, "PE-POE-002", "J1")[0] + assert cls.status != "ERROR" + assert cls.evidence_status == "INSUFFICIENT" + iso = _by_rule(findings, "PE-POE-003", "J1")[0] + assert iso.status == "INFO" + assert iso.evidence_status == "SUFFICIENT" + + +def test_poe_evidence_on_bare_jack_is_isolation_error(): + g = _rj45( + value="RJ45 PoE 802.3af", + mpn="RJ45-POE-BARE", + pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"}, + ) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + iso = _by_rule(findings, "PE-POE-003", "J1")[0] + assert iso.status == "ERROR" + assert iso.finding_class == "RULE" + + +def test_simple_project_usbc_not_false_error(): + graph = DesignGraph.model_validate_json( + (SIMPLE_PROJECT / "design_graph.json").read_text() + ) + findings = check_interface_classes(graph) + for f in findings: + complete_finding(f) + usbc = [f for f in findings if (f.rule_id or "").startswith("PE-USBC") and f.designator == "J1"] + assert usbc + assert all(f.status != "ERROR" for f in usbc) + rd = _by_rule(findings, "PE-USBC-003", "J1")[0] + assert rd.status == "INFO" + assert rd.evidence_status == "SUFFICIENT" + ss = _by_rule(findings, "PE-USBC-005", "J1")[0] + assert ss.status != "ERROR" + assert ss.evidence_status == "INSUFFICIENT" or "N/A" in ss.finding + assert _by_rule(findings, "PE-ETH-001")[0].status != "ERROR" + assert "N/A" in _by_rule(findings, "PE-ETH-001")[0].finding + assert _by_rule(findings, "PE-POE-001")[0].status != "ERROR" + assert "N/A" in _by_rule(findings, "PE-POE-001")[0].finding + + +def test_hubaudio_like_usbc_usb2_and_poe_magjack(): + """HubAudio J2 is USB2 Type-C 16P; J1 is PoE 10/100 MagJack — no false SS/GbE/PoE class.""" + usbc = _usbc_device_graph() + eth = _rj45( + value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module", + mpn="ARJP11A-MASA-B-A-EMU2", + pins={ + "1": "ETH_TX+", + "2": "ETH_TX-", + "3": "ETH_RX+", + "6": "ETH_RX-", + }, + ) + comps = dict(usbc.components) + comps.update(eth.components) + nets = dict(usbc.nets) + for name, net in eth.nets.items(): + if name in nets: + nets[name] = nets[name].model_copy( + update={"pins": list(nets[name].pins) + list(net.pins)}, + ) + else: + nets[name] = net + g = DesignGraph(components=comps, nets=nets) + findings = check_interface_classes(g) + for f in findings: + complete_finding(f) + assert _by_rule(findings, "PE-USBC-005", "J2")[0].status != "ERROR" + assert _by_rule(findings, "PE-ETH-001", "J1")[0].status != "ERROR" + assert _by_rule(findings, "PE-ETH-003", "J1")[0].status != "ERROR" + assert _by_rule(findings, "PE-POE-001", "J1")[0].status != "ERROR" + assert _by_rule(findings, "PE-POE-002", "J1")[0].status != "ERROR" + assert _by_rule(findings, "PE-POE-003", "J1")[0].status != "ERROR" + assert all( + f.status != "ERROR" + for f in findings + if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE")) + ) + + +def test_rule_catalog_shared_not_si(): + for rid in ( + "PE-USBC-001", "PE-USBC-002", "PE-USBC-003", "PE-USBC-004", "PE-USBC-005", + "PE-ETH-001", "PE-ETH-002", "PE-ETH-003", "PE-ETH-004", + "PE-POE-001", "PE-POE-002", "PE-POE-003", + ): + rec = lookup_rule(rid) + assert rec is not None, rid + assert rec.domain == "shared" + + +def test_run_pcb_checks_includes_interface_class(): + findings = run_pcb_checks(DesignGraph(), {}, None) + ids = {f.rule_id for f in findings} + assert "PE-USBC-001" in ids + assert "PE-ETH-001" in ids + assert "PE-POE-001" in ids + if_f = [f for f in findings if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE"))] + assert if_f + assert all(f.status != "ERROR" for f in if_f) + assert all((f.finding_id or "").startswith("PCB-") for f in if_f) + + +def test_merge_drops_duplicate_pcb_interface_findings(): + schema = { + "findings": [{ + "finding_id": "J2-001", + "designator": "J2", + "finding": "class USB-C USB2", + "status": "INFO", + "rule_id": "PE-USBC-001", + "source": "interface_class_check", + }], + "summary": {"INFO": 1, "ERROR": 0, "WARNING": 0}, + } + pcb = { + "findings": [{ + "finding_id": "PCB-J2-001", + "designator": "J2", + "finding": "class USB-C USB2", + "status": "INFO", + "rule_id": "PE-USBC-001", + "source": "interface_class_check", + }], + "summary": {"INFO": 1, "ERROR": 0, "WARNING": 0}, + } + merged = merge_schema_pcb_reports(schema, pcb) + usbc = [ + f for f in merged["findings"] + if f.get("rule_id") == "PE-USBC-001" and f.get("designator") == "J2" + ] + assert len(usbc) == 1 + assert usbc[0]["finding_id"] == "J2-001" + + +def test_does_not_use_layout_geometry(): + """Certifier takes the netlist graph only — no invented mm / Z / I.""" + import inspect + from backend.periscopex import interface_class_check as mod + + sig = inspect.signature(mod.check_interface_classes) + assert list(sig.parameters) == ["graph"] + src = inspect.getsource(mod) + assert "LayoutVia" not in src + assert "LayoutPad" not in src + assert "LayoutSegment" not in src + assert "LayoutZone" not in src diff --git a/tests/test_periscope_leftover_package_requirements.py b/tests/test_periscope_leftover_package_requirements.py index 1956043..e01a323 100644 --- a/tests/test_periscope_leftover_package_requirements.py +++ b/tests/test_periscope_leftover_package_requirements.py @@ -15,9 +15,15 @@ DOCKERIGNORE = ROOT / ".dockerignore" def test_package_json_is_periscope_web(): + import re text = PKG.read_text(encoding="utf-8") assert '"name": "periscope-web"' in text - assert '"version": "2.60.0"' in text + chg = (ROOT / "periscope" / "src" / "frontend" / "content" / "changelog.md").read_text( + encoding="utf-8", + ) + ver = re.search(r"^##\s+(\d+\.\d+\.\d+)", chg, re.M) + assert ver, "changelog missing ## X.Y.Z" + assert f'"version": "{ver.group(1)}"' in text assert "Native Periscope overlay" not in text[:400] assert LOCK.is_file() lock = LOCK.read_text(encoding="utf-8") diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py index 7eabad6..c79ec0d 100644 --- a/tests/test_placement_check.py +++ b/tests/test_placement_check.py @@ -48,7 +48,7 @@ def test_simple_project_without_pcb_has_no_ps_plc(): def test_simple_project_eval_has_no_placement_keys(): scores = eval_simple_project(SIMPLE) - assert scores.finding_count == 3 + assert scores.finding_count == 10 assert scores.precision == 1.0 assert scores.recall == 1.0 assert not any(k.startswith("PE-PLC-") for k in scores.extra_keys) diff --git a/tests/test_si_check.py b/tests/test_si_check.py index 53ba3ee..87b304b 100644 --- a/tests/test_si_check.py +++ b/tests/test_si_check.py @@ -1,6 +1,6 @@ """G1 SI vs simple_project — no invented millimetres or USBPHY boards. -Favor: real /USB.D+ and /USB.D- pair by suffix; eval stays 3 keys. +Favor: real /USB.D+ and /USB.D- pair by suffix; eval has no PE-SI keys. Against: no .kicad_pcb → no PE-SI-001; 3W is not invented. I2C/GPIO/CC are not 50/90 Ω. ImpedenceFinder numbers vs layout_rules only. """ @@ -54,7 +54,7 @@ def test_simple_project_without_pcb_has_no_ps_si_001(): def test_simple_project_eval_has_no_si_keys(): scores = eval_simple_project(SIMPLE) - assert scores.finding_count == 3 + assert scores.finding_count == 10 assert scores.precision == 1.0 assert scores.recall == 1.0 assert not any( diff --git a/tests/test_validation_concurrency.py b/tests/test_validation_concurrency.py index 49f0ce2..eebaad3 100644 --- a/tests/test_validation_concurrency.py +++ b/tests/test_validation_concurrency.py @@ -154,7 +154,15 @@ async def test_concurrency_bounded_by_knob_and_charging_isolated(workspace, monk assert len(shared.entries) == sum(entries_for.values()) == 15 report = json.loads(workspace["report"].read_text()) - assert report["summary"]["total"] == len(IC_MPNS) # all 5 reviewed + review_n = sum( + 1 for f in report["findings"] if f.get("source") in (None, "review") + ) + assert review_n == len(IC_MPNS) # all 5 reviewed + if_ids = { + f.get("rule_id") for f in report["findings"] + if (f.get("rule_id") or "").startswith(("PE-USBC", "PE-ETH", "PE-POE")) + } + assert "PE-USBC-001" in if_ids # N/A certifier, not a 6th IC review @pytest.mark.asyncio @@ -220,4 +228,7 @@ async def test_gate_trip_stops_new_reviews(workspace, monkeypatch): # Report is marked partial because a gate tripped. report = json.loads(workspace["report"].read_text()) assert report.get("partial") is True - assert report["summary"]["total"] == LIMIT + review_n = sum( + 1 for f in report["findings"] if f.get("source") in (None, "review") + ) + assert review_n == LIMIT