Goal: remove the panics and silent failures a real recording can hit, and put tests under the small pure functions that have none. Architecture: each task is one class of defect across the files that have it, fixed the same way everywhere, with a unit test that fails on the old code. Tech Stack: Rust 1.85 floor, no new crates.
Global Constraints
- A fix changes behaviour only on the failing input; every existing test still passes unchanged.
- No new
unwrap()on anything that comes from a file, a model, or the user. - No
.github/workflows/,.claude/, orloop/changes; no editing existing tests.
Task 1: NaN-safe argmax
Files:
- Modify:
src/features.rs(addpub fn argmax(xs: &[f32]) -> Option<usize>andpub fn argmin(xs: &[f32]) -> Option<usize>) - Modify:
src/asr.rs:269-273andsrc/asr.rs:278-282(token and duration argmax),src/diarize.rs:90-94(per-frame class argmax),src/vad.rs:248-251(quietest-frame argmin)
Interfaces:
-
Produces:
argmax/argminusingf32::total_cmp, returningNoneon empty input and the first index on ties; a NaN never panics and never wins over a finite value (filter NaN out before comparing). -
Step 1: write the failing tests in
src/features.rs’s test module:argmax(&[])isNone;argmax(&[1.0, 3.0, 3.0])isSome(1);argmax(&[f32::NAN, 2.0, 1.0])isSome(1);argmax(&[f32::NAN])isNone;argmin(&[2.0, f32::NAN, 1.0])isSome(2). -
Step 2: run
cargo test features::arg, expect FAIL “cannot find functionargmax”. -
Step 3: implement, then replace the four
max_by/min_by+partial_cmp().unwrap()sites. Inasr.rstakeargmax(...)then indexlogits[tok]for the winning value; aNonethere is ananyhowerror “empty logits” (the length and finiteness of the joiner output are validated once, in Task 6). Indiarize.rskeep theunwrap_or(0)fallback. Invad.rskeep theunwrap_or(target)fallback. -
Step 4: run
scripts/check, expectCHECK OK. -
Step 5: commit:
git add src/features.rs src/asr.rs src/diarize.rs src/vad.rs && git commit -m "argmax: total_cmp, no panic on NaN".
Task 2: Model-path errors that say what to do
Files:
- Modify:
src/session.rs:534(theno VAD model at …bail),src/session.rs:845(vad_path.to_str().unwrap()) - Modify:
src/session.rs(addpub fn utf8_path(p: &Path) -> Result<&str>next tomodels_root,src/session.rs:139)
Interfaces:
-
Produces: the VAD bail reads
no VAD model at <path> — run ./fetch-models.sh, matching the ASR bail five lines above it;utf8_patherrors withpath is not UTF-8: <display>and is used at:845and at the twobad model pathsites indiarize_session(src/session.rs:1194-1197). -
Step 1: write the failing test in
src/session.rs’s test module:utf8_path(Path::new("/a/b"))isOk("/a/b"); on unix,utf8_path(Path::new(std::ffi::OsStr::from_bytes(b"/a/\xff")))(viastd::os::unix::ffi::OsStrExt) is an error whose message contains “not UTF-8”. -
Step 2: run
cargo test session::utf8_path, expect FAIL “cannot find function”. -
Step 3: implement and replace the three sites; add the hint to the VAD bail.
-
Step 4: run
scripts/check, expectCHECK OK. -
Step 5: commit:
git add src/session.rs && git commit -m "session: model path errors name the fix".
Task 3: Tests under slugify, mmss and the status line
Files:
- Modify:
src/session.rs(test module only, plus any fix a test forces inslugify:178-191,mmss:1494-1497, orMeter::status_line:287-305)
Interfaces:
-
Consumes:
slugify(&str) -> String,mmss(u64) -> String,Meter::status_line(&self) -> String,MeterPhase(src/session.rs:220-228). -
Step 1: write the tests:
slugify("Hello, World!")is"hello-world";slugify(" --x-- ")is"x";slugify("")is"";slugify("Ünïcode ok")is"ncode-ok"(only ASCII alphanumerics survive; the test names that as the current rule);mmss(0)is"00:00";mmss(61_000)is"01:01";mmss(3_600_000)is"60:00";mmss(999)is"00:00". Forstatus_line: readMeterandstatus_linefirst, then construct aMeterin eachMeterPhaseand assert the exact string the current code produces, so the test pins the format the menu bar parses; every phase gets one assertion. -
Step 2: run
cargo test session::, expect the new tests to PASS against unchanged code, or FAIL with a message that names a real defect; a failing test here is a finding, fix the function and say so in the report. Do not weaken the test. -
Step 3: run
scripts/check, expectCHECK OK. -
Step 4: commit:
git add src/session.rs && git commit -m "session: pin slugify, mmss and status line".
Task 4: VAD hysteresis as a pure function with tests
Files:
- Modify:
src/vad.rs:177(fn segments_from(&self, …)becomesfn segments_from(probs: &[f32], n_samples: usize) -> Vec<Segment>, an associated function; update the call atsrc/vad.rs:173) - Modify:
src/vad.rs:186-194(a non-finite probability reads as0.0, silence) - Modify:
src/vad.rs(test module: new tests; create the module if absent)
Interfaces:
-
Consumes: the constants at
src/vad.rs:18-24(ON0.50,OFF0.35,MIN_SPEECH_MS250,MIN_SILENCE_MS400,PAD_MS200),SR16 000,FRAME512,Segment { start, end }in samples. -
Produces:
Vad::segments_fromcallable without a model; a NaN or infinite probability is treated as0.0; tests build a probability array with a helperfn probs(pattern: &[(f32, u32)]) -> Vec<f32>that emits, for each(value, ms),(ms * SR / 1000).div_ceil(FRAME)frames ofvalue, the ceiling the production frame grid uses. -
Step 1: write the failing tests: (a) all 0.0 for 2 s: no segments. (b) 1.0 for 1000 ms in the middle of 3 s of 0.0: one segment whose start is
PAD_MSbefore the speech and endPAD_MSafter, within one frame. (c) 1.0 for 100 ms: no segments (shorter thanMIN_SPEECH_MS). (d) 1.0 for 500 ms, 0.0 for 200 ms, 1.0 for 500 ms: one segment (gap underMIN_SILENCE_MS). (e) the same with a 600 ms gap: two segments. (f) exactlyMIN_SILENCE_MSof gap: assert whichever the code does today and name it in the test as the rule. (g) 0.45 for 1000 ms: no segments (never crossesON). (h) 1.0 for 500 ms then 0.40 for 1000 ms: the segment continues through the 0.40 run (aboveOFF) and ends at its end. (i) speech running to the last frame:end == n_samples, never past it. (j) 1.0 for 500 ms thenf32::NANfor 1000 ms then 0.0 for 1000 ms: the segment ends where the speech ended plusPAD_MS, not at the end of the recording. -
Step 2: run
cargo test vad::segments, expect FAIL (“cannot find” whilesegments_fromstill takes&self; (j) fails on the NaN policy; any other failure names a real defect, fixed in the function and reported). -
Step 3: make the function associated; map non-finite
pto0.0at the top of the loop; fix anything (i) exposed. -
Step 4: run
scripts/check, expectCHECK OK. -
Step 5: commit:
git add src/vad.rs && git commit -m "vad: hysteresis is a pure function with tests".
Task 5: Settings patch parsing pinned by tests
Files:
- Modify:
src/settings.rs(a#[cfg(test)] mod testsat the end;Patch:39-57andAssign:59-67stay private, the tests live in the same module)
Interfaces:
-
Consumes:
serde_json::from_str::<Patch>. -
Step 1: write the tests:
{}gives aPatchwith every fieldNone;{"threshold":0.6,"diarize":false}sets exactly those two;{"assign":{"label":"SPEAKER_00","name":"Ana","session":"2026-09-05-1200"}}sets all threeAssignfields;{"assign":{"label":"a"}}is an error mentioningname;{"threshold":"0.6"}is an error (a string is not a number);{"audio_retention":"forever"}givesSome("forever");{"unknown":1}parses (unknown keys are ignored today; the test names that as the rule so a change todeny_unknown_fieldsis deliberate). -
Step 2: run
cargo test settings::, expect PASS on unchanged code, or a FAIL that names a real defect to fix and report. -
Step 3: run
scripts/check, expectCHECK OK. -
Step 4: commit:
git add src/settings.rs && git commit -m "settings: pin the patch shape".
Task 6: Empty and damaged inputs to the recogniser
Files:
- Modify:
src/features.rs:91(log_melon empty input),src/asr.rs:66-72(tokens.txtread),src/asr.rs:265-284(joiner output validation before argmax) - Modify:
src/asr.rs(addpub fn check_joiner(logits: &[f32], n_tok: usize, n_dur: usize) -> anyhow::Result<()>andpub fn parse_tokens(text: &str, source: &str) -> anyhow::Result<Vec<String>>, the loop atsrc/asr.rs:69-72moved out ofRecognizer::load, which then calls it)
Interfaces:
-
Produces:
log_mel(&[])returns(Vec::new(), 0)andRecognizer::transcribeon empty samples returnsOkwith empty text (find the callers oflog_meland make each handle zero frames);parse_tokenserrors with<source> is emptywhen no token lines are read, soRecognizer::loadfails there instead oftokens.len() - 1underflowing;check_joinererrors withjoiner output has <n> values, expected at least <n_tok + n_dur>on a short output andjoiner output is not finitewhen any value is NaN or infinite, and is called once per decode step before the argmax and the softmax. -
Step 1: write the failing tests: in
src/features.rs,log_mel(&[])is(vec![], 0)(today it panics onsamples[0]); insrc/asr.rs’s test module,check_joiner(&[0.0; 5], 4, 2)errors with a message containing “expected at least 6”,check_joiner(&[0.0, f32::NAN, 0.0], 2, 1)errors with “not finite”,check_joiner(&[0.0; 6], 4, 2)isOk;parse_tokens("", "x/tokens.txt")errors with “x/tokens.txt is empty”,parse_tokens("\n\n", "x/tokens.txt")errors the same way, andparse_tokenson the first three lines of the realtokens.txtlayout (readsrc/asr.rs:69-72for the line format before writing this) yields three tokens. -
Step 2: run
cargo test features::log_mel, expect FAIL (a panic); runcargo test asr::, expect FAIL “cannot find function” forcheck_joinerandparse_tokens. -
Step 3: implement all three guards.
-
Step 4: run
scripts/check, expectCHECK OK. -
Step 5: commit:
git add src/features.rs src/asr.rs && git commit -m "asr: empty and damaged inputs fail with a message".