Summary
Importing circulax monkeypatches sax.saxtypes.netlist.Netlist at import time with a type that omits the nets field. This is a global, process-wide side effect: after import circulax, SAX's own recursive-netlist coercion silently drops nets from every sub-netlist, which corrupts unrelated SAX simulations running in the same process.
Where
circulax/netlist.py:
circulaxNetlist = Annotated[
TypedDict(
"Netlist",
{
"instances": Instances,
"connections": NotRequired[Connections],
"ports": Ports,
"placements": NotRequired[Placements],
"settings": NotRequired[Settings],
# note: no "nets" field
},
),
bval(sax_netlist.val_netlist),
]
Netlist = circulaxNetlist
# Monkeypatch sax.Netlist to be circulaxNetlist so that all functions using sax.Netlist
sax_netlist.Netlist = circulaxNetlist # <-- global side effect on sax
Because sax.saxtypes.netlist.Netlist is reassigned, sax.into[RecursiveNetlist] → val_recnet → try_into[Netlist] (which resolves Netlist from the patched module) coerces every sub-netlist against a type that has no nets, so nets is dropped. The top-level sax.Netlist alias is not repatched, so a flat into[Netlist] still keeps nets — that asymmetry makes it easy to miss.
Impact
gdsfactory's cell.get_netlist(recursive=True) expresses a composite cell's internal wiring as nets (a list of pairwise {p1, p2} links). Once circulax is imported anywhere in a worker process, a subsequent plain SAX simulation of a composite cell (e.g. ring_single) loses that internal wiring — the ring's loop instances become disconnected and get pruned by remove_unused_instances, so a resonant ring degrades to a flat/monotonic response.
In our app (GDSFactory+) SAX and circulax run in the same per-PDK worker and circulax is imported lazily, so the symptom is: run SAX (correct), run circulax once, switch back to SAX → wrong result for the rest of the worker's life.
Minimal reproduction
import sax
# build any recnet whose sub-netlist expresses connectivity via `nets`, e.g. a
# gdsfactory composite: recnet = {"top": {...}, "ring_single": {"instances": {...},
# "nets": [{"p1": "a,o1", "p2": "b,o1"}, ...],
# "ports": {...}}}
before = sax.into[sax.RecursiveNetlist]({"ring_single": sub}) # sub has 6 nets
# -> nets preserved
import circulax # import alone, no simulation
after = sax.into[sax.RecursiveNetlist]({"ring_single": sub}) # same sub dict
# -> nets == [] (silently dropped)
sax.into[sax.Netlist](sub) keeps the nets in both cases; only the recursive path drops them (it resolves Netlist from the patched module).
Suggested fix
Prefer not to mutate sax.saxtypes.netlist.Netlist globally. Options, roughly in order of preference:
- Don't reassign
sax_netlist.Netlist at all — keep circulaxNetlist internal to circulax and reference it explicitly where circulax needs the legacy SAX format.
- If the patch is needed, include the
nets field in circulaxNetlist so coercion is lossless for SAX consumers.
- At minimum, scope the change so it doesn't alter global SAX behavior for code that only uses SAX.
For context, nets and connections are equivalent in SAX (both pairwise 1:1 joins; _nets_to_connections raises on multiply-connected ports either way), so dropping nets loses connectivity that connections could have carried.
Environment
- circulax 0.2.1
- sax 0.18.2
Summary
Importing
circulaxmonkeypatchessax.saxtypes.netlist.Netlistat import time with a type that omits thenetsfield. This is a global, process-wide side effect: afterimport circulax, SAX's own recursive-netlist coercion silently dropsnetsfrom every sub-netlist, which corrupts unrelated SAX simulations running in the same process.Where
circulax/netlist.py:Because
sax.saxtypes.netlist.Netlistis reassigned,sax.into[RecursiveNetlist]→val_recnet→try_into[Netlist](which resolvesNetlistfrom the patched module) coerces every sub-netlist against a type that has nonets, sonetsis dropped. The top-levelsax.Netlistalias is not repatched, so a flatinto[Netlist]still keepsnets— that asymmetry makes it easy to miss.Impact
gdsfactory's
cell.get_netlist(recursive=True)expresses a composite cell's internal wiring asnets(a list of pairwise{p1, p2}links). Oncecirculaxis imported anywhere in a worker process, a subsequent plain SAX simulation of a composite cell (e.g.ring_single) loses that internal wiring — the ring's loop instances become disconnected and get pruned byremove_unused_instances, so a resonant ring degrades to a flat/monotonic response.In our app (GDSFactory+) SAX and circulax run in the same per-PDK worker and circulax is imported lazily, so the symptom is: run SAX (correct), run circulax once, switch back to SAX → wrong result for the rest of the worker's life.
Minimal reproduction
sax.into[sax.Netlist](sub)keeps the nets in both cases; only the recursive path drops them (it resolvesNetlistfrom the patched module).Suggested fix
Prefer not to mutate
sax.saxtypes.netlist.Netlistglobally. Options, roughly in order of preference:sax_netlist.Netlistat all — keepcirculaxNetlistinternal to circulax and reference it explicitly where circulax needs the legacy SAX format.netsfield incirculaxNetlistso coercion is lossless for SAX consumers.For context,
netsandconnectionsare equivalent in SAX (both pairwise 1:1 joins;_nets_to_connectionsraises on multiply-connected ports either way), so droppingnetsloses connectivity thatconnectionscould have carried.Environment