Skip to content

ten-vad driver WIP - #158

Draft
crutcher wants to merge 32 commits into
mainfrom
crutcher/tv-dev
Draft

ten-vad driver WIP#158
crutcher wants to merge 32 commits into
mainfrom
crutcher/tv-dev

Conversation

@crutcher

Copy link
Copy Markdown
Member

WIP on Ten-Vad Driver. Too much robot code here; needs cleanup/unification.

crutcher and others added 30 commits August 21, 2026 15:24
…rward cross test

`ten_vad` had only the network half: `TenVad::forward` starts at an
already-widened `[1, 3, 41]` feature stack, and nothing turned audio into
those 41 bins. This adds the other half, matching the split `silero_vad`
already has.

New `ten_vad::context` module, porting the reference front end
(`ALGO_TRACE.md` §3.3 - §3.7):

* `coeff`        - reference constants and the `coeff.h` mean/std tables
* `pre_emphasis` - `y[n] = x[n] - 0.97*x[n-1]`, with cross-hop carry
* `mel`          - the 40-band filterbank: HTK mel, truncating bin cast,
                   integer slopes, no area normalization
* `pitch`        - `TenVadPitchSource` seam, with a `ZeroPitch` default
* `features`     - the 41-dim extractor and its streaming state
* `driver`       - `TenVadContext` plus `TenVad::context_forward` and
                   `context_forward_sequence`

The STFT half reuses `ops::signal::SlidingStft` as-is, and the Hann-768
window is generated by `StftWindowConfig::Hann { periodic: true }` rather
than transcribed -- verified equal to the reference `coeff.h` table to
within f32 rounding.

Two orderings from the reference are load-bearing and preserved: pitch reads
the raw, un-pre-emphasized hop and the un-normalized bin power, and the
`1/32768^2` division precedes the filterbank matmul.

Adds `test_reference_model_context_forward_sequence_cross_test`, which drives
real 16 kHz audio through the driver and pins three arms against each other:
the batched sequence path, the iterative single-step path, and the ONNX
reference graph stepped by hand over the driver's own feature stacks. Since
all three share those features, it also asserts the output actually tracks the
audio rather than sitting flat. Feature-extraction fidelity is pinned
separately, against a from-scratch host implementation of the pipeline.

`TenVad::forward` gains rustdoc recording that its leading `a` axis is the
graph's sequence axis rather than a stream batch, and that `frame_features`'
reshape diverges from the reference for `a > 1`. Left as-is deliberately:
batch stays 1 until the driver machinery is fully mapped.

Known deviations, documented in `context::mod`: the pitch feature is stubbed,
there is no periodic LSTM reset, and there is no numeric golden yet.

Also promotes the duplicated `load_audio_mono_sr` test helper into
`support::testing`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFXHzoUTEy67NZqa1wTarA
…olden

Feature `40` of the ten-vad feature vector was stubbed: `ZeroPitch` pinned it
to a constant while the other 40 features were exact. This ports the reference
estimator behind the existing `TenVadPitchSource` seam.

Ported from the **C reference** (`/home/crutcher/git/ten-vad/src/pitch_est.cc`),
not from the `ten-vad-rs` Rust port, which was found to carry two divergences:

* its `lpc_from_bands` scales the inverse FFT by `1/n`, where the C's
  `AUP_FFTW_RescaleIFFTOut` multiplies by `0.5` -- leaving the autocorrelation
  512x too small, which matters because `ac[0]` then gets an *absolute* noise
  floor added; and
* it clears `pitch_max_path_reg` every frame, where the C carries that Viterbi
  accumulator across hops and renormalizes it by the running maximum.

New `ten_vad::context::pitch` module:

* `coeff`     - the reference constants, including `AUP_PE_PI`'s truncated
                `3.1415926` (one ULP below `f32::consts::PI`, and load-bearing
                only in that it seeds the DCT table)
* `biquad`    - the 5-section Direct-Form-II anti-alias cascade
* `lpc`       - band folding, the DCT/IDCT cepstrum, the autocorrelation, and
                the Levinson-Durbin solve
* `estimator` - the four-stage tracker: pre-filter design, excitation,
                correlation, and the Viterbi period search

The reference reaches its autocorrelation through an FFTW half-complex inverse
transform. Since the input is real and even that collapses to a cosine sum, and
only 17 of 1024 outputs are ever read, so `Autocorrelator` evaluates it directly
against a cosine table -- verified equal to the reference transform to within
f32 rounding, and free of any FFT dependency.

Adds `test_pitch_estimator_reference_golden`, against
`testdata/ten/pitch.json`: one reference pitch per hop over the whole 60 s
fixture, produced by driving the C `AUP_PE_proc` from the C `AUP_Analyzer` STFT
-- the same wiring `AUP_Aed_runOneFrm` uses. The two arms reach their bin powers
through different FFTs, so the test asserts the voicing decision on every frame
and a relative bound where both call a frame voiced. Measured: **100% voicing
agreement across all 3750 frames, mean relative error 2.5e-7**.

`testdata/ten/README.md` documents the fixture's provenance and carries
`dump_pitch.cc`, the harness that generates it; the documented recipe was
verified to reproduce the checked-in file byte for byte.

`TenVad::init_context` now defaults to the real estimator rather than
`ZeroPitch`; `init_context_with` still selects the stub, which keeps the
sequence path entirely on-device at the cost of feature `40`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…readback

The ten-vad front end is a burn tensor pipeline except for feature `40`:
`TenVadPitchEstimator` is host-side scalar code, so the driver read the raw
hops and the full bin-power array back from the device, stepped the estimator,
and uploaded the result. That device-host round trip was spread across
`features.rs`, which made the front end's one synchronization point hard to
see and impossible to swap out.

This is groundwork for a device-side pitch implementation, and changes no
numerics: `test_pitch_estimator_reference_golden` passes unchanged.

The seam is now three traits:

* `TenVadPitchSource<B>`      - tensor-in, tensor-out; `forward` /
                                `forward_sequence`, matching every other
                                streaming layer in the tree
* `TenVadPitchSourceInit<B>`  - a factory. Required, not incidental: a
                                tensor-native source must *allocate* its
                                carried buffers for a `(batch, device)` pair,
                                so it cannot be a prototype cloned per row
* `TenVadPitchScalarSource`   - the per-stream host contract the reference
                                estimator implements

`HostPitch<P>` (new `pitch/host.rs`) adapts a scalar source across that seam
and is **the only place the front end synchronizes** -- one readback per call,
not one per hop. `ZeroPitch` becomes tensor-native, so `inspects_input` is
deleted rather than replaced: with a tensor seam it never touches its
arguments, and `dims()` / `device()` are metadata, not sync points.

`TenVadFeatureContext` holds one `P` instead of a `Vec<P>`, and `batch_size()`
now comes from the STFT queue rather than the pitch vector, which would have
gone circular once the source became batch-aware. `pitch_column`,
`pitch_column_sequence` and `inspects_input` are gone (~75 lines).

Adds host-audio entry points -- `TenVadFeatureContext::forward_audio_sequence`,
`TenVad::context_forward_audio` and `context_forward_audio_sequence` -- which
frame and upload `&[f32]` rows and take the device from the context, so the
caller never names one. Pure API; the tensor forms are unchanged.

Also:

* every `ten_vad::context` test module moves to `PerformanceBackend`. They were
  pinned to `CpuBackend`, so 112 of 115 tests ran on Flex no matter what feature
  was passed and `--features wgpu` bought almost nothing for this kit. This
  required removing a hardcoded `FlexDevice` from `driver.rs`'s test helper.
* `PROC_RESAMPLE_RATE` moves to `coeff` beside `PROC_FS`, removing a test that
  reached backwards from `coeff` into `estimator`.
* `frame_pitch_with_lpc` exposes the one stage boundary that carries no state,
  plus a `#[cfg(test)]` oracle surface (`exc_buf`, `xcorr_slot`, `path_score`,
  ...) for differential testing. `xcorr_slot` takes a *stream* index and applies
  the ring translation internally.
* corrects `celt_lpc`'s rustdoc: coefficients past the early break are `0.0`,
  not "whatever the recursion had reached" -- index `k` is only written at step
  `k`, and the symmetric update touches only `0..i-1`.

Tests: 115 in the kit (from 97), 511 across the workspace, all green under
`--features wgpu`. New coverage where it was missing: both
`test_forward_sequence_matches_stepwise` and `test_batch_rows_are_independent`
used `ZeroPitch` and so proved nothing about the pitch branch -- they gain
estimator arms that assert residual per-row state, which is newly load-bearing
now that the source is batch-aware rather than a `Vec`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…ig::default

Crutcher's changes, committed alongside the ten-vad pitch work they were made
during.

* Two narrower run configurations, `Test (wgpu) bunsen` and
  `Test (wgpu) bunsen::kits::speech`, for a faster iteration loop than the
  whole-workspace `Test (wgpu)`.
* `RUST_TEST_THREADS` 8 -> 4, reducing contention for the device now that the
  speech kits actually run on it.
* `impl Default for SlidingStftConfig`, delegating to `new()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…the C golden

Phase 1 of moving the pitch estimator off the host. Adds
`context::pitch::tensor`, and within it the first of the estimator's four
stages: the whitening-filter design, `[rows, n_bins]` bin powers to
`[rows, lpc_order]` coefficients.

That stage is the natural first move because it **carries no state**. It reads
one hop's spectrum and writes one hop's filter with nothing threaded between
hops, so a whole `steps x batch` sequence flattens into the row axis and the
stage runs in a single pass -- a coefficient-only object with no context,
structurally like `TenVadMelBank`.

**Tables built by probing, not transcription.** Each projection matrix is the
linearization of a host function, and every one of those is linear by
construction, so each column is obtained by evaluating the host function on a
basis vector. Agreement with the host becomes a property of the construction
rather than something a test has to establish, and the awkward parts --
`band_span`'s independently rounded ends, `interp_band_gain`'s "later span
wins" assignment -- stay defined in exactly one place.

**Three matrices, not four.** `interp_band_gain` -> zero Nyquist ->
`autocorrelate` are three linear steps from 18 bands to 17 lags, so they fold
into one `[18, 17]` matrix. That removes the 513-wide intermediate from the
device path, and sidesteps a precision trap: `Autocorrelator` deliberately
accumulates in `f64` over 511 `f32` terms because a flat `f32` sum there is
worse than the FFT it replaces, and a naive device matmul over 513 bins would
reintroduce exactly that error. Folding on the host keeps the `f64`
accumulation and leaves the device an 18-term contraction. The DCT is likewise
one matrix serving both directions.

**The two parts that are not matmuls.** The log-compression clamp is a coupled
scan over 18 bands -- unrolled, but batched across the whole sequence, so it
costs 18 tiny kernels per call rather than per hop. The Levinson-Durbin
recursion has a data-dependent early exit, which a batched version cannot
branch on; it runs all 16 steps and freezes each row under a mask instead. Two
details make that faithful: the reference checks *after* completing an
iteration, so the freeze is applied after the update; and no sticky
bookkeeping is needed, because a frozen `error` stays below its threshold and
so the mask is already monotone.

**The gate.** `HybridPitch` (test-only) runs this stage on the device and the
remaining three on the host, and `test_tensor_prefilter_hybrid_reference_golden`
drives it through the same C golden the host estimator is pinned to. That is
the question the stage-level differential tests cannot answer: whether a
tolerance survives the tracker's `argmax` and voicing threshold, both discrete,
where a single argmax step of +/-1 moves the reported pitch by roughly half a
percent. It does -- **voicing agrees with the C reference on all 3750 frames**.
The value bound is relaxed to 1e-3 for the hybrid arm, since the device
contracts the band projections in `f32`; the voicing bound is not relaxed.

The two golden tests now share a driver and an assertion helper, so the host
arm and any device arm are compared the same way.

Also exposes `dc0_bias` to the crate, and notes that burn offers only a natural
logarithm, so `log10` is spelled `log(x)/ln(10)` with the matching
`exp(x*ln(10))` on the way back out.

Tests: 148 in `kits::speech` under `--features wgpu`, all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Phase 2: the correlation stage, `[rows, exc_len]` excitation history to
`[rows, 2, max_period]` normalized correlations plus `[rows, 2]` weights.

Taken before the excitation stage deliberately. Stage 3 is **stateless given
the history** -- everything it needs lives inside the window the excitation
stage maintains, and the correlation *ring* belongs to the tracker rather than
here -- so its differential test needs no tensor stage 2 at all: drive the host
estimator, read its excitation buffer out through the oracle accessors, upload
that, and compare both arms. Genuinely independent, and it means stage 2 lands
with a consumer already waiting for it.

Three things shaped the implementation:

* **The correlation is a shift-and-accumulate, not a matmul.** As
  `[max_period, half]` windows against a reference vector it would materialize
  a `rows x 64 x 32` intermediate -- tens of megabytes over a long sequence. As
  `half` broadcast multiply-adds over `[rows, max_period]` slices it is ~30x
  smaller, and it accumulates in the reference's order rather than a tree
  reduce's.
* **The lagged energy is not a `cumsum`.** The `max(..., 0)` sits inside the
  recurrence, so it is not associative and a prefix-sum difference diverges
  whenever the clamp fires. It stays a 64-step sequential loop, batched across
  every row, so it costs ~190 tiny kernels for a whole sequence rather than per
  hop.
* **The octave suppression vectorizes exactly**, which was not obvious. The
  reference rescales `xcorr[lag]` in place while reading three neighbours near
  `lag/2 + 32`. Those reads turn out to be *always strictly ahead* of the write
  -- tightest margin 8, at `lag = 46` -- so no iteration ever observes a value
  an earlier one changed, and reading the unmodified array is equivalent rather
  than approximate. `SHARPEN_IS_WRITE_SAFE` pins the invariant as a const, and
  a test re-derives it from the index vectors so a geometry change cannot
  quietly invalidate it.

Also adopts `SileroVad::forward_sequence`'s buffer discipline for the scans in
both device stages: write into a preallocated tensor with `slice_assign`
instead of collecting slices and concatenating, and -- where the output shares
its input's shape, as the log-compression scan does -- cannibalize that input
when `B::ad_enabled` is false, so the single live reference can lower to an
in-place update. The correlation's sliding energy has no same-shaped input to
reuse, but still beats a 64-way `cat`.

One test of mine was wrong and is corrected: an impulse train does not peak at
lags that are multiples of its period, because the reference window starts at
`max_period + base` rather than at the buffer start. The alignment condition is
`lag = max_period (mod period)`, which for the ten-vad geometry means lags
4, 24, 44 -- not 0, 20, 40.

Tests: 151 across `silero_vad` and `ten_vad` under `--features wgpu`, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
The first half of the excitation stage, and the one part of the pitch estimator
with two genuinely different tensor formulations -- so it is where the
reference and optimized tiers diverge. Every other stage has a single natural
translation, which is why the tier distinction lives here rather than as a
discriminant on the whole pipeline.

`PitchAntiAliasConfig` is a `#[derive(Config)]` enum, following
`StftWindowConfig`'s idiom of an enum config dispatching to concrete
formulations:

* `Recurrence` -- the exact five-section IIR, transcribed literally. Batched
  across streams but not across samples, so `5 x samples` dependent steps.
  Written for legibility rather than speed: it exists to answer the one
  question the optimized tier cannot, namely whether the *translation* is
  right. Comparing only against the host would conflate a translation error
  with the deliberate approximation.
* `TruncatedFir { taps }` -- the same LTI system as a truncated impulse
  response with decimation folded into the kernel, evaluated as one GEMM. The
  default, at 2048 taps.

The response is generated by driving the exact host `BiquadCascade` with a unit
impulse, so truncation is the *only* difference between the tiers. And it is
not a speed-for-accuracy trade: measured against an f64 ground truth the
truncated FIR is 25-50% *more* accurate than the host's own f32 DF-II cascade,
because it is a better-conditioned realization of the same filter rather than
an approximation of it.

A GEMM rather than a `conv1d` because `burn-flex`'s depthwise path fires at
`channels == groups == 1` and parallelizes over `batch x channels` -- which is
then one task, so a 2048-tap convolution would run single-threaded.

**Two geometry findings.**

The carry is `taps - 1`, not `window - hop`. Those differ by the decimation
factor; using the latter would both drop the three oldest taps and desync the
carry from the Toeplitz offset. Both now derive from one constant, and a test
pins the distinction.

More seriously: **`unfold` derives its batch-row stride from the span the
windows cover, `(steps-1)*step + size`, rather than from the row's real
length.** When a row is longer than that span -- as it is here, by three
samples -- every row after the first is placed early, silently. Row 0 came out
bit-exact while row 1 was garbage, which is exactly the shape of bug that
survives a batch-1 test suite. `run_fir` trims to the covered span before
unfolding; the trimmed tail is still carried forward.
`test_unfold_row_stride_assumption` pins the underlying behaviour so that a
future burn fixing it fails loudly rather than leaving dead defensive code, and
`test_batch_row_offset_regression` pins the symptom at the real geometry.

Cross-tests: each tier against the exact host cascade, the two tiers against
each other, streaming against a single call for both, and batch rows against
solo runs.

Tests: 163 across `silero_vad` and `ten_vad` under `--features wgpu`, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…nd to end

Finishes stages 2 and 4, so all four now run as tensor ops, and adds the
strongest golden in the tree.

**Stage 2, excitation.** Raw hops plus a per-hop whitening filter to the
decimated excitation history. Both FIRs are shift-and-accumulate rather than
convolutions: the whitening taps change every hop, which rules out `conv1d`,
and the window-times-taps form would materialize a `rows x 256 x 17`
intermediate. Accumulating one tap at a time also keeps the reference's
operation order. The raw FIFO subsumes the reference's `pitch_mem` outright --
whitening is a pure order-16 FIR over the aligned input, not a recurrence, so
the samples it needs are already sixteen positions behind the aligned window.

**Stage 4, tracking.** The Viterbi pass is the one genuinely sequential stage:
the accumulator carries across hops and is renormalized by subtracting its
running maximum, so it can be neither cleared nor reassociated. The backtrace,
though, is *not* sequential across hops -- keeping the slot histories flat and
non-circular turns it into six strided gathers instead of `6 x steps` scalar
ones, and deletes the reference's circular-buffer bookkeeping. The transition
is a dense penalty matrix because the reference's candidate window is
asymmetric and unbounded below (`min(0, 4 - idx)`), running 5 wide at one end
and 52 at the other; almost certainly a reference bug, but load-bearing for
parity.

**A second burn stride bug.** `gather` ignores strides on a non-contiguous
*index* tensor: given a transposed index it reads element 0 of each row rather
than the indexed element. The backtrace cursor was built with `cat` +
`swap_dims`, so hop 0 came out correct and every later hop walked into
forbidden states -- the same "first row fine, rest garbage" signature as the
`unfold` row-stride bug found earlier. Built with `stack` instead, which is
contiguous. `test_gather_needs_a_contiguous_index` pins the underlying
behaviour and fails loudly if burn fixes it.

Worth recording how it was found: the forward pass wrote correct backpointers
and the slot history read back correct, yet the gather returned wrong values --
so each primitive was probed in isolation (`unfold`+`reshape` across batch,
strided slicing on a middle dim, slice-then-`swap_dims`, `gather` on a swapped
strided view) until the one untested combination was left, the index tensor
itself.

**The end-to-end golden.** `testdata/ten/probs.json` holds one speech
probability per hop, produced by driving the shipped `libten_vad.so` through
the reference Python binding. Nothing is shared with bunsen except the audio:
the reference's own front end and inference engine against ours. Measured over
400 hops: mean probability error 3.2e-5, worst 2.8e-4, and the speech/no-speech
decision agrees on **every** hop. Bounds are set an order of magnitude above
the measurement.

The test is capped at 400 hops because `context_forward_sequence` runs the
model once per hop on device; the full 3750 runs for over a quarter of an hour.
The golden file holds all of them, so the cap is a dial.

`testdata/ten/README.md` documents both goldens and their recipes, including
that the prebuilt `.so` needs LLVM's libc++ -- obtainable without root via
`apt-get download` + `dpkg -x`, and needing `libunwind-18` rather than Ubuntu's
`libunwind8`, since `libc++abi` wants `libunwind.so.1`.

Tests: 179 across `silero_vad` and `ten_vad` under `--features wgpu`, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…evice estimator

The four tensor stages landed one at a time, each pinned against the host
oracle but reachable only from its own tests. This wires them into a source
and puts the choice in the config.

`tensor/source.rs` composes them:

* `TensorPitchConfig` / `TensorPitch<B>` / `TensorPitchContext<B>`, following
  the config -> init -> stateful-context shape the rest of the front end uses.
* `forward_sequence` runs stages 1 and 3 across the whole run in one pass and
  threads a carry through 2 and 4; `forward` is the single-hop case of the
  same code, so there is one implementation to be wrong.
* `with_anti_alias` / `reference` builders select the tier.

`TenVadPitchSourceConfig` is the seam the level above sees -- a `Config` enum
over `Zero`, `Host` and `Tensor(TensorPitchConfig)`, matched by
`TenVadPitchSourceKind<B>` dispatching `TenVadPitchSource<B>` on itself. The
associated-type contract cannot express one config yielding three source
types, so the enum source is what makes the enum config possible. Call sites
that stay monomorphic keep static dispatch; only config-driven ones pay the
match.

`TenVadContextConfig` gains `pitch`, defaulting to `Tensor` with the folded
FIR anti-alias -- so `init_context` now returns a fully device-resident front
end, and nothing in the pitch path synchronizes.

Cross-testing, which is the point of keeping three implementations:

* `test_forward_sequence_matches_the_host_estimator` drives the whole device
  path and the whole host path over the same audio.
* `test_the_two_tiers_agree` puts `Recurrence` against `TruncatedFir`.
* the reference probability golden now runs through the device path by
  default: mean 3.157e-5, worst 2.834e-4, decisions agree on every frame.

Also, in `track.rs`, the Viterbi triple becomes a named `ViterbiCarry` rather
than a four-tuple threaded through the loop by position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
The C driver zeroes both LSTM states every `resetFrameNum = 1875` model calls
-- 30 s of audio -- and this driver did not. It was the last entry on the
context module's "Known deviations from the reference driver" list, and that
entry named the cost: byte-parity on clips longer than 30 s needs it. The
shipped golden is 3750 hops, exactly two periods, so it has been encoding a
reset this driver could not reproduce.

`TenVadContextConfig::reset_frames: Option<usize>` carries the policy,
defaulting to `RESET_FRAMES`; `None` disables it. `TenVadContext` carries the
policy and its counter, and gains `reset_states()` -- the reference's reset as
a callable primitive, useful on its own to callers segmenting a stream.

Three details are the reference's, and each is pinned by a test:

* Only the recurrence is zeroed (`src/aed.cc:155-158`). The feature stack, the
  STFT queue and the pre-emphasis carry keep running, so the frame after a
  reset still sees its predecessors. That is what separates `reset_states`
  from `reset`.
* The counter increments after the model call and fires on `>=`
  (`src/aed.cc:476-481`), so call 1875 runs with the states it inherited and
  call 1876 runs from zero.
* Both drivers tick through one helper, so `context_forward_sequence` stays
  exactly "iterating `context_forward`" -- a reset lands on the same hops
  whatever chunk boundaries the caller uses. The chunked test deliberately
  splits *on* a reset boundary, which is where a misplaced tick shows itself.

The strongest of those tests needs no golden at all: a periodic reset over
`2K` hops must equal driving `K` hops, `reset_states()`, `K` more,
`reset_states()` again -- mirrored block for block, terminal boundary
included, since a period of `K` over `2K` hops fires twice.

The reference zeroes lazily, at the top of its next call, via a `clear_hidden`
flag; this driver zeroes eagerly at the end of the current one. Those are
observationally identical for any continuation.

`test_reference_probability_golden` is split so the capped form stays in the
suite and a new `#[ignore]`d `..._full` runs all 3750 hops. That full run is
the only test that can show 1875 is the right period and that our phase
matches the reference's -- a reset on the wrong hop diverges immediately after
1876 -- but it takes over a quarter of an hour, which is the deferred per-hop
`context_forward_sequence` cost rather than anything this change adds.

The reference marks the constant `// TODO` (`src/aed.cc:640`), so this is
reproduced behavior, not an endorsement. It is on by default because parity
with the pretrained weights is what this kit is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
`context_forward_sequence` batched the entire front end and then stepped the
model once per hop, because `TenVad::forward` was contracted to a single frame.
The reference graph is not so limited: its leading axis is *time*, not
stream-batch (`ALGO_TRACE.md` §8.1), and feeding T stacked context frames as
one call is bit-identical to T sequential calls, final states included (§8.2).

Almost all of that was already true here and unreachable behind two details:

* `frame_features` reshaped `[1, -1, d_ctx, n_freq]`, putting the leading axis
  on the conv *channel* dimension. The reference uses `[-1, 1, d_ctx, n_freq]`;
  the two agree only at one step, and above it the stem would not typecheck
  against `cs1`'s single input channel. `TenVad::forward`'s own rustdoc named
  this as the blocker.
* Both LSTMs are already `batch_first(false)`, so `lstm_step`'s `[-1, 1, 80]`
  reshape *is* `new_shape__177` -- burn walks the recurrence internally. What
  pinned it to one step was a shape contract reading `[1, 1, ..]` as
  `[a, 1, ..]` where the general shape is `[1, steps, ..]`; the two are
  indistinguishable at one step, and the contract guessed wrong.

So `TenVad::forward_sequence` is mostly contract repair. `forward` becomes its
single-step case, which is what keeps them honest. `output_head` loses two
reshape round-trips that were no-ops.

`context_forward_sequence` now materializes every step's widened context with
one `unfold` over the history and hands whole runs to `forward_sequence`,
chunked at reset boundaries -- which is exactly §8.2's usage note for
reproducing the C driver's periodic reset, and is why `calls_until_state_reset`
exists.

Verified where it counts: `test_reference_probability_golden_full` now runs the
whole 3750-hop fixture -- mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973,
**decisions agree on every frame**, across the reset boundary at 1875.
`test_sequence_matches_stepwise` and its chunked and periodic-reset siblings
pin the batched path against the iterated one.

Also fixes the release build, which was broken independently of this: two uses
of `unpack_shape_contract` in `pre_emphasis` are `cfg`-gated but the import was
not, so a non-test optimized lib build tripped `-D warnings`.

The new `test_where_the_time_goes` reports what is left, and the answer was not
what I expected. Warm cost is linear and small -- 0.75 ms/hop for the model,
0.50 ms/hop for the device pitch estimator, flat from 400 to 1600 hops. Cold
cost is not: cubecl keys kernel selection on shape, and the pitch estimator's
cold cost grows roughly quadratically (4.75 s, 15.09 s, 66.61 s at 400, 800,
1600 hops, against warm times of 0.50 s, 1.00 s, 2.06 s). The model half tunes
almost for free. So the golden's ten minutes is one-time shape-keyed tuning,
not work -- the same 3750 hops cost about five seconds warm, and the fix is to
stop handing the pitch estimator a new shape per run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
`TenVad::forward_sequence` made the model half cheap and left the device pitch
estimator as the whole cost, so `test_where_the_time_goes` went looking for
why. The answer was not the arithmetic.

Warm cost was already linear and small -- about 0.50 ms/hop, flat from 400 to
1600 hops. Cold cost was not: 4.75 s, 15.09 s, 66.61 s at 400, 800 and 1600
hops, roughly quadratic. cubecl selects kernels per shape, and a run of `steps`
hops derives a shape from `steps` in every stage, so a caller that hands in a
different length each time re-tunes the entire estimator each time. The
3750-hop golden was ten minutes of kernel selection wrapped around five seconds
of work.

So `TensorPitchConfig::chunk_steps` pins the pass size, defaulting to 512 hops
-- also roughly where the anti-alias GEMM's materialized input stops being
cheap, which is why the number was already written down. `forward_sequence`
splits into fixed passes and `forward_chunk` is the old body; `None` restores
the single-pass form.

This cannot change the answer, and that is the point: the state carry it leans
on is the same one that already lets a caller split a stream across calls. Two
tests pin it -- chunked against unchunked, at a length that leaves a remainder
and at an exact multiple.

Measured, wgpu, one stream:

| pitch | hops | cold, unchunked | cold, chunked | warm |
|---|---|---|---|---|
| tensor | 400 | 4.75 s | 4.77 s | 0.51 s |
| tensor | 800 | 15.09 s | 15.92 s | 1.03 s |
| tensor | 1600 | 66.61 s | 12.74 s | 2.09 s |

The sweep shows the mechanism rather than just the improvement: chunked, 1600
hops costs *less* cold than 800, because by then the 512-hop shape is tuned and
only the 64-hop remainder is new. 400 is unchanged -- it fits in one pass.

End to end, `test_reference_probability_golden_full` went from 612 s to 19.6 s
in release, and from over an hour to 126 s in debug, with byte-identical
results: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing
on every one of 3750 frames.

It stays `#[ignore]`d. 126 s is still more than the suite should spend on one
case when the capped form covers the same path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
The golden was capped at 400 hops because the full 3750 took over an hour, and
was then split into a capped test plus an `#[ignore]`d full one. Neither is
needed now: `TenVad::forward_sequence` and `TensorPitchConfig::chunk_steps`
brought the full run to 20 s in release and 126 s in debug.

So the two collapse back into one test at full length. The capped form's only
stated justification was the cost of the full one, and keeping a strict subset
of another test in the same suite run buys nothing.

Length is the point. 3750 hops is exactly two `RESET_FRAMES` periods, so this
is the only test that can show the periodic LSTM reset fires on the same hop as
the reference's -- a reset on the wrong hop, or a missing one, diverges
immediately after 1875. Every other end-to-end check stops short of it.

In the suite: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973, decisions
agreeing on all 3750 frames. The kit's suite goes from 475 s to 578 s.

`test_where_the_time_goes` stays ignored; it is a measurement and asserts
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
The kit's numerical decisions were documented where they were made -- module
docs, test names, commit bodies -- which is right for the person editing that
file and useless for anyone asking "what are we still matching, and what does
it cost us?". This collects all thirteen in one place, with a block diagram of
the front end and the pitch estimator's four stages.

The organizing claim is that "matching the reference" is three unrelated
things, and conflating them is why the question is hard to answer:

* the **trained function** -- filterbank, normalization tables, weights. Not a
  numerics decision; a retraining project.
* the reference's **algorithm** -- the Viterbi window, the Levinson early exit,
  the 30 s reset. Changing these changes output; whether it changes it for the
  worse is a twenty-second experiment nobody has run.
* the reference's **arithmetic realization** -- how a filter is factored, how a
  sliding sum accumulates. The function is unchanged and only rounding differs.
  Every departure here so far has improved speed and accuracy at once.

Two findings the ledger makes concrete. The lag-energy sliding sum (D5) clamps
inside its recurrence against a quantity that, being a window sum of squares
minus one of its own members, can only go negative through f32 cancellation --
so the clamp guards rounding, and reproducing it costs a 126-step dependency
chain where a direct windowed reduction is depth one and strictly more
accurate. And the Viterbi candidate window (D4) is unbounded below, almost
certainly a `min` that should be a `max`, forcing a dense 56x56 transition
matrix where the intended one is a 56x9 band.

Figures measured by the kit's own tests are cited as such; figures reasoned
from the code are marked estimated, including both of the above.

Also published at:
https://claude.ai/code/artifact/1e40807e-4d10-4373-b58c-38f8c47c0382

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…of ten_vad

First step of extracting the reusable machinery from `ten_vad` ahead of
dropping that layer. These two pieces have nothing to do with voice activity
detection; they were only ever there because that is where they were needed.

`ops::signal::BiquadCascade` is the Direct-Form-II cascade, moved unchanged --
its own doc had already predicted the move. Its tests did not survive
unchanged, and that is the point: they used to assert agreement with a
transcribed coefficient table, so they tested "does this match that vendor's
filter" rather than "is this a biquad". They are now closed-form:

* a one-pole section's impulse response is exactly `r^n`
* `H(1)` computed analytically equals the summed impulse response, which
  catches a sign-convention slip on `a` immediately
* a two-pole section is checked against its own difference equation in f64
* block-splitting is unobservable, `reset` rewinds, sections apply in series

That is a better anchor than the table was, and it depends on nothing external.

Two additions while it was in hand: `dc_gain`, which is a cheap sanity check on
any coefficient table, and `to_vec_impulse_response`, which the decimating-FIR
work already needed and open-coded.

`burner::tensor::burn_behavior` is a new test-only module holding pins on
upstream burn behavior that bunsen works around -- currently `unfold` deriving
its batch-row stride from the covered span rather than the true row length, and
`gather` ignoring strides on a non-contiguous index tensor. Both were found the
hard way earlier in this work, and both were pinned inside the pitch stages
where they were hit.

They are written to fail *when the bug is fixed*, and each says which
workaround has become redundant, so the upgrade that fixes them also tells you
what to simplify. A workaround with no test is indistinguishable from an
accident; the next reader cannot tell load-bearing from leftover, and deletes
it. `test_batch_row_offset_regression` stays with the anti-alias filter, since
it guards that consumer rather than the upstream behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…_vad

Second extraction step. Neither piece is voice-activity detection: an all-pole
spectral fit is ordinary linear prediction, and both halves of it were sitting
in the pitch branch because that is where they were first needed.

`ops::signal::Autocorrelator` moves unchanged. Its two baked-in conventions are
now stated rather than implied -- results are unnormalized by `N/2`, and the
Nyquist bin is excluded -- because neither is the only reasonable choice and a
caller outside an LPC fit needs to know. `scale()` and `n_bins()` were added so
that caller does not have to rederive them.

`ops::signal::levinson_durbin` generalizes `celt_lpc`: slices instead of fixed
arrays, and the early bail-out becomes `Option<f32>`. `None` is the textbook
recursion, which is what a new caller wants; `Some(ratio)` reproduces a
reference that stops early, which is a strictly weirder requirement and now
reads as one. `celt_lpc` stays in ten_vad as a thin wrapper pinning CELT's
`0.001`.

The tests are the actual work. The old ones compared against a realistic
envelope and a direct DFT -- fine, but they establish agreement with one case
rather than the transform's identity. The new ones are closed-form:

* a spectral delta at bin `k` must autocorrelate to `cos(2*pi*k*l/N)` exactly,
  which with the linearity test that follows it covers every spectrum, since
  deltas are a complete basis
* the DC bin produces a constant, carrying the documented `0.5` weight
* Nyquist energy produces exactly zero, pinning the exclusion
* an AR(1) process recovers `[-a, 0, ...]`, an AR(2) recovers its own
  coefficients through Yule-Walker in reverse
* the bail-out leaves exact zeros rather than partial values, and truncates
  without otherwise changing the coefficients it did compute

One doc claim did not survive contact: `celt_lpc` said scale invariance leaves
the result "unchanged", and asserting that bit-exactly fails. The invariance is
mathematical -- scaling the input rounds it, and the recursion then rounds
differently. The claim is now stated with that qualification and tested to a
peak-relative tolerance.

Verified against the golden while it still exists, which is the point of doing
extraction before deletion: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973,
decisions agreeing on all 3750 frames -- identical to the pre-extraction run,
so the delegation is byte-for-byte the old behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Third extraction, and the one most worth having: `ops::signal::DecimatingFir`
is a general answer to "run an IIR cascade and a downsample on a device". It
filters and decimates in one contraction, folding the decimation into the
kernel so the discarded samples are never computed.

Nothing about it is voice-activity detection. The impulse response is an
argument, not a constant -- a caller supplies one from
`BiquadCascade::to_vec_impulse_response`, a windowed-sinc design, or a file --
and the decimation factor is config rather than a hardcoded 4. ten_vad's
`PitchAntiAliasConfig::TruncatedFir` now holds one of these and delegates.

The reasoning that made this worth doing generalizes too, so it moved with the
code: a truncated FIR is a better-conditioned realization of the same LTI
system rather than an approximation of it, which is why it measured 25-50%
*more* accurate than the f32 Direct-Form-II cascade it replaced; and why
neither `conv1d` nor a log-depth scan is the right shape here.

Its tests no longer mention biquads at all. The anchor is now
`test_matches_direct_convolution`: an arbitrary response against
`y[m] = sum(h[k] * u[decim*m - k])` computed on the host. That tests the
definition rather than agreement with one filter. Around it, a unit kernel must
select exactly every Nth sample -- which catches a phase error immediately, and
was previously only implied -- decimation of 1 must be plain convolution, and
the carry must make block boundaries unobservable.

`test_batch_rows_are_independent` doubles as the regression for burn's `unfold`
row-stride bug, now at general geometry rather than only ten-vad's.
ten_vad keeps its own version: that one guards the consumer at the real
geometry, which is a different job from guarding the op.

Golden unchanged again -- mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973,
decisions agreeing on all 3750 frames -- so the delegation preserved behavior
exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
New module `ops::seq`, for algorithms that find or score a path through a
sequence, as opposed to the per-sample filtering in `ops::signal`. Viterbi is
the first occupant; CTC decode and DTW belong here too.

Unlike the three preceding commits this is a **rewrite, not a lift**, and the
distinction is worth stating. ten_vad's period tracker was the motivation and
the source of the technique, but it is not a general Viterbi: its backtrace is
fused with a weighted least-squares fit over recovered pitch periods, it steps
two half-hop slots per hop, and it carries a fallback that reflects a quirk in
its own reference rather than anything a decoder generally needs. Extracting
that shape would have exported the quirks. So the general op is written from
the recursion, and the pitch tracker keeps its own until the estimator is
rebuilt reference-free -- at which point it can adopt this and drop them.

What did carry across is the part worth having. The forward pass is genuinely
sequential, but **the backtrace is not**: at lookback `k` every timestep reads
the same relative position in the backpointer history, so one slice serves them
all and the whole backtrace costs `depth` batched gathers instead of
`steps * depth` sequential hops. Over a few thousand steps that is the
difference between the backtrace dominating and the backtrace being free.

Two design decisions are documented rather than implied. Scores are
renormalized to peak zero every step, because path scores otherwise grow
without bound and f32 loses the differences that decide the path. And
forbidden transitions take a large finite negative (`FORBIDDEN`) rather than
`-inf`, because `-inf` propagates and then produces NaN under exactly that
renormalizing subtraction.

Anchored against a scalar Viterbi written out from the recursion, on both
deterministic and random emissions -- fixtures alone could flatter a shared
misreading of the recursion; random data cannot. Around that: the peak really
is zero, streaming matches a single call for scores *and* backpointers, batch
rows are independent, forbidden edges are never taken, and the batched
backtrace agrees with following one pointer at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Completes `ops::signal::lpc` as one unit: the scalar recursion and the device
form that fits many spectra at once. Any LPC-based speech model wants the
second -- spectral envelopes, formant tracking, vocoder analysis -- and it is
the half that is awkward to write, so it is the half worth having in a shared
place.

`levinson_durbin_batched` generalizes the ten_vad stage: order comes from the
input rather than a constant, and the bail-out is the same `Option<f32>` the
scalar form takes.

The interesting part is why a batched port is not a transcription, and that is
now documented on the function rather than lost in a commit:

* **Commit, then freeze.** The scalar form checks after an iteration
  completes, so iteration `i` always commits and only `i+1..` are skipped. Move
  the mask before the update and the bail-out shifts by one step.
* **No sticky bit.** A frozen row's error stays below the threshold, so
  re-deriving the mask each step is already monotone.
* **The frozen tail is the zero initializer.** Coefficient `k` is only written
  at step `k`, so a row that stopped early leaves exact zeros -- which is what
  makes masking sufficient rather than merely convenient.

Frozen rows divide by 1 rather than a stale error, so nothing ever produces an
inf to be masked away afterwards. One generalization the ten_vad version did
not need: it relied on its noise floor guaranteeing `ac[0] > 0`, which a
general caller cannot promise, so non-positive rows are now frozen from the
start and come back zeroed like the scalar form's guard.

Tested differentially against the scalar solver -- which is itself pinned by
the closed-form AR tests, so agreement reaches back to the recursion rather
than to another implementation -- across all three bail settings, on lag
sequences built by autocorrelating random spectra so they are sequences a real
fit could actually produce. Plus closed-form AR(1) recovery per row, a dead row
that must not poison its neighbours, and rows that demonstrably bail at
different steps, which is the only thing that really exercises the mask.

ten_vad's prefilter now delegates. Golden unchanged: mean |diff| 5.649e-5,
worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
The piece the next speech model needs regardless of what happens to ten_vad.
Until now bunsen had exactly one filterbank, welded to ten-vad's geometry;
`ops::signal::TriangularBank` is the construction every mel, bark and ERB bank
shares.

The split is the design: `TriangularBankConfig` builds weights from edges you
supply and knows nothing about frequency, while `mel_bin_edges` and `MelScale`
produce mel-spaced edges to hand it. A bank on any warped scale is the same
triangles over different edges, so only the edge computation has to change --
including, as it turns out, ten-vad's.

Edges are fractional bin coordinates rather than integers, so a boundary
falling between bins gets slopes that reflect where it landed. Integer edges
still work and reproduce an integer-rounded bank exactly; that is a property of
the input rather than a limit of the construction, and it is what lets ten_vad
delegate.

`MelScale` carries both warpings, and they are not interchangeable: HTK is
log-warped everywhere, Slaney is linear below 1 kHz, and they disagree by tens
of mel across the speech band. A model trained against one will not accept the
other's filterbank, so both are spelled out with that stated rather than a
single "mel" that silently picks a side. `BankNorm` likewise makes peak-versus-
area an explicit choice instead of an omission.

The load-bearing test is closed-form: where two adjacent bands overlap their
weights must sum to exactly one, which pins both slopes and their alignment
together. Around it, both scales round-trip, HTK is calibrated so 1 kHz lands
near 1000 mel, Slaney really is linear below its breakpoint -- and the two
scales are asserted to *disagree*, without which every other scale test would
pass against a single implementation.

ten_vad's mel bank now delegates its triangles and keeps only its edges, which
sharpens what its deviation actually is. Its module docs said three details
were load-bearing; one of them, integer slopes, turns out to be a consequence
of the integer edges rather than a separate quirk, and the docs now say so.
All ten of its mel tests pass unchanged, including the exact-coefficient one,
and the golden is unchanged: mean |diff| 5.649e-5, worst 6.446e-4 at hop 2973,
decisions agreeing on all 3750 frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
It documented which of ten-vad's numerical decisions bunsen reproduces and what
switching would cost. That question is being answered by deleting the layer, so
the document has outlived the decision it was written to inform.

It was also going stale as the extraction proceeded: D1's deviation turned out
to be narrower than described -- integer slopes are a consequence of the
integer edges, not a separate quirk -- and D4 and D5 are about to be resolved
rather than reproduced.

The reasoning worth keeping has moved to where it applies. `ops::signal`
carries the truncated-FIR-versus-recurrence argument, the batched-freeze recipe
for porting an early-exit recursion, and the mel-scale and normalization
choices; `ops::seq` carries the renormalization and forbidden-transition
rationale; `burner::tensor::burn_behavior` carries the upstream bugs. Those are
attached to code that will outlive ten_vad.

A published copy remains at
https://claude.ai/code/artifact/1e40807e-4d10-4373-b58c-38f8c47c0382
if the history is wanted later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Completes `ops::signal::lpc` as a chain that stands on its own: autocorrelate a
spectrum, solve for coefficients, then filter with them.
`lpc_residual_batched` is the third link, and it uses the same convention
`levinson_durbin` emits, so its output feeds straight in.

The generalization that matters is per-row taps. Speech coefficients are
refitted every frame, so a run of frames is a run of *different* filters --
which is exactly what `conv1d` cannot express, since its kernel is shared
across the batch. That constraint, not performance, is why this is written as
shift-and-accumulate; the performance argument (an `order`-wide window stack
would materialize `rows * out_len * order`) is the secondary one, and both are
now on the function.

The accumulation order is documented rather than incidental: base sample first,
then lag 0 upward. Summation order is observable in f32, so pinning it is what
lets a host implementation agree exactly rather than approximately.

Also documented is what this deliberately is not. Synthesis --
`x[n] = y[n] - sum(taps[j] * x[n-1-j])` -- is a recurrence in its own output
and needs a sequential scan, so a caller reaching for the inverse does not find
a function here that quietly computes the wrong thing.

The anchor is closed-form: drive an AR(1) process with a known excitation,
whiten it with the matching tap, and the excitation comes back out. Around it,
zero taps are the identity, and a scalar difference equation with a different
tap set per row -- the case `conv1d` cannot do at all -- matches term for term.

ten_vad's whitening stage now delegates. Golden unchanged: mean |diff|
5.649e-5, worst 6.446e-4 at hop 2973, decisions agreeing on all 3750 frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…lure

`ops::signal::LagSearch` scores a reference window against every lag behind it
-- the basis of time-domain pitch detection, and equally of delay estimation,
echo alignment and onset matching.

Like the Viterbi this is a rewrite rather than a lift: ten_vad's stage fuses
the search with pitch-specific octave suppression, and its energy floor is an
absolute constant calibrated to int16-scale signals. Extracting the shape would
have carried both. The general op takes the floor as config and says plainly
that it is absolute, so a caller working in [-1, 1] knows the number means
nothing until they rescale it.

Two things are documented rather than assumed. The normalization
`2<x,y>/(|x|^2 + |y|^2)` is bounded above by 1 and reaches it exactly on a
repeat, by AM-GM -- so a score reads directly as "how close to a repeat is
this" and a threshold means the same thing at any amplitude. And it is *not*
the Pearson form: the geometric mean is indifferent to unequal energies, this
penalizes them, which for periodicity is the behavior you want.

The substantive change is that lag energies are computed directly rather than
slid. A sliding sum is O(max_lag + window) against O(max_lag * window) and is
still the wrong choice: it serializes the whole lag range into a dependency
chain where the direct form is one parallel reduction, and it accumulates
rounding monotonically with no way to recover -- which in near-silence drives
it negative, which is why implementations that slide invariably end up
clamping. `test_direct_energy_beats_a_sliding_sum` measures both against an f64
truth on a loud-then-quiet signal rather than asserting the claim.

Anchored closed-form: an exact repeat scores exactly 1, no score exceeds 1 on
structureless data, the peak lands a whole number of periods from the
reference, and a direct host computation matches term for term.

Along the way this turned up a third burn behavior worth pinning. `matmul`
fails outright -- every autotune candidate returns `InvalidSamples` and the
tuner panics rather than falling back -- when its left operand is an `unfold`
view and its right is a column. Narrowed by elimination: the same shapes with a
contiguous left operand succeed on both random and constant data, so the
trigger is the non-contiguous operand, not the mat-vec shape. Worth stating
because the autotune key reports `matrix_layout_lhs: Contiguous` for exactly
the view that fails. Pinned with a control test, since without one the failure
reads as "mat-vec is broken", which is both wrong and more alarming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
The pin was right that something was wrong and wrong about what. It said
`unfold` "derives its batch-row stride from the covered span", which blamed the
wrong code: `burn-cubecl`'s `unfold` sets `strides[dim] = step * old`, appends
`old`, and leaves every other stride alone -- exactly PyTorch's view semantics,
and correct.

The fault is in the read. When `size` and `step` share a factor of two the
access vectorizes, and the outer stride is truncated to a multiple of the line
width -- `(len / v) * v` rather than `len` -- so every row after the first
starts `len % v` elements early. Row 0 is always correct, which is why a
batch-1 test passes and the corruption only appears once a second row exists.

Established by elimination rather than inspection, since the two candidate
rules ("uses the covered span" and "rounds the row down to a line") predict the
same offset in almost every configuration. They differ at size=2 step=4 len=9,
which predicts row 1 starting at flat 6 or 8 respectively; it starts at 8.

Swept 315 configurations: 42 wrong, all with `size` and `step` both even,
failing exactly when `tail % v != 0`. Zero wrong when either is odd, i.e. when
the scalar path runs. `Flex` is correct throughout, so this is CubeCL-specific.

The tests are now minimal and use `arange`, so a displaced row reads as an
off-by-one run rather than as arbitrary values, with two controls that isolate
the cause: an odd `step` (v = 1, same tail, correct) and no tail (same
vectorization, correct). The second control also shows why trimming to the
covered span is a *sufficient* workaround and not merely a different shape --
the line width divides both `size` and `step`, so it divides the covered span
and the truncation becomes a no-op.

`DecimatingFir` and `LagSearch` keep the same trim; only the comments
explaining it change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Moves the standalone reproduction into the crate as `bunsen::burner::repro`,
a home for backend-generic harnesses for defects in `burn` itself. Each is
written to be lifted out and taken upstream unchanged, so they use only burn's
public tensor surface.

`repro` and `tensor::burn_behavior` assert deliberately opposite things:

| | asserts | fails when |
|---|---|---|
| `repro` | the correct semantics | the bug is present |
| `burn_behavior` | the current behavior | the bug is fixed |

The pins keep bunsen's suite green while guaranteeing a fix announces itself
and names the workarounds it makes redundant. The reproductions are the bug
report: run one against a candidate fix and a pass means it works. So these
fail on affected backends by design, which is why the tests driving them are
`#[ignore]`d rather than wired into the suite.

`repro::unfold` carries the minimal `arange` case, the two controls that
isolate the cause, a discriminator between the two candidate rules, and a
sweep that returns the failing set rather than printing it, so it can be run
against a fix and watched shrink to empty.

One test is *not* ignored: `test_cpu_backend_is_correct` runs the whole
reproduction against `Flex`, where it passes. That documents the defect as
backend-specific rather than universal, and would catch a regression that made
the CPU path match the broken one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
…nergy

Runs the experiment the fidelity ledger proposed and never performed: D5
argued the reference's clamped sliding sum should be replaced by a direct
reduction, and predicted the ~1e-6 perturbation would be absorbed. It is
better than that.

The geometry lined up exactly -- lags running backwards from a reference
window at the end of the buffer, `2*dot / (lagged + reference + 1)`, with the
reference's `1 +` guard as the energy floor -- so the stage delegates rather
than reimplements. Octave suppression stays here; it is pitch-specific.

Two deliberate departures come with it. The reference maintains the lagged
energy as a sliding sum with a `max(..., 0)` inside the recurrence, which
serializes the lag range into a 126-step dependency chain and accumulates
rounding with no way to recover -- the clamp exists precisely because that
error can drive the sum negative. `LagSearch` reduces each window directly:
parallel, and strictly more accurate. It also sums the correlation in a
different order than the reference's shift-and-accumulate.

Both sit upstream of an `argmax`, so neither is free by inspection. The result:

* The 3750-hop probability golden is **bit-identical** -- same mean |diff|
  5.649e-5, same worst 6.446e-4 at the same hop 2973, same 0.37836984. Not
  merely within tolerance; unchanged.
* The kit's suite drops from ~575 s to 434 s.

Bit-identical is the interesting part, and it is not luck. The tracker's output
passes through an argmax over 56 states, so a perturbation that does not flip
one produces exactly the same period, hence exactly the same feature 40, hence
exactly the same probability. Across 3750 hops and 7500 Viterbi steps, not one
flipped. That is the same argument the ledger used to explain why small errors
are dangerous here -- discrete decisions have no small errors -- read in the
other direction: below the flip threshold, they have no errors at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
The last extracted op to be put under golden verification. The stage keeps its
transition table -- transposed and negated on the way in, since
`to_vec_penalty` is a cost indexed `[arrival][source]` and the decoder wants a
score indexed `[source][arrival]` -- and hands the decode itself to the shared
implementation.

That drops the reference's floor-and-fallback: it seeds each search at
`path_best - 1e10` and keeps the previous best period when nothing beats it.
The fallback is unreachable. Under the reference's own candidate window,
`cand = min(idx, 4)` is valid for every `idx`, so no state is ever without a
predecessor, and renormalized scores never approach -1e10. Argued that way it
is only an argument; the golden makes it a measurement -- with the floor gone
the 3750-hop trace is bit-identical, so across 7500 Viterbi steps it never
fired.

`PitchTrackState` loses `path_best` and `best_period` with it. They existed
solely to feed the fallback, so the carried state is now the accumulator and
the three ring buffers.

Every general op is now exercised against the reference golden: the biquad and
decimating FIR through the anti-alias tier, autocorrelation and both LPC forms
through the prefilter and excitation, the filterbank through the mel path, the
lag search through correlate, and now the Viterbi through track.

This also sets up the D4 experiment properly. The asymmetric window is now
confined to one table rather than woven through a bespoke step, so correcting
it to the symmetric form the reference presumably intended is a change to
`to_vec_penalty` alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
D4 in the old fidelity ledger predicted the reference's asymmetric candidate
window was "probably output-neutral on ordinary speech". Now that the decode
runs through `ops::seq::Viterbi` the window lives in one table, so the
experiment is a one-line change and worth actually running.

The prediction was half wrong. Replacing the window with the symmetric
`cand in [idx - 4, idx + 4]` moves the trace materially against the 3750-hop
golden -- mean |diff| 5.6e-5 -> 1.9e-4, worst 6.4e-4 -> 1.8e-2, which is past
the test's own bound. So `argmax` flips do occur and the bug is load-bearing
for numeric parity.

It is not load-bearing for behavior: voicing decisions still agree with the
reference on every one of the 3750 frames. A port needing parity keeps the
window; one wanting the intended algorithm corrects it and loses nothing a VAD
consumer can observe.

ten_vad keeps the reference window, since parity is what it is for. The
measurement is recorded on the stage so the choice is informed rather than
inherited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Moves the estimator out from under `ten_vad` and cuts its dependency on that
kit entirely, so it survives when ten_vad is deleted. `grep ten_vad` inside
`kits::speech::pitch` now returns nothing but two doc cross-references.

Mechanically:

* the whole `context/pitch` tree relocates, history intact;
* `TenVadPitch*` become `Pitch*`, and `TenVadPitchEstimator` becomes
  `HostPitchEstimator`, which is what it always was -- the scalar oracle every
  device stage is validated against;
* `SAMPLE_RATE` and `HOP_SIZE` move into the estimator's own `coeff`, since
  they are the geometry it is defined for rather than the feature path's;
* `ZeroPitch::normalized_feature` goes the other way. Pinning feature `40` to
  `(0 - mean) / (std + eps)` is the *feature path's* business, not the
  estimator's, so it becomes a test helper in `ten_vad::context::features`
  where those tables live.

ten_vad's context no longer glob-re-exports pitch, which is what surfaced the
last of the coupling: several of its doc links were resolving only through that
re-export, and now name the real paths.

The module docs are rewritten for the new home. They lead with what the thing
*is* -- four stages, host and device, cross-tested -- rather than with which
feature index of which model consumes it, and they state the LPCNet lineage and
what that implies: the band tables are reference data, and a consumer whose
model was fitted against them cannot change them without refitting.

Behavior is untouched: 188 speech tests pass and the golden is unchanged at
mean |diff| 5.649e-5, worst 6.446e-4, decisions agreeing on all 3750 frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
`HostPitchEstimator` is pinned by `testdata/ten/pitch.json`, which is ten-vad
data and goes when that kit does. Two behaviours were covered only there, and
both are worth having independently.

**Octave robustness.** A pulse train with alternate pulses attenuated
correlates strongly at twice its period, so a tracker without octave
suppression reports f0/2. That is the entire reason `suppress_octaves` exists
and it had no behavioural test -- only a static check that its neighbour reads
are write-safe. Verified by disabling the suppression, which makes the new test
report 75.47 Hz against a 150 Hz fixture: precisely the octave error, so the
test fails for the intended reason rather than by coincidence.

**Aperiodic rejection.** Noise has no period to find. The estimator may call
some frames voiced -- noise does correlate by chance -- so the test compares
against a tonal fixture rather than demanding silence: either most noise frames
are rejected, or what survives is far less consistent than a real period.

Neither replaces what the golden did, which was agree with an independent
implementation on real speech. What remains after deletion is a three-layer
argument instead: closed-form tests on the shared ops underneath, these
behavioural anchors on the host oracle, and the host-versus-device cross tests
that pin the port to the oracle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
crutcher and others added 2 commits August 24, 2026 17:29
Drops the model, its driver, its weights and its reference goldens. The
extraction is complete: everything general has a home elsewhere, and what
remains here would have been the ten-vad-shaped parts alone.

Gone: the network and pretrained weights, the 41-dim feature path, the
non-standard mel bank, `coeff.h`'s transcribed normalization tables, the
driver, the cross tests, and `testdata/ten` -- both goldens plus the C and
Python harnesses that produced them.

Also gone: `pitch::tensor::hybrid`, the staged-migration harness that ran stage
1 on the device and the rest on the host so a single stage could be measured
against the golden end to end. Its own doc said it comes out when the last
stage lands. The last stage landed, and the golden it drove is gone.

What survives, and where:

* `ops::signal` -- biquad cascade, decimating FIR, autocorrelation, Levinson
  in scalar and batched forms, the LPC analysis filter, triangular filterbanks
  with mel scales, and the normalized lag search.
* `ops::seq` -- the batched Viterbi decoder.
* `burner::tensor::burn_behavior` and `burner::repro` -- three pinned upstream
  burn defects and a standalone reproduction of one.
* `kits::speech::pitch` -- the assembled estimator, host and device, built on
  the above and cross-tested.

The verification story changes shape and is worth being explicit about. The
goldens compared bunsen against an independent implementation on real speech;
nothing replaces that. What stands in its place is three layers that were built
while the goldens were still there to check them: closed-form tests on the
shared ops, behavioural anchors on the host oracle, and host-versus-device
cross tests pinning the port to that oracle.

456 tests pass across the crate.

BREAKING CHANGE: `bunsen::kits::speech::ten_vad` is removed. The pitch
estimator it contained is now `bunsen::kits::speech::pitch`, with
`TenVadPitch*` renamed to `Pitch*` and `TenVadPitchEstimator` to
`HostPitchEstimator`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Measured coverage rather than guessing at gaps, and the shape of the result was
not what I expected. The stage internals were already at 97-99%; the hole was
`source.rs` at 74.6% regions and 70% functions -- the module's *public entry
point*. `PitchSourceConfig` and `PitchSourceKind` had no tests at all. They had
only ever been exercised through ten_vad's driver, so deleting that kit left
the seam a caller actually uses as the least-tested code in the module.

Nine tests for it, each running the same assertion across all four selectable
variants, because interchangeability is the entire point of the seam: every
kind reports a finite non-negative plausible pitch; `forward` is the one-step
case of `forward_sequence`; state carries and `reset` rewinds it; batch rows
are independent; the config round-trips through serde; and `try_init_source`
reports an invalid tensor geometry.

One of those needed a guard of its own. The reset test proves nothing against a
source with no state to rewind, so a companion test asserts that the two
stateful kinds really do give a different answer with history than without --
otherwise `reset` would pass vacuously on all three.

Two validate branches turned out to be unreachable rather than untested.
`PitchCorrelate`'s period-range check compares two *constants* (64 against 16),
and `PitchExcitation`'s history check reduces to `65 <= 0`. Neither depends on
anything a caller can vary, so both become `const _: () = assert!(...)`, the
idiom already used for `SHARPEN_IS_WRITE_SAFE`. That moves the invariant to
compile time and removes a runtime branch that could never fire -- and an
unfireable branch is indistinguishable from a bug in waiting.

The rest is accessors and `Default` impls, which are worth covering because an
accessor forwarding to the wrong sub-config field is a real bug class that
nothing else would notice. `TensorPitch`'s test asserts `hop_size != n_bins`
for exactly that reason.

Coverage: source.rs 74.6% -> 98.6% regions, 70% -> 97% functions; lpc.rs and
estimator.rs both improved; every file now above 97%. 121 pitch tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyQ8pMaeFyG653cEvyz8mU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant