Skip to content

Commit af8d2c4

Browse files
committed
fix(klu): keep topology indices in numpy so nested sax.circuit traces cleanly
When a SAX model composes a sub-circuit via ``sax.circuit(...)`` inside its own body and the outer model is itself wrapped in another JAX trace (``jax.jit``, ``jax.jacfwd``, or any external simulator that traces SAX models for Jacobian assembly), ``analyze_circuit_klu`` raised ``NonConcreteBooleanIndexError`` at: CSi = jnp.broadcast_to(Ci[None, :], match_2d.shape)[match_2d] The boolean fancy-index requires ``match_2d`` to be concrete, but it became a tracer because ``Si``/``Sj`` came from ``sax.scoo(instance)`` which turned them into tracers under the outer trace. Index arrays Si / Sj / Ci / Cj are pure netlist topology (which (i, j) positions of each model's S-matrix are non-zero, plus the connection endpoints) — they never depend on traced parameters. Force them to numpy *inside the klu backend only* — ``_scoo_with_numpy_indices`` extracts topology directly from the SDict's keys before SCoo wrapping turns them into tracers, and the rest of ``analyze_circuit_klu`` then runs concretely. ``sax.scoo`` and the rest of SAX continue to return jnp indices, so other backends are unaffected. Repro:: def coupler_with_sbends(*, wl=1.55, length=10.0, kappa=0.5): sub, _ = sax.circuit( netlist={ "instances": {"s1": "st", "s2": "st", "dc": "cp"}, "connections": {"s1,o2": "dc,o1", "s2,o2": "dc,o2"}, "ports": {"o1": "s1,o1", "o2": "s2,o1", "o3": "dc,o3", "o4": "dc,o4"}, }, models={"st": straight, "cp": coupler_4port}, ) return sub(wl=wl, s1={"length": length / 2}, s2={"length": length / 2}, dc={"kappa": kappa}) jax.jit(coupler_with_sbends)(wl=1.55) # before: NonConcreteBooleanIndexError jax.jacfwd(lambda w: coupler_with_sbends(wl=w)[("o1","o3")].real)(1.55) # before: NonConcreteBooleanIndexError All 113 existing SAX tests pass.
1 parent b4a9eae commit af8d2c4

1 file changed

Lines changed: 54 additions & 13 deletions

File tree

src/sax/backends/klu.py

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import jax
88
import jax.numpy as jnp
99
import klujax
10+
import numpy as np
1011
from natsort import natsorted
1112

1213
import sax
@@ -55,13 +56,46 @@ def analyze_instances_klu(
5556
model_names = set()
5657
for i in instances.values():
5758
model_names.add(i["component"])
58-
dummy_models = {k: sax.scoo(models[k]()) for k in model_names}
59+
# Build the per-model SCoo with the topology indices ``Si``/``Sj``
60+
# cast to numpy. Indices are pure netlist topology (which (i, j)
61+
# entries of the model's S-matrix are non-zero) and never depend on
62+
# traced parameters, so concretizing them here avoids tracer-only
63+
# operations downstream — specifically, the boolean fancy indexing
64+
# in ``analyze_circuit_klu``. Only ``Sx`` (the actual S-values) stays
65+
# in jnp, where it must be traceable. This is a klu-backend-local
66+
# workaround — ``sax.scoo`` and the rest of SAX continue to return
67+
# jnp indices for backward compatibility with other backends.
68+
dummy_models = {k: _scoo_with_numpy_indices(models[k]()) for k in model_names}
5969
dummy_instances = {}
6070
for k, i in instances.items():
6171
dummy_instances[k] = dummy_models[i["component"]]
6272
return dummy_instances
6373

6474

75+
def _scoo_with_numpy_indices(s: sax.SType) -> sax.SCoo:
76+
"""Like ``sax.scoo`` but with topology indices forced to numpy.
77+
78+
For ``SDict`` inputs (the common case) the (Si, Sj) pair is built
79+
directly from the dict's keys, so the function is JAX-trace-safe even
80+
when the model body has been traced. For ``SCoo`` / ``SDense`` inputs
81+
the indices come from ``sax.scoo`` and are eagerly evaluated; if those
82+
were produced inside an outer trace this still won't help, but in
83+
practice models return ``SDict``.
84+
"""
85+
if isinstance(s, dict):
86+
all_ports: dict[str, None] = {}
87+
for p1, p2 in s:
88+
all_ports.setdefault(p1, None)
89+
all_ports.setdefault(p2, None)
90+
ports_map = {p: int(i) for i, p in enumerate(all_ports)}
91+
Si = np.array([ports_map[p] for _, p in s], dtype=np.int32)
92+
Sj = np.array([ports_map[p] for p, _ in s], dtype=np.int32)
93+
Sx = jnp.stack(jnp.broadcast_arrays(*s.values()), -1)
94+
return Si, Sj, Sx, ports_map
95+
si, sj, sx, ports_map = sax.scoo(s)
96+
return np.asarray(si, dtype=np.int32), np.asarray(sj, dtype=np.int32), sx, ports_map
97+
98+
6599
def analyze_circuit_klu(
66100
analyzed_instances: dict[sax.InstanceName, sax.SCoo],
67101
nets: sax.Nets,
@@ -110,8 +144,13 @@ def analyze_circuit_klu(
110144
n_col = idx
111145
n_rhs = len(port_map)
112146

113-
Si = jnp.concatenate(Si, -1)
114-
Sj = jnp.concatenate(Sj, -1)
147+
# Keep Si / Sj as numpy — they're pure topology (S-matrix nonzero
148+
# coordinates per instance) and feed into concrete-only operations
149+
# below (boolean fancy indexing, comparisons). Going through jnp here
150+
# would turn them into tracers when ``analyze_circuit_klu`` runs
151+
# inside an outer JAX trace.
152+
Si = np.concatenate(Si, -1)
153+
Sj = np.concatenate(Sj, -1)
115154

116155
pairs: set[tuple[int, int]] = set()
117156
for net in nets:
@@ -120,8 +159,8 @@ def analyze_circuit_klu(
120159
pairs.add((p1_idx, p2_idx))
121160
pairs.add((p2_idx, p1_idx))
122161
sorted_pairs = sorted(pairs)
123-
Ci = jnp.array([p[0] for p in sorted_pairs], dtype=jnp.int32)
124-
Cj = jnp.array([p[1] for p in sorted_pairs], dtype=jnp.int32)
162+
Ci = np.array([p[0] for p in sorted_pairs], dtype=np.int32)
163+
Cj = np.array([p[1] for p in sorted_pairs], dtype=np.int32)
125164

126165
Cextmap = {
127166
int(instance_ports[k]): int(port_map[v]) for k, v in inverse_ports.items()
@@ -130,15 +169,17 @@ def analyze_circuit_klu(
130169
Cextj = jnp.stack(list(Cextmap.values()), 0)
131170
Cext = jnp.zeros((n_col, n_rhs), dtype=complex).at[Cexti, Cextj].set(1.0)
132171

172+
# All in numpy — pure topology, no traced parameters touch this.
133173
match_2d = Cj[None, :] == Si[:, None] # (len_Si, len_Cj)
134-
CSi = jnp.broadcast_to(Ci[None, :], match_2d.shape)[match_2d]
135-
s_idx_grid = jnp.broadcast_to(jnp.arange(len(Si))[:, None], match_2d.shape)
136-
cs_s_indices = s_idx_grid[match_2d]
137-
CSj = Sj[cs_s_indices]
138-
139-
Ii = Ij = jnp.arange(n_col)
140-
I_CSi = jnp.asarray(jnp.concatenate([CSi, Ii], -1), dtype=jnp.int32)
141-
I_CSj = jnp.asarray(jnp.concatenate([CSj, Ij], -1), dtype=jnp.int32)
174+
CSi = np.broadcast_to(Ci[None, :], match_2d.shape)[match_2d]
175+
s_idx_grid = np.broadcast_to(np.arange(len(Si))[:, None], match_2d.shape)
176+
cs_s_indices_np = s_idx_grid[match_2d]
177+
CSj = Sj[cs_s_indices_np]
178+
179+
Ii = Ij = np.arange(n_col)
180+
I_CSi = jnp.asarray(np.concatenate([CSi, Ii], -1), dtype=jnp.int32)
181+
I_CSj = jnp.asarray(np.concatenate([CSj, Ij], -1), dtype=jnp.int32)
182+
cs_s_indices = jnp.asarray(cs_s_indices_np)
142183
symbolic = klujax.analyze(I_CSi, I_CSj, n_col)
143184

144185
return (

0 commit comments

Comments
 (0)