ten-vad driver WIP - #158
Draft
crutcher wants to merge 32 commits into
Draft
Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WIP on Ten-Vad Driver. Too much robot code here; needs cleanup/unification.