Skip to content

Commit e5016c4

Browse files
Docs: comprehensive validation & results (#108) (#130)
* Docs: comprehensive validation evidence (#108) Restructure guides/validation.md into a full evidence page: at-a-glance summary table, source-density bit-exactness table, cross-platform device/precision invariance (phase3 equivalence matrix + IC topomaps), EEGLAB drop-in round-trip, and an other-validated-behaviors table. Collapse the 40-run per-run table for scannability. Amari-distance figures cross-checked against amari_summary.json. Builds under mkdocs build --strict. * Docs: address PR review findings on validation page Scope source-density bit-exactness to the non-GG families (the default GG has no literal Fortran oracle; validated by single-model parity), fix two test-path citations to include the pyAMICA/ prefix, reconcile the two k=30/70ch rows (different backend sets), define IC/LL before first use, expand ZCA, and drop the unused EM abbreviation.
1 parent b14ad14 commit e5016c4

3 files changed

Lines changed: 189 additions & 51 deletions

File tree

200 KB
Loading
2.53 MB
Loading

docs/guides/validation.md

Lines changed: 189 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
**Correctness in pyAMICA is defined as parity with the reference Fortran binary,
44
not merely as convergence.** A run is correct when it reproduces the Fortran output within numerical tolerance.
5+
This page collects the full verification evidence: bit-exact score functions, single-model parity,
6+
multi-model distributional equivalence, cross-platform device and precision invariance,
7+
the EEGLAB drop-in round-trip, and the remaining validated behaviors.
8+
Every result uses the bundled real sample EEG and the reference Fortran binary; none uses synthetic data.
9+
Throughout, IC abbreviates independent component and LL log-likelihood.
10+
11+
## Validation at a glance
12+
13+
| What was checked | How | Result |
14+
|---|---|---|
15+
| Source-density score and log-density (non-GG families) | vs the literal `amica15.f90` expressions | bit-exact ($<10^{-12}$) |
16+
| Per-block sufficient statistics and one M-step | vs Fortran | bit-exact ($\sim\!10^{-15}$) |
17+
| Single-model solution | log-likelihood, component correlation, Amari distance vs Fortran | LL within ~0.005 of $-3.4018$; correlation 0.997; Amari 0.006 |
18+
| Multi-model solution | distributional equivalence over 20-run ensembles | indistinguishable from Fortran's own run-to-run spread ($p = 0.96$) |
19+
| Device and precision invariance | same independent components across CPU/CUDA/MPS/MLX, float32/float64, Linux/macOS | identical (1.000) across all eight torch/MLX combinations |
20+
| Cross-backend log-likelihood | converged LL across every backend | agree to ~3 significant digits (max pairwise ~0.003) |
21+
| EEGLAB output | `write_amica_output` round-trip through `loadmodout15` | single-model bytes are an exact serialization; loads with correct layout |
22+
| Degenerate fits | NaN or singular log-likelihood | refused, never returned as NaN sources |
523

624
## The validation harness
725

@@ -23,6 +41,31 @@ On real sample EEG the natural-gradient backend reaches Fortran's solution:
2341
The fixed source-density families are bit-exact against the literal Fortran score/derivative expressions (~1e-15),
2442
and the backend converges to the binary's solution within ~0.005 log-likelihood.
2543

44+
### Source-density families are bit-exact
45+
46+
AMICA models each source with one of the reference's five `pdftype` density families.
47+
For every family other than the default generalized Gaussian, the vectorized log-density and score reproduce the literal `amica15.f90` expressions
48+
to float64 precision (test bound $<10^{-12}$, observed $\sim\!10^{-15}$):
49+
the source model is not an approximation of the Fortran one, it is the same function.
50+
The generalized Gaussian has no closed-form literal to compare against, since its score depends on the adaptive shape $\rho$,
51+
so the default family is validated by the single-model parity above instead.
52+
The oracle column below records which check applies to each family:
53+
54+
| `pdftype` | Density family | Score $f_p(y)$ | Bit-exact oracle |
55+
|---|---|---|---|
56+
| 0 (default) | Generalized Gaussian (adaptive shape $\rho$) | GG score (shape-dependent) | single-model parity above |
57+
| 1 | Extended-Infomax adaptive switch (super- ↔ sub-Gaussian by kurtosis sign) | $y + \tanh y$ / $y - \tanh y$ | real-data LL (see below) |
58+
| 2 | Gaussian | $y$ | yes |
59+
| 3 | Logistic | $\tanh(y/2)$ | yes |
60+
| 4 | Sub-Gaussian (cosh$^{+}$) | $y - \tanh y$ | yes |
61+
62+
`pdftype=0` is the default and is byte-for-byte the pre-family implementation.
63+
The `pdftype=1` extended-Infomax switcher flips each source between the super-Gaussian (code 1) and
64+
sub-Gaussian (code 4) densities on a kurtosis schedule; its dynamic switch has no bit-exact oracle
65+
(the reference's `do_choose_pdfs` accumulator is dead code in the binary), so it is validated by real-data
66+
log-likelihood instead. Each fixed family converges within ~0.005 LL of the binary at a matched Newton budget.
67+
See `pyAMICA/tests/torch_tests/test_ng_pdf_families.py` and ADR 0002.
68+
2669
## Multi-model equivalence
2770

2871
Multi-model AMICA is not partition-identifiable, so exact partition parity with Fortran is the wrong acceptance bar.
@@ -71,54 +114,81 @@ This pairing correction is not free: on this ensemble it lowers the reported dis
71114

72115
The same run-level permutation test (20000 permutations, intact 40-run units) finds no evidence that between-implementation agreement is worse than Fortran's own run-to-run agreement ($p > 0.999$), agreeing with the correlation-based conclusion above.
73116

74-
#### Per-run detail
117+
??? note "Per-run detail (all 40 runs, both metrics)"
118+
119+
Table 1 in the paper and the group summaries above report distribution means;
120+
the table below gives each of the 40 runs' own mean agreement to its own group's other 19 runs (`within`) and to all 20 opposite-implementation runs (`between`), for both metrics.
121+
Regenerate with `uv run python .context/issue-27/amari_distance.py`, which writes `.context/issue-27/per_run_detail.csv`.
122+
123+
| implementation | run | corr within | corr between | Amari within | Amari between |
124+
|---|---:|---:|---:|---:|---:|
125+
| Fortran | 0 | 0.6164 | 0.6274 | 0.1880 | 0.1745 |
126+
| Fortran | 1 | 0.6292 | 0.6568 | 0.1784 | 0.1592 |
127+
| Fortran | 2 | 0.6465 | 0.6396 | 0.1694 | 0.1654 |
128+
| Fortran | 3 | 0.6593 | 0.6740 | 0.1627 | 0.1508 |
129+
| Fortran | 4 | 0.6323 | 0.6522 | 0.1797 | 0.1629 |
130+
| Fortran | 5 | 0.6692 | 0.6773 | 0.1590 | 0.1488 |
131+
| Fortran | 6 | 0.6660 | 0.6808 | 0.1628 | 0.1502 |
132+
| Fortran | 7 | 0.6341 | 0.6400 | 0.1786 | 0.1678 |
133+
| Fortran | 8 | 0.6285 | 0.6491 | 0.1779 | 0.1663 |
134+
| Fortran | 9 | 0.6212 | 0.6341 | 0.1835 | 0.1702 |
135+
| Fortran | 10 | 0.6308 | 0.6414 | 0.1794 | 0.1663 |
136+
| Fortran | 11 | 0.6297 | 0.6444 | 0.1736 | 0.1614 |
137+
| Fortran | 12 | 0.6334 | 0.6416 | 0.1782 | 0.1690 |
138+
| Fortran | 13 | 0.6513 | 0.6630 | 0.1693 | 0.1574 |
139+
| Fortran | 14 | 0.6666 | 0.6689 | 0.1584 | 0.1540 |
140+
| Fortran | 15 | 0.6231 | 0.6254 | 0.1827 | 0.1750 |
141+
| Fortran | 16 | 0.6147 | 0.6258 | 0.1903 | 0.1755 |
142+
| Fortran | 17 | 0.6280 | 0.6483 | 0.1749 | 0.1632 |
143+
| Fortran | 18 | 0.6441 | 0.6371 | 0.1691 | 0.1665 |
144+
| Fortran | 19 | 0.6389 | 0.6505 | 0.1733 | 0.1637 |
145+
| pyAMICA | 0 | 0.6317 | 0.6149 | 0.1690 | 0.1806 |
146+
| pyAMICA | 1 | 0.6935 | 0.6777 | 0.1440 | 0.1529 |
147+
| pyAMICA | 2 | 0.6624 | 0.6583 | 0.1545 | 0.1606 |
148+
| pyAMICA | 3 | 0.6406 | 0.6459 | 0.1527 | 0.1561 |
149+
| pyAMICA | 4 | 0.6493 | 0.6384 | 0.1506 | 0.1569 |
150+
| pyAMICA | 5 | 0.6755 | 0.6591 | 0.1548 | 0.1622 |
151+
| pyAMICA | 6 | 0.6339 | 0.6236 | 0.1677 | 0.1807 |
152+
| pyAMICA | 7 | 0.6809 | 0.6788 | 0.1417 | 0.1479 |
153+
| pyAMICA | 8 | 0.6780 | 0.6552 | 0.1508 | 0.1631 |
154+
| pyAMICA | 9 | 0.6270 | 0.6206 | 0.1703 | 0.1823 |
155+
| pyAMICA | 10 | 0.6855 | 0.6665 | 0.1474 | 0.1612 |
156+
| pyAMICA | 11 | 0.6739 | 0.6556 | 0.1506 | 0.1612 |
157+
| pyAMICA | 12 | 0.6321 | 0.6190 | 0.1617 | 0.1753 |
158+
| pyAMICA | 13 | 0.6639 | 0.6610 | 0.1519 | 0.1597 |
159+
| pyAMICA | 14 | 0.6866 | 0.6849 | 0.1460 | 0.1520 |
160+
| pyAMICA | 15 | 0.6944 | 0.6672 | 0.1417 | 0.1559 |
161+
| pyAMICA | 16 | 0.6576 | 0.6533 | 0.1551 | 0.1633 |
162+
| pyAMICA | 17 | 0.6435 | 0.6249 | 0.1615 | 0.1756 |
163+
| pyAMICA | 18 | 0.6753 | 0.6547 | 0.1442 | 0.1549 |
164+
| pyAMICA | 19 | 0.6302 | 0.6180 | 0.1562 | 0.1655 |
165+
166+
## Cross-platform device and precision invariance
167+
168+
The strongest reassurance that pyAMICA is a single, well-defined implementation is that it recovers the *same*
169+
independent components no matter where or how it runs. Fitting the same real EEG (ds002718 sub-002, 147,000 frames, 70 channels, 2000 iterations)
170+
on every backend and Hungarian-matching the unmixing components across them:
171+
172+
![Cross-backend IC-equivalence matrix at 70 channels.](../assets/figures/cross-backend-equivalence-matrix.png){ width=680 }
173+
/// caption
174+
Mean Hungarian-matched \|correlation\| of the recovered components between every pair of backends.
175+
The eight torch/MLX combinations, CPU, CUDA, MPS, and MLX, at both float32 and float64, on macOS-arm64 and Linux-x86_64, all agree at **1.000**.
176+
///
75177

76-
Table 1 in the paper and the group summaries above report distribution means;
77-
the table below gives each of the 40 runs' own mean agreement to its own group's other 19 runs (`within`) and to all 20 opposite-implementation runs (`between`), for both metrics.
78-
Regenerate with `uv run python .context/issue-27/amari_distance.py`, which writes `.context/issue-27/per_run_detail.csv`.
178+
**Every torch/MLX backend is identical to every other at 1.000**: the same decomposition on any device, at any
179+
precision, on either operating system. This is the definitive "float32 == float64" and "GPU == CPU" result.
180+
The two native-Fortran builds (macOS-arm64 and Linux-x86_64) agree with each other at 0.972 and with the
181+
torch/MLX cluster at ~0.90. That residual is not a backend defect: Fortran does not even reach 1.000 against
182+
*itself* across platforms, because each native run is seeded from the clock and settles into a different (equally valid)
183+
local optimum on the weakly-determined components only. As the decomposition becomes better-determined that gap closes
184+
(next section), and the component maps are visibly the same down every row:
79185

80-
| implementation | run | corr within | corr between | Amari within | Amari between |
81-
|---|---:|---:|---:|---:|---:|
82-
| Fortran | 0 | 0.6164 | 0.6274 | 0.1880 | 0.1745 |
83-
| Fortran | 1 | 0.6292 | 0.6568 | 0.1784 | 0.1592 |
84-
| Fortran | 2 | 0.6465 | 0.6396 | 0.1694 | 0.1654 |
85-
| Fortran | 3 | 0.6593 | 0.6740 | 0.1627 | 0.1508 |
86-
| Fortran | 4 | 0.6323 | 0.6522 | 0.1797 | 0.1629 |
87-
| Fortran | 5 | 0.6692 | 0.6773 | 0.1590 | 0.1488 |
88-
| Fortran | 6 | 0.6660 | 0.6808 | 0.1628 | 0.1502 |
89-
| Fortran | 7 | 0.6341 | 0.6400 | 0.1786 | 0.1678 |
90-
| Fortran | 8 | 0.6285 | 0.6491 | 0.1779 | 0.1663 |
91-
| Fortran | 9 | 0.6212 | 0.6341 | 0.1835 | 0.1702 |
92-
| Fortran | 10 | 0.6308 | 0.6414 | 0.1794 | 0.1663 |
93-
| Fortran | 11 | 0.6297 | 0.6444 | 0.1736 | 0.1614 |
94-
| Fortran | 12 | 0.6334 | 0.6416 | 0.1782 | 0.1690 |
95-
| Fortran | 13 | 0.6513 | 0.6630 | 0.1693 | 0.1574 |
96-
| Fortran | 14 | 0.6666 | 0.6689 | 0.1584 | 0.1540 |
97-
| Fortran | 15 | 0.6231 | 0.6254 | 0.1827 | 0.1750 |
98-
| Fortran | 16 | 0.6147 | 0.6258 | 0.1903 | 0.1755 |
99-
| Fortran | 17 | 0.6280 | 0.6483 | 0.1749 | 0.1632 |
100-
| Fortran | 18 | 0.6441 | 0.6371 | 0.1691 | 0.1665 |
101-
| Fortran | 19 | 0.6389 | 0.6505 | 0.1733 | 0.1637 |
102-
| pyAMICA | 0 | 0.6317 | 0.6149 | 0.1690 | 0.1806 |
103-
| pyAMICA | 1 | 0.6935 | 0.6777 | 0.1440 | 0.1529 |
104-
| pyAMICA | 2 | 0.6624 | 0.6583 | 0.1545 | 0.1606 |
105-
| pyAMICA | 3 | 0.6406 | 0.6459 | 0.1527 | 0.1561 |
106-
| pyAMICA | 4 | 0.6493 | 0.6384 | 0.1506 | 0.1569 |
107-
| pyAMICA | 5 | 0.6755 | 0.6591 | 0.1548 | 0.1622 |
108-
| pyAMICA | 6 | 0.6339 | 0.6236 | 0.1677 | 0.1807 |
109-
| pyAMICA | 7 | 0.6809 | 0.6788 | 0.1417 | 0.1479 |
110-
| pyAMICA | 8 | 0.6780 | 0.6552 | 0.1508 | 0.1631 |
111-
| pyAMICA | 9 | 0.6270 | 0.6206 | 0.1703 | 0.1823 |
112-
| pyAMICA | 10 | 0.6855 | 0.6665 | 0.1474 | 0.1612 |
113-
| pyAMICA | 11 | 0.6739 | 0.6556 | 0.1506 | 0.1612 |
114-
| pyAMICA | 12 | 0.6321 | 0.6190 | 0.1617 | 0.1753 |
115-
| pyAMICA | 13 | 0.6639 | 0.6610 | 0.1519 | 0.1597 |
116-
| pyAMICA | 14 | 0.6866 | 0.6849 | 0.1460 | 0.1520 |
117-
| pyAMICA | 15 | 0.6944 | 0.6672 | 0.1417 | 0.1559 |
118-
| pyAMICA | 16 | 0.6576 | 0.6533 | 0.1551 | 0.1633 |
119-
| pyAMICA | 17 | 0.6435 | 0.6249 | 0.1615 | 0.1756 |
120-
| pyAMICA | 18 | 0.6753 | 0.6547 | 0.1442 | 0.1549 |
121-
| pyAMICA | 19 | 0.6302 | 0.6180 | 0.1562 | 0.1655 |
186+
![Variance-ordered IC scalp maps recovered by each backend.](../assets/figures/cross-backend-ic-topomaps.png){ width=600 }
187+
/// caption
188+
IC scalp maps at 70 channels, variance-ordered (IC1 = highest back-projected variance, EEGLAB convention),
189+
Hungarian-matched and sign-aligned across backends (rows). Each map is the de-sphered sensor-space projection.
190+
The well-determined components are indistinguishable across all backends.
191+
///
122192

123193
## Data adequacy and cross-backend equivalence
124194

@@ -129,16 +199,30 @@ $$k = \frac{\text{frames}}{\text{channels}^2}$$
129199
As `k` grows, cross-backend component equivalence rises toward 1.0;
130200
at the rule-of-thumb minimum (`k` around 20-30) only the strongest components are backend-reproducible,
131201
while the rest are under-determined and settle into different but equally valid local optima (AMICA is non-convex).
202+
This is why the native-Fortran rows above sit at ~0.90 (70 channels, `k` = 30) rather than 1.0. Two independent sweeps confirm it.
203+
204+
### Sweeping channels at fixed frames
205+
206+
Holding frames at 147,000 and increasing the channel count lowers `k`.
207+
Comparing MLX-float32 against the independent native-Fortran-float64 build (the hardest cross-implementation pair):
208+
209+
| channels | k = frames/ch² | mean matched \|corr\| | components > 0.95 |
210+
|---:|---:|---:|---:|
211+
| 16 | 574 | **0.997** | 16/16 |
212+
| 32 | 144 | 0.974 | 27/32 |
213+
| 48 | 64 | 0.954 | 34/48 |
214+
| 70 | 30 | 0.898 | 20/70 |
215+
216+
At high `k` **every backend, including the independent Fortran build, recovers identical components** (0.997, all 16/16 at `k` = 574).
217+
At `k` = 30, the rule-of-thumb minimum, only the strongest ~20/70 components are reproducible; the rest are under-determined.
132218

133-
### Data-size sweep: equivalence versus k
219+
### Sweeping frames at fixed channels
134220

135-
Holding channels fixed at 70 and increasing the number of frames (so `k` rises),
136-
on real EEG (ds002718 sub-002),
137-
cross-backend IC equivalence climbs sharply and then saturates once the decomposition is well-determined:
221+
Holding channels at 70 and increasing frames raises `k`, on the same real EEG:
138222

139223
![Cross-backend IC equivalence versus the data-adequacy factor k at 70 channels.](../assets/figures/kfactor-equivalence.png){ width=640 }
140224
/// caption
141-
Mean Hungarian-matched cross-backend |correlation| versus $k = \text{frames} / \text{channels}^2$ (70 channels, 2000 iterations,
225+
Mean Hungarian-matched cross-backend \|correlation\| versus $k = \text{frames} / \text{channels}^2$ (70 channels, 2000 iterations,
142226
native-Fortran and PyTorch-CUDA float64/float32 backends).
143227
Equivalence saturates at ~0.98 once $k \geq 60$.
144228
///
@@ -151,6 +235,9 @@ Equivalence saturates at ~0.98 once $k \geq 60$.
151235
| 490,000 | 100 | 0.983 | 94.8% |
152236
| 747,750 | 152 | 0.982 | 92.4% |
153237

238+
The `k` = 30 row here (0.929) sits above the 0.898 at `k` = 30 in the channel-sweep table because the two average different backend sets:
239+
the channel sweep reports only the hardest MLX-versus-native-Fortran pair, while this frame sweep averages over the native-Fortran and PyTorch-CUDA float64/float32 cluster.
240+
154241
!!! note "The threshold is data-specific"
155242
For this recording the equivalence knee falls **between k=30 and k=60**; below
156243
it the backends settle into different (equally valid) local optima, above it
@@ -172,6 +259,27 @@ The two double-precision implementations, an independent native Fortran binary a
172259

173260
This is cross-*implementation* agreement, not just cross-device. The residual gap is dominated by the float32 path (rounding accumulated over 2000 iterations, plus an early stop when the natural-gradient learning rate hit its floor), which is a convergence/precision effect rather than a backend defect.
174261

262+
## EEGLAB drop-in round-trip
263+
264+
pyAMICA writes the same on-disk format EEGLAB's AMICA plugin reads, so a fit is a drop-in replacement:
265+
no re-sorting, sign-flipping, or reformatting. After a fit, `write_amica_output(dir)` writes the raw binary
266+
files (`gm`, `W`, `S`, `mean`, `c`, `alpha`, `mu`, `sbeta`, `rho`, `comp_list`, `LL`) that EEGLAB's
267+
`loadmodout15.m` loads.
268+
269+
The round-trip is verified two ways:
270+
271+
- **Byte-level:** for a single model the written files are an exact float64 serialization of the fitted
272+
parameters. `W` and the symmetric zero-phase component analysis (ZCA) sphere are byte-identical in C order; the non-square mixture
273+
parameters and `c`/`comp_list` are column-major (Fortran layout), matching the reference `amicaout` files.
274+
- **Reader-level:** the directory loads through `loadmodout15.m` (and its NumPy port `loadmodout`) with the
275+
expected shapes and the correct column-major layout. The MATLAB round-trip during development is what caught,
276+
and fixed, a column-major format bug in the mixture-parameter arrays.
277+
278+
`variance_order()` reproduces EEGLAB's IC ordering (IC1 = highest back-projected variance) in Python without a
279+
disk round-trip. For `n_models > 1` the layout is self-consistent and round-trips through both readers, but is
280+
not byte-identical to a native multi-model run (see the multi-model discussion above).
281+
Full usage is in the [EEGLAB interoperability guide](eeglab.md); tests are in `pyAMICA/tests/torch_tests/test_amica_ng_wrapper.py`.
282+
175283
## Performance across backends
176284

177285
Throughput on real EEG (OpenNeuro ds002718 sub-002; `n_mix=3`, `pdftype=0`, `block_size=512`, warmed, min-of-repeats).
@@ -247,3 +355,33 @@ across device and precision, confirming the whole backend family end-to-end:
247355
| 48 | -3.20951 | -3.20952 | -3.20953 | -3.20951 | -3.21019 |
248356
| 70 | -3.21579 | -3.21562 | -3.21560 | -3.21570 | -3.21315 |
249357

358+
## Other validated behaviors
359+
360+
Beyond the core parity results, the following AMICA features are implemented and validated. Where the reference
361+
binary contains a bit-exact oracle it is used; where the reference code path is unrunnable (declared but never
362+
allocated, so it cannot be exercised even in Fortran) the feature is behavior-validated on real EEG instead, and
363+
guarded to a no-op so the parity results above stay byte-for-byte unchanged.
364+
365+
| Behavior | Status | Validation |
366+
|---|---|---|
367+
| Best-iterate safeguard (`keep_best`, #51) | on by default | returns the highest-LL iterate; cuts multi-model LL sd from 12.7x to 2.0x Fortran's. Single-model parity stays bit-exact (monotone, no restore). ADR 0003 |
368+
| Per-model bias `c` update (#27) | on for `n_models>1` | Fortran `update_c`; per-block stats bit-exact; no-op for `n_models=1` |
369+
| Component sharing (`share_comps`, #60) | off by default | Fortran `identify_shared_comps` ported; no bit-exact oracle (`Spinv2` is unrunnable), behavior-validated; byte-identical when unshared |
370+
| Outlier rejection (`do_reject`, #123) | off by default | `good_idx` mechanism in both backends; NumPy port validated vs the PyTorch backend |
371+
| Degenerate-fit contract (#50) | always | a NaN or singular fit is refused (`converged_` / `stop_reason_`); `transform`/`get_*`/`save`/`write_amica_output` raise instead of returning NaN sources |
372+
373+
Tests live under `pyAMICA/tests/`: `torch_tests/test_ng_backend.py`, `torch_tests/test_ng_sharing.py`, `torch_tests/test_amica_ng_wrapper.py`, and `test_numpy_reject.py`.
374+
375+
## Reproducing these results
376+
377+
Everything on this page runs from the bundled sample data and the reference binary, with no external download:
378+
379+
```bash
380+
uv run python validate_implementations.py # single- and multi-model parity report
381+
uv run pytest # the full parity/behavior test suite
382+
```
383+
384+
The multi-model ensemble and Amari detail regenerate from saved fits (no re-fitting) with
385+
`uv run python .context/issue-27/amari_distance.py`. The cross-platform benchmark and equivalence
386+
figures are produced by `benchmarks/benchmark_decompose.py` (and the sweep scripts alongside it);
387+
the underlying findings are in `.context/issue-84/` and `.context/issue-90/`.

0 commit comments

Comments
 (0)