|
Download DEBUG.md from Celsia/torch-dimensions: direct link, hf CLI and curl.
- Browser
- Download file 41.6 kB
-
https://huggingface.co/Celsia/torch-dimensions/resolve/main/DEBUG.md
- Command line
-
hf download hf://Celsia/torch-dimensions/DEBUG.md
-
curl -L -o DEBUG.md https://huggingface.co/Celsia/torch-dimensions/resolve/main/DEBUG.md
41.6 kB
| # Debug log | |
| Every bug found in this library, what caused it, how it was found, and what now | |
| prevents it from coming back. | |
| Kept because the *patterns* are worth more than the individual fixes. Four of | |
| the first eight below are the same two mistakes wearing different clothes, and | |
| the techniques that caught them (Β§B) caught them in code that was already | |
| passing a green test suite β as did the later entries, found by re-running | |
| those same techniques against code the suite already blessed. | |
| The last four (#19β#22) came from a different direction and are worth reading | |
| together: two were found by *looking at what shipped* rather than at what the | |
| build said, and two were found by running this library's own conformance suite | |
| against this library's own documentation, where it failed twice. | |
| Every citation here is live. The pre-fix code for each fixed bug is one | |
| `git checkout` away, so each **Reproduce** block below lets you watch the guard | |
| fire β and each was *run before being written down*, which is how two citations | |
| that were not actually guards got caught (see #1 and #3). A "guarded by" line | |
| is itself a comment stating an invariant (Β§A1), so the citations get a test: | |
| [tests/test_debug_md.py](tests/test_debug_md.py) fails if any test name or | |
| commit hash cited here stops existing. | |
| **Scope note.** This file covers torch-dimensions only. Two further bugs were | |
| found in separate private research repositories during the same work; they are | |
| documented on their own fix branches and are deliberately not detailed here. | |
| Their generalizable lessons are folded into Β§A without identifying details. | |
| --- | |
| ## Summary | |
| | # | Where | Severity | Found by | Status | | |
| |---|---|---|---|---| | |
| | 1 | `plan.py` β `ScanPlan` hashable but mutable | high | mypy | fixed, `7c3f812` | | |
| | 2 | `lattice.py` β cached `flat_idx` with a mutable lattice | high | mypy | fixed, `7c3f812` | | |
| | 3 | `compose/kernel.py` β `clamp_min` on a signed denominator | high | targeted probe | fixed, `7c3f812` | | |
| | 4 | `plan.py` β bidirectional schedule aliasing | high | design review | never shipped, `0fba849` | | |
| | 5 | `testing.py` β `check_trainable` trained on a fixed batch | high | negative control | fixed before commit, `e5edbfb` | | |
| | 6 | `tests/test_kernel.py` β a test that could not fail | medium | audit | replaced, `7c3f812` | | |
| | 7 | CI β mypy was never executed | medium | audit | fixed, `7c3f812` | | |
| | 8 | invariant script β batch dims folded into the cell index | low | crash | fixed immediately | | |
| | 9 | `data/` β `Sample`/`Batch` unpicklable, `DataLoader(num_workers>0)` hangs | high | targeted probe | fixed | | |
| | 10 | `models/rnn.py` β `plan` silently overrode `n_layers` | medium | targeted probe | fixed | | |
| | 11 | `compose/kernel.py` β `nan_to_num` laundered input NaNs | medium | targeted probe | fixed | | |
| | 12 | `compose/kernel.py` β absolute epsilon vs relative cancellation | high | targeted probe | fixed | | |
| | 13 | `lattice.py` β `mask()` was a view; `valid` aliased the caller's tensor | high | targeted probe | fixed | | |
| | 14 | `data/collate.py` β targets dropped when the first sample lacked one | medium | targeted probe | fixed | | |
| | 15 | `data/window.py` β `split_at_time` on unsorted times: silent nonsense | medium | targeted probe | fixed | | |
| | 16 | `testing.py` β conformance checks ran at ranks the caller excluded | medium | audit | fixed | | |
| | 17 | `compose/scan.py` β `chunk=0` errored from deep inside `range()` | low | targeted probe | fixed | | |
| | 18 | `lattice.py` β device lattice could not index CPU tensors | medium | device probe (MPS) | fixed | | |
| | 19 | viewer bundle β a stale local training run shipped inside the wheel | medium | looking at the artifact | fixed | | |
| | 20 | `benchmarks/bench.py` β a memory column that measured the driver, not the model | medium | reading the output | fixed before publishing | | |
| | 21 | `examples/custom_method.py` β a strategy that indexed an empty axis list at rank 1 | low | conformance suite | fixed | | |
| | 22 | `examples/custom_method.py` β a schedule derived from *storage* order, not sweep order | high | conformance suite | fixed | | |
| | 23 | `testing.py` β gradcheck ran at a width the caller never asked for | low | new mixer | fixed | | |
| | 24 | `data/memmap.py` + `[safetensors]` β assumed numpy is always there | medium | CI (a leaner environment than the laptop) | fixed | | |
| | 25 | `examples/repro/*.py` β a dry-run timer that measured the dispatch queue | low | a run that took 40x its estimate | fixed | | |
| Severity is "what would this have cost if it reached a user", not "how hard was | |
| it to fix". Every one of #1β#5 is silent: no exception, no NaN, just wrong | |
| numbers or a model quietly weaker than requested. | |
| --- | |
| ## 1. `ScanPlan` was hashable but mutable | |
| ```python | |
| plan = td.ScanPlan.cyclic(("a", "b"), 4) | |
| store = {plan: "value"} | |
| plan.steps = (td.Step("z", True),) # succeeded | |
| store.get(plan) # None β lost from its own dict | |
| ``` | |
| `__hash__` was defined over `self.steps`, so mutating a plan changed its hash | |
| and dropped it out of any dict or set holding it. The worse consequence is | |
| downstream: `AxialScan` builds **one mixer per step** at construction, then | |
| `zip`s the plan against that list every forward pass. A plan edited afterwards | |
| would pair new steps with old mixers β no error, just a model that no longer | |
| matches its own description. | |
| The class already used `object.__setattr__` in `__init__`, an idiom that | |
| signals immutability, but nothing enforced it. | |
| **Cause.** Borrowed the frozen-dataclass idiom without the frozen-dataclass | |
| guarantee. | |
| **Fix.** `__setattr__` and `__delattr__` raise after construction. | |
| **Guarded by** `test_a_plan_cannot_be_mutated_after_construction`. | |
| `test_a_plan_survives_use_as_a_dict_key` documents the value-semantics contract | |
| but **passes even on the pre-fix code** β it never mutates anything β so it is | |
| not a guard for this bug. An earlier revision of this file cited it as one; | |
| running both tests against the pre-fix file is what corrected that. | |
| **Reproduce.** | |
| ```bash | |
| git checkout 7c3f812~1 -- src/torch_dimensions/plan.py | |
| pytest tests/test_plan.py -k cannot_be_mutated # 1 failed | |
| git checkout HEAD -- src/torch_dimensions/plan.py | |
| ``` | |
| --- | |
| ## 2. `Lattice` cached derived tensors but allowed mutation | |
| ```python | |
| lat = Lattice(shape=(2, 2), valid=...) # 3 cells present | |
| lat.valid = ... # now 1 cell present | |
| lat.n_valid # 1 β recomputed | |
| lat.flat_idx # [0, 1, 2] β cached, stale | |
| ``` | |
| `n_valid` recomputes on every access; `flat_idx` is memoized. After a mutation | |
| they disagree, and `scatter` indexes with `flat_idx` β so data lands in cells | |
| that no longer exist, silently. This is precisely the failure mode `Lattice` | |
| exists to make impossible, one level up. | |
| Blocks also `register_buffer` the cell mask at construction, so mutating a | |
| lattice desyncs an already-built module regardless of the cache. | |
| **Cause.** Same as #1: a value object that isn't a value. | |
| **Fix.** Immutable after `__post_init__`. `to()` already returned a new | |
| instance rather than mutating. | |
| **Guarded by** `test_a_lattice_cannot_be_mutated_after_construction`. | |
| **Reproduce.** | |
| ```bash | |
| git checkout 7c3f812~1 -- src/torch_dimensions/lattice.py | |
| pytest tests/test_lattice.py -k cannot_be_mutated # 1 failed | |
| git checkout HEAD -- src/torch_dimensions/lattice.py | |
| ``` | |
| --- | |
| ## 3. The sparse renormalizer divided by a clamped signed denominator | |
| ```python | |
| out = out / (kernel @ mass).clamp_min(1e-6) | |
| ``` | |
| `clamp_min` assumes the denominator is a non-negative *mass*. That holds for a | |
| softmax kernel. It does not hold for a signed one β and LeakyReLU-gated scores | |
| are the default in the reference implementation this follows. A signed kernel | |
| can cancel the denominator to **exactly zero** while the numerator stays | |
| nonzero: | |
| ``` | |
| denominator values : [0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] | |
| input max |x| : 1.85 | |
| output max |x| : 1201025.71 β 647,502x blow-up | |
| ``` | |
| The code comment asserted "lines with no present cells have a zero numerator, | |
| the clamp keeps them finite" β true only for non-negative kernels. The comment | |
| documented an assumption instead of checking it. | |
| **Cause.** A guard written against one failure mode (a genuinely empty line) | |
| that silently mis-handles a different one (cancellation). | |
| **Fix.** Guard the *magnitude* and leave degenerate lines unscaled. A genuinely | |
| dead line still has a zero numerator, so it stays zero. | |
| **Guarded by** `test_a_signed_kernel_does_not_explode_when_the_mass_cancels`. | |
| `test_a_genuinely_dead_line_is_still_zero_under_the_guard` **passes even on the | |
| pre-fix code** β a dead line's numerator is zero either way β so it pins the | |
| fix against overcorrection rather than catching the bug. Both roles are worth | |
| having; they are different roles, and this file originally conflated them. | |
| **Reproduce.** | |
| ```bash | |
| git checkout 7c3f812~1 -- src/torch_dimensions/compose/kernel.py | |
| pytest tests/test_kernel.py -k signed_kernel # 1 failed | |
| git checkout HEAD -- src/torch_dimensions/compose/kernel.py | |
| ``` | |
| --- | |
| ## 4. Bidirectional schedules aliased against the axis cycle | |
| Caught in design, never shipped β but it is the sharpest bug of the set and it | |
| exists in published research code, so it is recorded here in full. | |
| The obvious way to build a bidirectional sweep schedule is to cycle the axis | |
| each layer and flip the direction each layer: | |
| ```python | |
| axis = axes[i % n] # period n | |
| reverse = bool(i % 2) # period 2 | |
| ``` | |
| With an **even** number of axes the two periods phase-lock: | |
| | layer | axis | direction | | |
| |---|---|---| | |
| | 0 | h | forward | | |
| | 1 | w | **backward** | | |
| | 2 | h | forward | | |
| | 3 | w | **backward** | | |
| `h` is forward forever and `w` is backward forever, at *any* depth. Adding | |
| layers never fixes it. You asked for bidirectional and got a model with half | |
| the receptive field on every axis β and the loss still goes down. | |
| With **three** axes it accidentally works, because 3 and 2 are coprime. Three | |
| axes is also the case anyone would eyeball first, so the bug hides exactly | |
| where it would be looked for. | |
| **Fix.** Flip after each full *cycle*, giving period `2n`, which cannot share a | |
| factor with `n`. | |
| **Guarded by** `test_bidirectional_cyclic_gives_every_axis_both_directions`, | |
| which fails on exactly the even axis counts and passes on odd β the signature | |
| of the aliasing, and evidence the test is testing the right thing. | |
| **Reproduce.** The bug never shipped, so the reproduction is a mutation: in | |
| `ScanPlan.cyclic`, change the direction term `(i // n) % 2 == 1` to | |
| `i % 2 == 1`, then | |
| ```bash | |
| pytest tests/test_plan.py::test_bidirectional_cyclic_gives_every_axis_both_directions | |
| ``` | |
| fails at 2 and 4 axes and passes at 1 and 3 β the parity signature above, | |
| observed rather than asserted. | |
| --- | |
| ## 5. `check_trainable` proved nothing on its first version | |
| The first version trained on one fixed batch of eight examples. Result: | |
| ``` | |
| sweeps w loss 2.0812 -> 0.0003 (8073x) | |
| never sweeps w (control) loss 2.1109 -> 0.0000 (1950510207371x) | |
| ``` | |
| The **control model β which cannot see the axis the task is defined along β | |
| scored better than the real one.** It had memorized eight examples without | |
| performing any axial mixing at all. Every plan would have passed. | |
| **Fix.** Draw fresh data every step and score on a held-out batch. The same | |
| comparison then reads: | |
| ``` | |
| sweeps w held-out 0.157 | |
| never sweeps w (control) held-out 1.376 | |
| ``` | |
| **Cause.** A learning test without held-out data measures capacity, not | |
| learning. | |
| **Guarded by** `test_a_model_that_never_sweeps_the_needed_axis_cannot_learn_it`, | |
| which asserts the control *fails*. | |
| **Reproduce.** Not reproducible from history: the fixed-batch version was | |
| replaced before `e5edbfb` was committed. The numbers above are from the | |
| session in which it was caught. | |
| --- | |
| ## 6. A test that could not fail | |
| ```python | |
| plain = axial_contract(x, lat, 1, ones) # `ones` is unnormalized | |
| assert torch.allclose(plain[0, 1] / 3, torch.full((3, 1), 1 / 3)) | |
| ``` | |
| Named "structural zeros dilute the result", but an unnormalized all-ones kernel | |
| computes a *sum*, not a mean β there is no dilution to demonstrate. The final | |
| assertion divides a known value by 3 and asserts it equals that value over 3. | |
| **Fix.** Use a row-stochastic kernel, where the effect is real and visible: | |
| without renormalization a line with one present cell out of three averages to | |
| `1/3`; with it, to `1`. | |
| --- | |
| ## 7. mypy was in the plan but not in CI | |
| [PLAN.md](PLAN.md) specifies mypy as "advisory, not gating". It was read as | |
| optional and left out of the workflow entirely, so it had never run. First | |
| execution: **23 errors across 5 files**, two of which were bugs #1 and #2. | |
| **Fix.** Runs in CI with `continue-on-error: true`. Advisory means "does not | |
| block merge", not "never executed". | |
| --- | |
| ## 8. Batch dimensions folded into the cell index | |
| ```python | |
| absent = out.reshape(-1, H)[~valid] # IndexError: mask [12] vs tensor [72, 8] | |
| ``` | |
| `reshape(-1, H)` collapses batch and time into the cell axis, so a | |
| cell-indexed mask no longer lines up. Caught immediately by the crash. Recorded | |
| only because the correct form β index with the broadcast mask rather than | |
| flattening β is the same operation the library gets right internally, and it | |
| was still easy to get wrong when writing a one-off script. | |
| --- | |
| ## 9. `Sample`/`Batch` could not be pickled, so worker loading hung | |
| ```python | |
| class Sample(dict): | |
| __getattr__ = dict.__getitem__ # sample.x β neat, and broken | |
| ``` | |
| A missing attribute raised ``KeyError`` where Python promises | |
| ``AttributeError``. That breaks ``hasattr`` and ``getattr(s, "y", None)`` β and | |
| breaks **pickling**, because pickle probes for optional dunders like | |
| ``__getstate__`` with ``getattr`` and only tolerates ``AttributeError``. Every | |
| ``DataLoader(num_workers>0)`` pickles each sample through the worker queue, so | |
| multiprocessing loading did not fail cleanly: the worker died mid-pickle and | |
| the main process **hung forever waiting on the queue**. | |
| **Cause.** A shortcut that changes an exception type across a protocol | |
| boundary. The one-liner reads as equivalent to a real ``__getattr__`` and is | |
| not. | |
| **Fix.** A ``__getattr__`` that translates ``KeyError`` to ``AttributeError``. | |
| **Guarded by** `test_samples_and_batches_survive_pickling`, | |
| `test_a_missing_field_reads_as_absent_not_as_a_keyerror`, and the end-to-end | |
| `test_dataloader_with_worker_processes`. Note the first is the canonical guard: | |
| on the broken code the worker test *hangs* rather than fails, which is exactly | |
| why a fast direct test must sit in front of a slow end-to-end one. | |
| --- | |
| ## 10. A `plan` silently overrode `n_layers` | |
| `td.LSTM(8, n_layers=6, lattice=lat, plan=two_step_plan)` built a 2-layer | |
| model. No error, no warning β a model quietly shallower than requested, the | |
| same silent-downgrade class as #4. | |
| **Fix.** The plan still wins, but the downgrade warns. A warning rather than an | |
| error because the first attempt at a hard error immediately broke this suite's | |
| own generic factories β builders that fill ``n_layers`` unconditionally and add | |
| a plan only sometimes are legitimate, and the default ``n_layers=1`` passes | |
| untouched either way. | |
| **Guarded by** `test_rnn_warns_when_a_plan_disagrees_with_n_layers`. | |
| --- | |
| ## 11. `nan_to_num` laundered upstream NaNs into finite output | |
| After #3's magnitude guard, the division in the sparse renormalizer cannot | |
| create a fresh NaN β so the ``nan_to_num`` wrapped around it could only ever | |
| fire on NaNs already present in the *input*, zeroing them. A diverging model's | |
| NaNs vanished mid-network into plausible finite numbers. | |
| **Cause.** A guard kept after the failure it guarded against was fixed | |
| properly. Same shape as #3: ask what a ``nan_to_num`` is for, and if the | |
| answer is "nothing anymore", it is hiding something else. | |
| **Fix.** Removed. A NaN that arrives must leave. | |
| **Guarded by** `test_a_nan_in_the_input_is_not_silently_laundered`. | |
| --- | |
| ## 12. The renormalizer's epsilon was absolute; cancellation is relative | |
| The #3 fix guarded ``|den| < 1e-6``. A signed float32 kernel row of | |
| ``[1.0, -0.9999]`` over two present cells leaves a denominator of ~1e-4 β | |
| small enough to amplify by 1e4, large enough to sail past any tiny fixed | |
| threshold. Measured: input max 252, output max 1.8M, a ~7,000x blow-up in the | |
| library's *default* dtype. The prediction that half precision would be the | |
| vulnerable dtype was exactly backwards: fp16/bf16 round the near-cancellation | |
| to exact zero, which the old guard caught, and came out bounded. | |
| **Cause.** The fix for #3 repeated #3's mistake one level up: it guarded a | |
| threshold instead of the phenomenon. The phenomenon is cancellation, and | |
| cancellation is relative. | |
| **Fix.** Compare the signed mass against the absolute mass that went into it | |
| (``|kernel| @ mass``); a line is degenerate when almost everything cancelled. | |
| A genuinely *small* mass still renormalizes exactly, because the numerator | |
| carries the same factor. | |
| **Guarded by** `test_float32_near_cancellation_does_not_explode`. | |
| `test_a_genuinely_small_mass_still_renormalizes_exactly` passes pre-fix β it | |
| pins the new guard against overreach, in the sense #1/#3 taught us to state. | |
| --- | |
| ## 13. `mask()` returned a view of `valid`, and `valid` aliased the caller | |
| Two directions of the same aliasing. ``mask(torch.bool)`` was | |
| ``valid.reshape(...).to(bool)`` β a **view**, so writing into "your" mask | |
| corrupted the lattice through it. And ``valid.to(torch.bool)`` returns the | |
| caller's own tensor when it is already bool, so a caller who reused or edited | |
| that tensor after construction desynced the cached ``flat_idx`` β measured: | |
| ``flat_idx`` still listing 3 cells while ``n_valid`` said 2, which is | |
| ``scatter`` misplacing data, the exact failure #2 was fixed to prevent. | |
| **Cause.** #2 froze the *attributes* and left the *tensors* shared. A value | |
| object holding mutable buffers is only a value if it owns them. | |
| **Fix.** Clone ``valid`` at construction; ``mask()`` builds a fresh tensor per | |
| call (callers buffer it at module construction β there was nothing worth | |
| caching). | |
| **Guarded by** `test_mask_is_a_copy_not_a_view_of_valid`, | |
| `test_the_callers_valid_tensor_is_not_aliased`. | |
| --- | |
| ## 14. `collate_lattice` keyed target presence off `samples[0]` | |
| A batch mixing horizon-0 samples with targeted ones either silently dropped | |
| every target (first sample lacked one) or crashed mid-stack. Under a shuffled | |
| loader, *which* of the two you get changes per batch. | |
| **Fix.** Target presence must be unanimous; a mixed batch raises. | |
| **Guarded by** `test_collate_refuses_mixed_target_presence`, both orderings. | |
| --- | |
| ## 15. `split_at_time` trusted its times to be sorted | |
| Unsorted timestamps produced a silently nonsensical train/test split β the | |
| quietest possible leakage bug, in the function whose entire job is preventing | |
| leakage. ``LatticeTable.times`` happens to be sorted, which is exactly the | |
| assumption-holds-in-one-configuration shape of Β§A1: the function is public and | |
| takes any sequence. | |
| **Fix.** Sortedness is checked; unsorted input raises. | |
| **Guarded by** `test_split_at_time_refuses_unsorted_times`. | |
| --- | |
| ## 16. The conformance suite tested ranks the caller never requested | |
| ``check_block(factory, ranks=(3, 4))`` gradchecked a **rank-2** lattice β | |
| hardcoded "for speed" β and would have run the rank-1 equivalence check on a | |
| rank-1 one. A factory that is only valid at its stated ranks failed checks it | |
| should pass, and the passing checks partly measured a configuration nobody | |
| asked about. | |
| **Fix.** The gradient check uses a requested rank; the equivalence check skips | |
| with a reason when rank 1 was not requested, per the suite's own | |
| skips-are-not-passes rule. | |
| **Guarded by** `test_checks_run_only_at_ranks_the_caller_requested`. | |
| --- | |
| ## 17. `chunk=0` failed as `range() arg 3 must not be zero` | |
| Not wrong, just useless: the contract violation surfaced three frames deep | |
| with no mention of ``chunk``. Boundaries state their contracts; | |
| ``axial_apply`` now validates ``chunk >= 1`` itself. | |
| **Guarded by** `test_chunk_must_be_positive`. | |
| --- | |
| ## 18. `gather`/`scatter` broke in one device direction | |
| ``lat.to(device).gather(x_cpu)`` raised from three frames inside an indexing | |
| kernel, while the mirror case β CPU lattice, device tensor β happened to work, | |
| because torch tolerates CPU indices on a device tensor but not the reverse. | |
| The cached ``flat_idx`` lives wherever ``valid`` lives, and callers should not | |
| have to know that. | |
| Found **without CUDA**: device-placement bugs need *a* second device, not a | |
| specific one, and this machine's MPS backend is one. The whole class is now | |
| pinned by [tests/test_device.py](tests/test_device.py), which runs against | |
| whatever accelerator exists (MPS here, CUDA elsewhere) and skips visibly on | |
| CPU-only machines. What MPS cannot vouch for β CUDA kernel numerics, | |
| ``torch.compile`` backends, float64 on device β is stated in that file's | |
| docstring rather than silently unclaimed. | |
| **Fix.** Index with ``flat_idx.to(x.device)``; a no-op when they agree. | |
| **Guarded by** `test_gather_scatter_round_trip_across_device_mismatches`, | |
| both mismatch directions. | |
| --- | |
| ## 19. A stale training run shipped inside the viewer bundle | |
| `td.viz.show` serves a static bundle built from `viewer/`. Vite copies | |
| everything in `viewer/public/` into that build, and the live-training script | |
| writes `viewer/public/run.json` there β so the first bundle carried a training | |
| run from this laptop, and the viewer loaded **it in preference to the model | |
| passed to `show()`**. The feature's central promise ("show me *this* model") | |
| was broken by a file nobody thought of as part of the feature. | |
| Found by opening the served page and reading the sidebar: the model card said | |
| `Mamba, 18 layers, 4x5x6x4` for a model built as `S4DND, 8 layers, 4x5x6`. | |
| Every automated check passed β the bundle built, the server served, the tests | |
| (as written at that moment) were green. | |
| This is the 35 MB sdist (`node_modules` in a released tarball) one directory | |
| over, and the same lesson: **the thing that ships is the thing to inspect.** | |
| `twine check` passes bloated tarballs happily and a build log says nothing | |
| about what a page will render. | |
| **Fix.** `viewer/install_bundle.py` strips `run.json` from the copied bundle | |
| and says so. **Guarded by** | |
| `test_no_local_training_run_rides_along_in_the_bundle`, which asserts the | |
| served `/run.json` is a 404, plus a publish-workflow step that greps the | |
| built *wheel* for `viz/static/index.html` rather than trusting the build. | |
| --- | |
| ## 20. A benchmark column that measured something else entirely | |
| The Phase 10 benchmark table had a `peak MB` column reading | |
| `torch.mps.driver_allocated_memory()` on MPS β which is the whole process's | |
| driver allocation, not the model's. It reported **18 GB for a 25,000-parameter | |
| model**, and would have been published in BENCHMARKS.md as a memory | |
| measurement. | |
| Nothing failed. The number was plausible in shape (a float, in MB, varying | |
| between rows) and absurd only if you knew what it should be. It was caught by | |
| reading the generated table and asking why two models three orders of | |
| magnitude apart in size used the same memory. | |
| **Fix.** Only CUDA tracks an allocation high-water mark, so the column reads | |
| `n/a` everywhere else, and `peak_memory_mb`'s docstring records why the | |
| plausible substitute was removed. **A number that is not what its header | |
| claims is worse than a blank** β a blank invites a question, a wrong number | |
| answers it. | |
| --- | |
| ## 21 & 22. The conformance suite found both bugs in its own documentation's example | |
| While writing `docs/adding-a-method.md`, the example strategy β thirty lines | |
| that rewrite a schedule β was run through `check_block`. Two failures: | |
| ``` | |
| [FAIL] shape is preserved β ZeroDivisionError: integer division or modulo by zero | |
| [FAIL] output is covariant with axis storage order | |
| ``` | |
| **#21** was `others[(i // 2) % len(others)]` on a rank-1 lattice, where the | |
| only axis *is* the dominant one and `others` is empty. Low severity, instant | |
| diagnosis, and caught by the cheapest check in the suite at the rank people | |
| skip because "rank 1 is trivial". | |
| **#22** is the interesting one, and it is the canonical N-D bug in a single | |
| line: the strategy took its axis order from `lattice.axis_names`, so the same | |
| model over the same data *laid out differently* produced a different schedule. | |
| No exception, no NaN, no loss curve that looks wrong β just a model whose | |
| behaviour depends on storage order rather than on the sweep order it was | |
| asked for. This is the same shape as #4 (bidirectional aliasing) and it is why | |
| the covariance check exists at all. | |
| Both are fixed in the example, and both are now *described in the guide* as | |
| what the suite caught, with a test that reproduces the broken version to prove | |
| the check still fails it. The most useful thing a conformance suite can do for | |
| a documentation page is embarrass it. | |
| --- | |
| ## 23. The gradient check substituted its own width | |
| `check_block`'s gradient step built the block at `d_model=2` regardless of what | |
| the caller passed, for speed. That went unnoticed for as long as every mixer | |
| accepted every width β and surfaced the moment `AttentionMixer` arrived, whose | |
| head count must divide `d_model`: a check about *gradients* failed with | |
| `n_heads=4 does not divide d_model=2`, about a model the caller never asked | |
| for. | |
| This is DEBUG.md #16 with a different argument. That entry was about ranks the | |
| caller excluded; this is a width the caller excluded. The pattern is the | |
| general one in Β§A: **a test harness that quietly substitutes its own | |
| parameters is testing something other than what it reports on** β and the | |
| failure mode is not always a confusing error, it can equally be a silent pass | |
| for a configuration nobody runs. | |
| **Fix.** Use the caller's `d_model`. **Guarded by** | |
| `tests/test_attention_mixer.py::test_conformance`, which cannot pass under | |
| the old behaviour. | |
| --- | |
| ## 24. numpy is not a torch dependency, and the laptop lied about it | |
| `MemmapSource` reads and writes `.npy`; `safetensors`' torch bindings import | |
| numpy internally. Both worked perfectly on the development machine and both | |
| failed in CI with `ModuleNotFoundError: No module named 'numpy'` from three | |
| frames inside somebody else's writer β because torch does not require numpy, | |
| and the CI install is a genuinely minimal one. | |
| Two fixes, because there are two faults. The extras now declare numpy where | |
| the feature needs it, so CI actually exercises those paths instead of skipping | |
| them and reporting green. And `MemmapSource` raises an error that names the | |
| package and points at `TensorSource` as the in-memory alternative, rather than | |
| surfacing an import failure from a stack the user did not write. | |
| **The process finding is the larger one.** This landed one commit after | |
| `scripts/check.sh` was added *specifically* to stop CI surprises, and the | |
| script had passed. It makes the **commands** identical to CI's; it cannot make | |
| the **environment** identical, and this failure lived entirely in the | |
| difference. A local gate can prove "the checks pass here". Only CI can prove | |
| "the checks pass in a clean environment", and the honest conclusion is that | |
| the two answer different questions β so the script now says so in its own | |
| docstring rather than implying it is a substitute. | |
| **Guarded by** `[dev]` carrying numpy (so the paths run in CI at all) plus | |
| `pytest.importorskip("numpy")` in both test modules, so an environment without | |
| it skips *visibly* instead of failing. | |
| --- | |
| ## 25. The timer measured the queue, and I had already written that down | |
| The reproduction scripts have a `--dry-run` that times one training step and | |
| extrapolates to minutes-per-epoch, so a long run can be scheduled with some | |
| idea of its cost. For the 2-D Mamba model it reported **0.03 s/step, 0.5 | |
| min/epoch**. The real run took **1.19 s/step, ~19 min/epoch** β 40x more β and | |
| a six-epoch job was queued on the strength of the wrong number and had to be | |
| killed an hour later. | |
| The cause is one missing line. On MPS and CUDA a `.backward()` returns as soon | |
| as the work is *queued*, so an unsynchronized timer measures dispatch. This is | |
| stated, in bold, in the second paragraph of `benchmarks/bench.py`: | |
| > **the timer synchronizes**: on MPS and CUDA the dispatch returns long before | |
| > the work does, so an unsynchronized loop measures the queue, not the model. | |
| Written by the same hand, the same day, in the file next door β and then not | |
| applied to the second timer, because the second timer did not look like a | |
| benchmark. It looked like a convenience. | |
| **The pattern (Β§A):** a rule written down in the place it was learned does not | |
| transfer to the next place it applies. What transfers is a shared function. | |
| `sync(device)` now lives in `harness.py` and is called by both dry-run paths, | |
| which is the only version of "remember to synchronize" that survives contact | |
| with a second author or a tired one. | |
| **Guarded by** nothing automatic, and that is honest: this is a measurement | |
| helper, and a test that asserts a timing is the flake this project has already | |
| refused to write once (`tests/test_perf.py`). The mitigation is that the | |
| timing code exists in exactly one place now. | |
| Two things that look like defects and are not. Recorded so they are not | |
| "fixed" later. | |
| **A one-ULP drift in multi-layer stacks.** A single layer on a rank-1 lattice | |
| is bitwise identical to the bare 1-D module. A *stack* differs by ~1e-16. The | |
| fold reshapes, which requires contiguity, while `nn.LSTM` returns a transposed | |
| view β so from layer two the mixer receives contiguous input where a bare stack | |
| receives a view, and torch's RNN kernels are not bit-identical across memory | |
| layouts. This is why the conformance suite claims bitwise equality for one | |
| layer only. | |
| **A surviving mutant.** Reversing the order in which non-swept axes fold into | |
| the batch changes nothing, and no test catches it. Correct: the mixer contract | |
| requires rows to be independent, so their order within the folded batch is | |
| unobservable. A semantically equivalent mutant, not a coverage hole. | |
| --- | |
| ## 26. The spec described a model the library never runs | |
| `td.spec(model)` emits the document the viewer renders, `td.viz.show` serves, | |
| and a downstream tool may parse. For a kernel-family model β `method=td.cafa` | |
| or `td.axial_attention` β it said this: | |
| ``` | |
| layer 0: axis time, mixer LSTMMixer | |
| layer 1: axis h, mixer LSTMMixer | |
| layer 2: axis w, mixer LSTMMixer | |
| ``` | |
| None of layers 1 and 2 happens. `AxialKernel.forward` contracts **every** | |
| spatial axis with a kernel on **every** layer, and applies the mixer along | |
| time only. The document was describing a scan model that was never built, and | |
| `"nd_method": {"family": "scan"}` was a hardcoded string in a function whose | |
| name β `scan_model_spec` β was the only thing about it that was still true | |
| after the kernel family landed. | |
| The viewer, meanwhile, was *already right*: `Scene.jsx` sniffed | |
| `nd_method.name === "AxialKernel"` and drew a simultaneous flash instead of a | |
| travelling wavefront. So the renderer had a special case that the document it | |
| renders did not, and the sidebar β which reads `layer.axis` directly β printed | |
| the three sweeps anyway. Half the system knew. | |
| **Cause.** A schema written when there was one family, extended by adding a | |
| family rather than by extending the schema. The per-layer record had exactly | |
| the fields a scan needs (`axis`, `axis_index`, `reverse`) and no way to say | |
| "this layer contracts a set of axes and sweeps none of them", so the kernel | |
| family was serialized through the only vocabulary available. | |
| **Found by** writing a third family. Asking "what will a `flatten` layer put | |
| in the `axis` field?" has no answer, and the same question asked of the | |
| existing kernel family turned out to have a wrong one already shipped. | |
| **Fixed** by giving layers a `kind` (`scan` | `kernel`), an `axes` list of what | |
| the layer actually mixes, and `contracted` for the axes handled by kernels; | |
| `sweeps` gains `contracted_axes`, and `directions` now lists only axes a mixer | |
| genuinely sweeps, because a contraction has no direction. `nd_method.family` | |
| is derived from the composition class. Spec version 1 to 2. | |
| **Guarded by** `tests/test_spec.py::test_the_kernel_family_spec_does_not_claim_spatial_sweeps` | |
| and the golden fixtures, which is how the blast radius was visible at all: the | |
| regeneration diff showed four stored specs changing, and the cafa one changing | |
| in exactly the way the fix intends. | |
| **The pattern (Β§A):** the renderer's special case was documentation of a defect | |
| in the data. A consumer that has to compensate for a producer is evidence the | |
| producer is wrong, and it is worth reading such a special case as a bug report | |
| rather than as a feature. | |
| --- | |
| ## 27. Out of the sequence is not out of the output | |
| The joint (`flatten`) composition drops absent cells from the token sequence | |
| entirely β a sparse lattice becomes a shorter sequence, which is the one place | |
| in this library where sparsity is a saving rather than bookkeeping. Absent | |
| cells never reach the mixer at all, so the mask-invariance guarantee looked | |
| free. | |
| It was not. The conformance suite failed on the first run: | |
| ``` | |
| [FAIL] absent cells cannot influence the output β rank 2: perturbing absent | |
| cells changed the output | |
| ``` | |
| The residual stream is the path. `x = x + h` adds the layer's output to the | |
| *original* `x`, which still holds whatever was sitting in the absent cells, so | |
| the output at an absent cell echoed its input. Every value the mixer saw was | |
| clean; the output was not. | |
| **The reasoning error, stated exactly:** "their values never reach the mixer" | |
| is a claim about one path. "Their values cannot influence any output" is a | |
| claim about every path. I proved the first and wrote the second, and the two | |
| differ by a residual connection that was three lines further down the same | |
| function. | |
| **Fixed** by zeroing on entry and after every layer, which is precisely what | |
| `AxialScan` and `AxialKernel` already do β the new family had rederived the | |
| guarantee from scratch instead of copying the mechanism, and got a weaker | |
| version of it. | |
| **Found by** `td.testing.check_block`, first run, before the family had a test | |
| of its own. This is the fifth bug the conformance suite has caught in a block | |
| written by the same person who wrote the suite (Β§B), and the argument for | |
| running it against your own work is exactly that: it does not share your | |
| reasoning, only your interface. | |
| --- | |
| ## 28. The shared function existed and I did not call it | |
| Two red builds in one session, both from the same cause, and the cause was not | |
| a missing tool. | |
| The first: `ruff format src tests` locally where CI runs `ruff format --check .`, | |
| so `viewer/make_samples.py` and a code block inside `LTI.md` went unformatted. | |
| The second, in the very next push: `pytest` locally where CI runs | |
| `pytest --cov`, so the new families' error paths pushed total coverage to | |
| 93.96% against a 95% floor and the gate caught what I had not looked at. | |
| `scripts/check.sh` runs exactly what CI runs, in the same order. It was added | |
| earlier the same day, in a commit titled *"run what CI runs, because I did | |
| not"*. Its header names this failure mode in its second paragraph β it even | |
| uses `ruff check src tests` as the example. I wrote that file, gave it that | |
| commit message, and then typed the ad-hoc command anyway. Twice. | |
| **The pattern, one level past Β§A's usual form.** #25's lesson was "a rule | |
| written where it was learned does not transfer; what transfers is a shared | |
| function." This is the sequel: *a shared function only transfers if it is the | |
| path of least resistance.* Typing `pytest -q` is shorter than | |
| `bash scripts/check.sh`, and shorter wins under momentum every time. | |
| **Not fixed by resolve.** What would actually fix it is making the shortest | |
| command the correct one β a pre-push hook, or an alias β and that is a change | |
| to a developer's environment rather than to this repo, so it is recorded here | |
| rather than committed. The honest status is: the tool is right, the habit is | |
| not, and the guard that caught both was CI. | |
| **Third instance, and a different failure.** Two commits later I *did* run | |
| `scripts/check.sh` β and then committed and pushed in the same shell command, | |
| reading its output only after the push had gone out. The gate had exited 1 on | |
| a long line in a docstring I had just edited. Running the check and *gating on | |
| the check* are not the same act; a gate whose exit status you do not branch on | |
| is a log message. The pattern is now: forgot to run it, ran the wrong one, ran | |
| the right one and ignored the result. Each is a smaller mistake than the last, | |
| which is progress of a sort, and each produced an identical red build. | |
| **And then running it found a defect in it.** The first honest run of | |
| `scripts/check.sh` failed β on four `UP038` violations that CI cannot possibly | |
| report, because the rule was *removed* from ruff before the version CI | |
| installs. The script invoked bare `ruff`, which resolved through PATH to a | |
| conda-installed 0.6.7 while the project's dev extra pins 0.16.1. So the tool | |
| written to make local checks match CI was itself running a different tool than | |
| CI. It now invokes everything as `$PYTHON -m <tool>`, defaults to `.venv`, and | |
| prints the resolved versions first, because a gate that reports failures CI | |
| cannot have teaches you to ignore the gate. | |
| **Worth noting what worked.** The coverage floor did exactly its job. The new | |
| code was not undertested by accident; it was undertested because I had not | |
| written tests for the refusal paths yet, and a number that moved 1.04% told me | |
| so before a reviewer had to. `mixers/conv.py`, `models/vit.py` and | |
| `compose/flatten.py` are now at 100%, and every line of it is an error message | |
| someone will eventually read. | |
| --- | |
| ## A. Recurring patterns | |
| Four classes account for the first eight β and the later finds keep landing in | |
| them: #10 is another silent downgrade like #4, #11 another guard aimed at the | |
| wrong failure like #3, and #9 another object whose neat idiom does not deliver | |
| what it announces, like A3. | |
| **A1 β An assumption that holds in one configuration.** (#3, and both | |
| private-repo findings.) A masking or normalization step correct for one axis, | |
| or one kernel sign, or one density, silently wrong outside it. All three | |
| instances were *documented in a comment* asserting the property rather than | |
| tested for it. **A comment stating an invariant is a place to put a test.** | |
| **A2 β Periodicity aliasing.** (#4.) Two independent cycles whose periods share | |
| a factor lock together. Anywhere a schedule combines "which thing" with "which | |
| variant", check whether the two periods are coprime, and test at even *and* odd | |
| counts β testing one parity finds nothing. | |
| **A3 β Value objects that aren't.** (#1, #2.) An object that is hashed, cached | |
| from, or used to build derived state at construction must be immutable. The | |
| `object.__setattr__` idiom announces this without delivering it. | |
| **A4 β Tests that cannot fail.** (#5, #6.) A test with no reachable failing | |
| input is worse than no test, because it reads as coverage. Every assertion | |
| should have an input that breaks it; if you cannot name one, the test is | |
| decoration. | |
| --- | |
| ## B. What actually found these | |
| Ranked by yield in this project. | |
| 1. **Mutation testing.** Break the code deliberately, confirm the suite | |
| notices. Found #6 and validated #4. Cheap: revert with `git checkout` β | |
| and now automatic: [`scripts/mutate.py`](scripts/mutate.py) holds a catalog | |
| of seven mutations, each one a bug from this list, and runs weekly in CI. | |
| All seven are currently caught. A survivor would be a hole in the tests | |
| rather than a bug in the code, which is the distinction that makes this | |
| worth automating at all. | |
| The same trick runs backwards through history β | |
| `git checkout <fix>~1 -- <file>`, run the guard, restore β which is how | |
| every **Reproduce** block above was verified, and how two cited "guards" | |
| were exposed as tests that pass on the buggy code (#1, #3). | |
| 2. **Independent references.** Check against something sharing no code β | |
| `x.cumsum(dim=d)` for an axial sweep, `torch.kron` for the factorization, | |
| `torch.argsort` for an inverse permutation, a hand-written decoder for an | |
| encoder. A round-trip against your own implementation proves only | |
| self-consistency. | |
| 3. **Constant-input invariants.** If a convex combination is claimed, feed a | |
| constant: any convex combination of identical values is that value, so any | |
| deviation is mass that the denominator did not count. One line, no | |
| tolerance-tuning, and it localizes the error immediately. | |
| 4. **Negative controls.** For anything measuring capability, include a variant | |
| that *must* fail. #5 was invisible until the control outscored the model. | |
| 5. **Running the tools you already configured.** #7. Twenty-three errors were | |
| sitting behind a command nobody had typed. | |
| 6. **Adversarial edge cases on guards.** Ask what a clamp, an epsilon, or a | |
| `nan_to_num` is protecting against, then construct the case it *isn't*. | |
| That is exactly how #3 was found. | |
| 7. **Looking at the artifact rather than the process.** #19 and #20 were both | |
| invisible to every automated check and obvious within seconds of *reading | |
| the output*: a served page whose sidebar described the wrong model, a table | |
| claiming 18 GB for a 25k-parameter model. Build logs, green suites and | |
| `twine check` all report on the process. Open the page, read the table, | |
| list the wheel. | |
| 8. **Running the conformance suite on the examples.** #21 and #22 were in this | |
| project's own documentation, in code written to *demonstrate* correctness. | |
| The suite found both in one run. | |