Files
magdevandClaude Fable 5 4737a5bae8 Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation
Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now
reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to
decode a container; the emulator survives as a development-only oracle that the
codec is differentially tested against.

Verified:
- 134 containers decode byte-identically to Boxsim's own decoder, 270 pre-2.0
  files rejected by both implementations (tools/difftest.py --self-test)
- Default.BPJ: 22 components, 14 nets, no dangling terminal
- 22 of 22 parts resolve to library symbols; one series inferred from a
  sibling, two DC resistance deviations reported
- ERC clean and the netlist KiCad extracts from the generated sheet is an
  identical partition to the one derived from the Boxsim grid
- container build byte-identical to the host build
- 32 tests passing, ruff clean

Decode time went 69 s -> 14.5 s (binding emulator hooks to trap addresses)
-> 1.6 s (pure Python).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 11:10:43 +02:00

168 lines
5.3 KiB
Python

"""Verify a generated project against the Boxsim data it came from.
The gate that matters is not ERC: a schematic can be perfectly well formed and
still wired to the wrong cells. Instead the netlist KiCad extracts from the
generated sheet is compared against the netlist derived independently from the
Boxsim grid. Two derivations that agree is real evidence; ERC alone is not.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from .model import Crossover
from .schematic import Placement
MIN_KICAD_MAJOR = 10
_NET_RE = re.compile(r"\(net\b(.*?)(?=\n\t\t\(net\b|\n\t\)\s*$)", re.S)
_NAME_RE = re.compile(r'\(name\s+"([^"]*)"\)')
_NODE_RE = re.compile(r'\(node\s*\(ref\s+"([^"]+)"\)\s*\(pin\s+"([^"]+)"\)', re.S)
class VerifyError(RuntimeError):
pass
@dataclass
class GateResult:
erc_ok: bool
erc_violations: int
netlist_matches: bool
kicad_version: str
reference_nets: list[frozenset[str]] = field(default_factory=list)
kicad_nets: list[frozenset[str]] = field(default_factory=list)
messages: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return self.erc_ok and self.netlist_matches
def kicad_cli() -> str:
exe = shutil.which("kicad-cli")
if exe is None:
raise VerifyError(
"kicad-cli not found. KiCad 10 is a hard dependency of the "
"verification gate; install it or run `boxsim build` without --verify."
)
return exe
def kicad_version() -> str:
out = subprocess.run(
[kicad_cli(), "version"], capture_output=True, text=True, check=True
).stdout.strip()
major = out.split(".")[0]
if not major.isdigit() or int(major) != MIN_KICAD_MAJOR:
raise VerifyError(
f"kicad-cli reports version {out!r}, but this tool targets "
f"KiCad {MIN_KICAD_MAJOR}.x schematics"
)
return out
def reference_partition(
crossover: Crossover, placements: list[Placement]
) -> list[frozenset[str]]:
"""Group `REFDES.PIN` terminals by the net they sit on in the Boxsim grid."""
netlist = crossover.build_netlist()
groups: dict[int, set[str]] = {}
for p in placements:
for pin, node in p.pin_nodes.items():
net = netlist.net_of(node)
groups.setdefault(net, set()).add(f"{p.refdes}.{pin}")
return sorted((frozenset(v) for v in groups.values()), key=_net_key)
def parse_netlist(text: str) -> list[frozenset[str]]:
"""Read the `(nets ...)` block of a KiCad sexpr netlist into a partition."""
start = text.find("\t(nets")
if start < 0:
raise VerifyError("exported netlist has no (nets ...) section")
body = text[start:]
out = []
for chunk in body.split("\t\t(net")[1:]:
terminals = {f"{ref}.{pin}" for ref, pin in _NODE_RE.findall(chunk)}
if terminals:
out.append(frozenset(terminals))
if not out:
raise VerifyError("exported netlist contained no nets")
return sorted(out, key=_net_key)
def _net_key(net: frozenset[str]) -> tuple:
return (len(net), tuple(sorted(net)))
def run_gate(
schematic: Path,
crossover: Crossover,
placements: list[Placement],
*,
workdir: Path | None = None,
) -> GateResult:
version = kicad_version()
workdir = workdir or schematic.parent
erc_report = workdir / f"{schematic.stem}-erc.rpt"
netlist_path = workdir / f"{schematic.stem}.net"
erc = subprocess.run(
[
kicad_cli(), "sch", "erc",
"--severity-error", "--exit-code-violations",
"-o", str(erc_report), str(schematic),
],
capture_output=True, text=True,
)
violations = 0
if erc_report.exists():
m = re.search(r"Errors (\d+)", erc_report.read_text(encoding="utf-8"))
if m:
violations = int(m.group(1))
erc_ok = erc.returncode == 0 and violations == 0
export = subprocess.run(
[
kicad_cli(), "sch", "export", "netlist",
"--format", "kicadsexpr", "-o", str(netlist_path), str(schematic),
],
capture_output=True, text=True,
)
if export.returncode != 0:
raise VerifyError(f"netlist export failed: {export.stderr.strip()}")
reference = reference_partition(crossover, placements)
extracted = parse_netlist(netlist_path.read_text(encoding="utf-8"))
messages = []
if not erc_ok:
messages.append(f"ERC reported {violations} error(s); see {erc_report.name}")
matches = reference == extracted
if not matches:
only_ref = [sorted(n) for n in reference if n not in extracted]
only_kicad = [sorted(n) for n in extracted if n not in reference]
messages.append(
f"netlist mismatch: {len(reference)} reference nets vs "
f"{len(extracted)} extracted"
)
for net in only_ref[:8]:
messages.append(f" only in Boxsim reference: {net}")
for net in only_kicad[:8]:
messages.append(f" only in KiCad netlist: {net}")
return GateResult(
erc_ok=erc_ok,
erc_violations=violations,
netlist_matches=matches,
kicad_version=version,
reference_nets=reference,
kicad_nets=extracted,
messages=messages,
)