Address all critical and important findings from safety review

C1: Port count mismatch now emits ERROR to stderr and marks
    unconnected ports as __UNCONNECTED_<name>__ (never silent)
C2: Single-module pin mapping built in lockstep — cable_wires
    always matches header_pins length, warns on unmappable nets
C3: VSS removed from is_power_net (it's ground per CMOS convention),
    dead _POWER_PATTERN regex replaced with _KNOWN_NET_PATTERN
C4: apply_filters dead computation removed, docstring clarifies
    that net-level filtering is a mapper concern

I3: WireViz render catches (ValueError, TypeError, OSError) with
    diagnostic context instead of bare Exception
I5: Invalid --format flags now warn and error instead of silently
    falling through to defaults
I6: Model/value heuristic warns on stderr when it triggers, since
    signal names like ALERT or DATA could be misidentified

New tests: VSS classification, port count mismatch (C1), model/value
heuristic warning (I6), duplicate refs (S3), empty subcircuit (S2),
full pipeline determinism across 3 runs (S1)

115 tests pass, ruff clean
This commit is contained in:
Ryan Malloy 2026-02-13 01:34:30 -07:00
parent eb3ad60bd1
commit b9154e851b
7 changed files with 254 additions and 46 deletions

View file

@ -288,11 +288,31 @@ def _render_wireviz(
sys.exit(1)
# Map format flags to WireViz output_formats
format_map = {"h": "html", "p": "png", "s": "svg", "g": "gv", "c": "csv", "t": "tsv"}
formats = tuple(format_map[f] for f in format_flags.lower() if f in format_map)
format_map = {
"h": "html", "p": "png", "s": "svg",
"g": "gv", "c": "csv", "t": "tsv",
}
# Warn about unrecognized format characters
invalid_chars = [f for f in format_flags.lower() if f not in format_map]
if invalid_chars:
click.echo(
f"Warning: unrecognized format flags: {''.join(invalid_chars)} "
f"(valid: {''.join(format_map.keys())})",
err=True,
)
formats = tuple(
format_map[f] for f in format_flags.lower() if f in format_map
)
if not formats:
formats = ("html", "png", "svg")
click.echo(
"Error: no valid format flags provided. "
f"Valid flags: {''.join(format_map.keys())}",
err=True,
)
sys.exit(1)
output_dir = output.parent if output else Path.cwd()
output_name = output.stem if output else "spice2wireviz_output"
@ -308,6 +328,12 @@ def _render_wireviz(
f"Rendered: {', '.join(f'{output_name}.{fmt}' for fmt in formats)}",
err=True,
)
except Exception as exc:
click.echo(f"WireViz render error: {exc}", err=True)
except (ValueError, TypeError, OSError) as exc:
click.echo(
f"WireViz render error ({type(exc).__name__}): {exc}\n"
f" Output dir: {output_dir}\n"
f" Output name: {output_name}\n"
f" Formats: {formats}",
err=True,
)
sys.exit(1)

View file

@ -124,7 +124,12 @@ def filter_net(net: str, config: FilterConfig, netlist: ParsedNetlist) -> bool:
def apply_filters(netlist: ParsedNetlist, config: FilterConfig) -> ParsedNetlist:
"""Return a new ParsedNetlist with filters applied.
"""Return a new ParsedNetlist with component/instance-level filters applied.
Filters components by prefix/ref and instances by prefix/ref/subcircuit.
Net-level filtering (ground, power, glob patterns) is handled by the
mappers during connection tracing, not here because net filtering
affects pin lists and cable generation which are mapper concerns.
Produces warnings on stderr for significant exclusions.
"""
@ -138,17 +143,13 @@ def apply_filters(netlist: ParsedNetlist, config: FilterConfig) -> ParsedNetlist
inst for inst in netlist.instances if filter_instance(inst, config, netlist)
]
# Filter nets within surviving components/instances
excluded_net_count = 0
for comp in filtered_components:
original_count = len(comp.nodes)
comp = comp.model_copy()
new_nodes = [n for n in comp.nodes if filter_net(n, config, netlist)]
excluded_net_count += original_count - len(new_nodes)
excluded_comp_count = len(netlist.top_level_components) - len(filtered_components)
excluded_inst_count = len(netlist.instances) - len(filtered_instances)
if excluded_net_count > 0:
if excluded_comp_count > 0 or excluded_inst_count > 0:
print(
f"Note: {excluded_net_count} net connections hidden by filters",
f"Note: filters excluded {excluded_comp_count} components "
f"and {excluded_inst_count} instances",
file=sys.stderr,
)

View file

@ -12,6 +12,7 @@ This shows the external interface of a single board/module.
from __future__ import annotations
import sys
from typing import Any
from ..emitter.yaml_emitter import (
@ -103,31 +104,43 @@ def map_single_module(
shared_nets = _find_shared_nets(subckt, comp, config, netlist)
if shared_nets and header_name in connectors:
cable_name = f"W_{comp_name}"
wire_colors = [_net_wire_color(net, netlist) for net in shared_nets]
wire_labels = list(shared_nets)
# Build pin mappings in lockstep — every entry must resolve
mapped_header: list[int] = []
mapped_comp: list[int] = []
mapped_nets: list[str] = []
cables[cable_name] = build_cable(
cable_name,
wirecount=len(shared_nets),
colors=wire_colors if any(wire_colors) else None,
wirelabels=wire_labels,
category="bundle",
notes=f"Nets: {', '.join(shared_nets)}",
)
for net in shared_nets:
if net in port_labels and net in comp.nodes:
mapped_header.append(port_labels.index(net) + 1)
mapped_comp.append(comp.nodes.index(net) + 1)
mapped_nets.append(net)
else:
print(
f"Warning: net '{net}' shared between "
f"{header_name} and {comp_name} but not "
f"resolvable in both pin lists",
file=sys.stderr,
)
# Map pin indices for connection
header_pins = [port_labels.index(net) + 1 for net in shared_nets if net in port_labels]
comp_pins = [
comp.nodes.index(net) + 1 for net in shared_nets if net in comp.nodes
]
cable_wires = list(range(1, len(shared_nets) + 1))
if mapped_nets:
cable_name = f"W_{comp_name}"
wire_colors = [
_net_wire_color(net, netlist) for net in mapped_nets
]
cables[cable_name] = build_cable(
cable_name,
wirecount=len(mapped_nets),
colors=wire_colors if any(wire_colors) else None,
wirelabels=mapped_nets,
category="bundle",
notes=f"Nets: {', '.join(mapped_nets)}",
)
if header_pins and comp_pins and len(header_pins) == len(comp_pins):
cable_wires = list(range(1, len(mapped_nets) + 1))
connections.append(build_connection(
header_name, header_pins,
header_name, mapped_header,
cable_name, cable_wires,
comp_name, comp_pins,
comp_name, mapped_comp,
))
return assemble_wireviz_doc(connectors, cables, connections, metadata)

View file

@ -96,8 +96,10 @@ class ParsedNetlist(BaseModel):
def is_power_net(self, net: str) -> bool:
upper = net.upper()
return upper in {"VCC", "VDD", "V+", "V-", "VEE", "VSS", "AVCC", "AVDD", "DVCC", "DVDD"}
# VSS is ground in CMOS conventions, not power — see is_ground_net
return upper in {"VCC", "VDD", "V+", "V-", "VEE", "AVCC", "AVDD", "DVCC", "DVDD"}
def is_ground_net(self, net: str) -> bool:
upper = net.upper()
# VSS is ground (CMOS convention: VSS = negative supply = ground)
return upper in {"GND", "AGND", "DGND", "GND!", "EARTH", "VSS"} or net == "0"

View file

@ -36,13 +36,17 @@ BOUNDARY_PREFIXES = {"J", "TP", "P"}
# Prefixes we recognize as external-facing components
RECOGNIZED_PREFIXES = {"J", "TP", "P", "X"}
# Known power net patterns
_POWER_PATTERN = re.compile(
r"^(V(CC|DD|EE|SS|[+-])|A?V(CC|DD)|D?V(CC|DD))$", re.IGNORECASE
)
# Known ground net patterns (used in value/model heuristic)
_GROUND_PATTERN = re.compile(r"^(GND!?|AGND|DGND|EARTH|0)$", re.IGNORECASE)
# Known ground net patterns
_GROUND_PATTERN = re.compile(r"^(GND!?|AGND|DGND|EARTH|VSS|0)$", re.IGNORECASE)
# Known power net patterns (used in value/model heuristic)
_POWER_NET_NAMES = {"VCC", "VDD", "V+", "V-", "VEE", "AVCC", "AVDD", "DVCC", "DVDD"}
# Combined: nets the heuristic should never misidentify as model/value names
_KNOWN_NET_PATTERN = re.compile(
r"^(V(CC|DD|EE|[+-])|AV(CC|DD)|DV(CC|DD)|GND!?|AGND|DGND|EARTH|VSS|0)$",
re.IGNORECASE,
)
def _strip_comment(line: str) -> str:
@ -289,9 +293,30 @@ def _parse_x_instance(
port_to_net: dict[str, str] = {}
subckt_def = known_subcircuits.get(subckt_name)
if subckt_def:
expected = len(subckt_def.port_names)
actual = len(nodes)
if expected != actual:
print(
f"ERROR: port count mismatch for {ref}: "
f"subcircuit '{subckt_name}' defines {expected} ports "
f"({', '.join(subckt_def.port_names)}) but instance "
f"provides {actual} nodes ({', '.join(nodes)})",
file=sys.stderr,
)
for i, port_name in enumerate(subckt_def.port_names):
if i < len(nodes):
port_to_net[port_name] = nodes[i]
else:
# Mark unconnected ports visibly
port_to_net[port_name] = f"__UNCONNECTED_{port_name}__"
# Warn about extra nodes beyond the definition
if len(nodes) > expected:
extras = nodes[expected:]
print(
f"Warning: {ref} has {len(extras)} extra nodes "
f"beyond subcircuit definition: {', '.join(extras)}",
file=sys.stderr,
)
else:
# No definition available; use positional indices as port names
for i, node in enumerate(nodes):
@ -326,18 +351,27 @@ def _parse_boundary_component(tokens: list[str], prefix: str) -> SpiceComponent
positional.append(token)
# Heuristic: if last positional looks like a model name (contains only
# alphanumeric/underscore and doesn't match net patterns), treat it as value
# alphanumeric/underscore and doesn't match net patterns), treat it as value.
# WARNING: This can misidentify signal net names (e.g., ALERT, ENABLE, DATA)
# as model names. We warn when the heuristic triggers.
value = ""
nodes = positional
if len(positional) >= 2:
last = positional[-1]
# If it's not purely numeric and not a known net pattern, it's likely a value/model
if re.match(r"^[A-Za-z_]\w*$", last) and not _POWER_PATTERN.match(
last
) and not _GROUND_PATTERN.match(last):
if (
re.match(r"^[A-Za-z_]\w*$", last)
and not _KNOWN_NET_PATTERN.match(last)
):
value = last
nodes = positional[:-1]
print(
f"Note: treating '{last}' as model/value for {ref} "
f"(not as net name). If this is wrong, the net "
f"connection to '{last}' will be missing.",
file=sys.stderr,
)
pins = [
SpicePin(