From 430eae142458b33d1bd4c0311774f756df912a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:46:07 +0000 Subject: [PATCH 01/44] Make three over-claiming trace tags true (issues #64, #71) FR-CHAIN-030's tagged test compared four samples at latency 0 and never performed the null test its Verify: line specifies; FR-LIB-070's "disappear, change or are added" had no test spanning the *added* member; FR-LIB-040's search tests covered two of the six metadata fields searchable_text reads. Each requirement now has one test spanning it whole, and the narrow tests keep their assertions but lose the tag that over-claimed for them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-engine/src/chain.rs | 80 +++++++++++++++++++++- crates/namir-library/src/scan.rs | 103 +++++++++++++++++++++++++++-- crates/namir-library/src/search.rs | 71 +++++++++++++++++++- 3 files changed, 246 insertions(+), 8 deletions(-) diff --git a/crates/namir-engine/src/chain.rs b/crates/namir-engine/src/chain.rs index dfae088..3132e99 100644 --- a/crates/namir-engine/src/chain.rs +++ b/crates/namir-engine/src/chain.rs @@ -559,7 +559,9 @@ mod tests { fn telemetry(&self, _out: &mut crate::telemetry::TelemetrySink<'_>) {} } - // trace: FR-CHAIN-030 + /// One half of FR-CHAIN-030 pinned exactly; the requirement's own null-test method is + /// executed by `bypassed_output_nulls_against_delayed_input_to_within_120_dbfs` below, which + /// carries the tag. #[test] fn prepare_crosscutting_bypass_is_unity_gain_passthrough_at_zero_latency() { // +6 dB stage: if bypass were merely "skip clamping" rather than "skip the stages @@ -606,6 +608,82 @@ mod tests { assert_eq!(io.channel(0), &[0.0, 0.0, 0.0, 0.1, 0.2]); } + /// FR-CHAIN-030's own `Verify:` method, executed as written: "null test: bypassed output + /// minus delayed input is silence to within -120 dBFS". The two tests above each pin one + /// half of the requirement's sentence with an exact four/five-sample comparison; neither + /// subtracts a delayed input from a bypassed output, and neither spans both latency cases. + /// This one does both, over 512 samples of a deterministic non-trivial signal pushed through + /// in 64-sample blocks, so the compensation ring is exercised *across* block boundaries as + /// well as within one — including a declared latency longer than the block size, where the + /// null depends on the ring carrying samples over several calls. + /// + /// The +6 dB stage ahead of the delay-declaring one is the unity-gain half: a bypass that + /// merely skipped clamping, or that ran the stages and then delayed, could not null. Signal + /// amplitude stays at 0.5 so FR-CHAIN-090's 0 dBFS ceiling (active from + /// `prepare_crosscutting` onward) cannot clip it and be mistaken for a null. + // trace: FR-CHAIN-030 + #[test] + fn bypassed_output_nulls_against_delayed_input_to_within_120_dbfs() { + const BLOCK: usize = 64; + const BLOCKS: usize = 8; + const TOTAL: usize = BLOCK * BLOCKS; + + // -120 dBFS as a linear amplitude: the null floor the requirement's method names. + let null_floor = namir_core::db_to_linear(-120.0); + + // Zero latency (nothing to compensate for), a latency shorter than one block, and one + // longer than a block so the ring must carry samples between `process` calls. + for latency in [0u32, 3, 97] { + let input: Vec = (0..TOTAL) + .map(|n| { + let t = n as f32 / 48_000.0; + 0.25 * (2.0 * std::f32::consts::PI * 220.0 * t).sin() + + 0.25 * (2.0 * std::f32::consts::PI * 3_001.0 * t).sin() + }) + .collect(); + + let stages: Vec> = vec![ + Box::new(FixedGainPrep { gain_db: 6.0 }.prepare(&ctx()).unwrap()), + Box::new(ConstantTail { latency, tail: 0 }), + ]; + let mut chain = Chain::new(stages); + assert_eq!(chain.latency_samples(), latency); + chain.prepare_crosscutting(&ctx()); + chain.set_global_bypass(true); + + let mut output = Vec::with_capacity(TOTAL); + for block in input.chunks(BLOCK) { + let mut buffer = block.to_vec(); + { + let mut channels: [&mut [f32]; 1] = [&mut buffer]; + let mut io = StageIo::new(&mut channels, block.len()); + audio_section(|| chain.process(&mut io)); + } + output.extend_from_slice(&buffer); + } + assert_eq!(output.len(), TOTAL); + + // The delayed input: `latency` samples of silence, then the input itself. Only the + // alignment delay FR-CHAIN-030 permits, and nothing else. + let delay = latency as usize; + let delayed_input: Vec = std::iter::repeat_n(0.0f32, delay) + .chain(input.iter().copied()) + .take(TOTAL) + .collect(); + + let peak_residual = output + .iter() + .zip(&delayed_input) + .map(|(out, delayed)| (out - delayed).abs()) + .fold(0.0f32, f32::max); + assert!( + peak_residual <= null_floor, + "bypassed output minus input delayed by {latency} samples peaked at \ + {peak_residual:e}, above the -120 dBFS null floor {null_floor:e}" + ); + } + } + /// **No FR-CHAIN-080 tag any more** (M14). `NanOnce` writes into an *output* buffer at the end /// of a chain of one, so this reaches no product stage's state and never executes the /// requirement's "inject a NaN into each stage's state". It still proves the containment diff --git a/crates/namir-library/src/scan.rs b/crates/namir-library/src/scan.rs index 0eb1da6..aaf6ddb 100644 --- a/crates/namir-library/src/scan.rs +++ b/crates/namir-library/src/scan.rs @@ -322,8 +322,15 @@ mod tests { } fn write_nam(dir: &std::path::Path, name: &str) { + write_nam_seeded(dir, name, 1); + } + + /// `write_nam` with the fixture seed chosen by the caller — two different seeds give two + /// different models, hence two different content hashes, which is what FR-LIB-070's "files + /// that change" needs in order to be distinguishable from "files that were re-listed". + fn write_nam_seeded(dir: &std::path::Path, name: &str, seed: u64) { let model = - namir_fixtures::nam::generate(namir_fixtures::nam::WaveNetShape::Nano, 1).unwrap(); + namir_fixtures::nam::generate(namir_fixtures::nam::WaveNetShape::Nano, seed).unwrap(); std::fs::write(dir.join(name), model.to_json_bytes()).unwrap(); } @@ -418,9 +425,10 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } - /// FR-LIB-070: a file removed between scans is reflected as a removal, when the scan - /// completed. - // trace: FR-LIB-070 + /// FR-LIB-070's "files that disappear", in isolation: a file removed between scans is + /// reflected as a removal, when the scan completed. The requirement's whole set is spanned + /// by `one_rescan_reflects_disappeared_changed_and_added_files_and_survives_a_vanishing_one` + /// below, which carries the tag. #[test] fn a_deleted_file_is_reported_as_a_removal_on_a_complete_scan() { let root = temp_dir("deleted"); @@ -513,7 +521,8 @@ mod tests { assert_eq!(delta.warnings[0].code.id, error_codes::FILE_TOO_LARGE.id); } - // trace: FR-LIB-070 + /// FR-LIB-070's "a missing file shall never crash Namir", in isolation; the requirement's + /// whole set is spanned by the tagged rescan test below. #[test] fn an_unreadable_file_is_warned_about_and_skipped_not_fatal() { let root = temp_dir("vanishing"); @@ -533,6 +542,90 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// FR-LIB-070 in full, in a single rescan. The requirement's own sentence enumerates three + /// mutations — files that **disappear**, **change** or **are added** while Namir is running — + /// and adds that a missing file shall never crash Namir or the host. The three tests around + /// this one each pin one member in isolation (deletion above, the same-length edit below, the + /// vanish-mid-read race in between); none of them spans the set, and "are added" was spanned + /// by nothing at all before this test existed. Here all four conditions are applied at once, + /// between one completed scan and the next, and the *single* following rescan must reflect + /// every one of them. + /// + /// The vanishing file is made deterministic rather than raced: the scanner's first `step` + /// expands the root and queues every file, so deleting one between that step and the + /// file-examining steps that follow guarantees the read finds it missing — inside this same + /// rescan, so the no-crash clause is exercised on a scan that must still get the other three + /// members right. + // trace: FR-LIB-070 + #[test] + fn one_rescan_reflects_disappeared_changed_and_added_files_and_survives_a_vanishing_one() { + let root = temp_dir("mutations"); + write_nam(&root, "gone.nam"); + write_nam_seeded(&root, "changed.nam", 2); + write_nam_seeded(&root, "stable.nam", 3); + // Outside D-12.1's settling window, so an unchanged file is genuinely recognised as + // unchanged rather than rehashed for safety (which would make the "stable" control + // prove nothing). + for name in ["gone.nam", "changed.nam", "stable.nam"] { + age_mtime(&root.join(name), 3600); + } + + let mut index = Index::empty(); + index.apply(Scanner::new(vec![root.clone()], &index.clone()).run_to_completion(&StdFs)); + assert_eq!(index.len(), 3); + let changed_path = root.join("changed.nam"); + let stable_path = root.join("stable.nam"); + let changed_hash_before = index.get(&changed_path).unwrap().hash; + let stable_hash_before = index.get(&stable_path).unwrap().hash; + + // Disappears; changes (different model, so a different content hash); is added. + std::fs::remove_file(root.join("gone.nam")).unwrap(); + write_nam_seeded(&root, "changed.nam", 4); + write_nam_seeded(&root, "added.nam", 5); + // And one more that exists when the directory is listed and is gone by the time the + // scanner tries to read it. + write_nam_seeded(&root, "racing.nam", 6); + + let mut scanner = Scanner::new(vec![root.clone()], &index); + assert!(matches!(scanner.step(&StdFs), Step::Progressed(_))); // expands the root + std::fs::remove_file(root.join("racing.nam")).unwrap(); + while let Step::Progressed(_) = scanner.step(&StdFs) {} + let delta = scanner.take_delta(); + + assert!(delta.complete); + // Disappeared. + assert_eq!(delta.removals, vec![root.join("gone.nam")]); + // Changed and added -- and only those two: the untouched file is not re-upserted, and + // the file that vanished before it could be read contributes no entry. + let mut upserted: Vec = delta.upserts.iter().map(|e| e.path.clone()).collect(); + upserted.sort(); + assert_eq!(upserted, vec![root.join("added.nam"), changed_path.clone()]); + // Never crashed on the missing file: the scan ran to completion and recorded the miss as + // one warning rather than a panic or an aborted scan. + assert_eq!(delta.warnings.len(), 1); + assert_eq!(delta.warnings[0].code.id, error_codes::FILE_UNREADABLE.id); + + index.apply(delta); + let mut paths: Vec = index.iter().map(|e| e.path.clone()).collect(); + paths.sort(); + assert_eq!( + paths, + vec![ + root.join("added.nam"), + changed_path.clone(), + stable_path.clone() + ] + ); + assert_ne!( + index.get(&changed_path).unwrap().hash, + changed_hash_before, + "a changed file must be reflected as new content, not just re-listed" + ); + assert_eq!(index.get(&stable_path).unwrap().hash, stable_hash_before); + + let _ = std::fs::remove_dir_all(&root); + } + /// D-12.1's corrected change-detection rule (`docs/02-architecture.md` §12's M5 consequence /// note on D-12.1): a file edited in place to the *same length*, whose new mtime happens to /// coincide with the mtime already on record (a real risk on a filesystem with coarser mtime diff --git a/crates/namir-library/src/search.rs b/crates/namir-library/src/search.rs index 88cbc09..0dbc72a 100644 --- a/crates/namir-library/src/search.rs +++ b/crates/namir-library/src/search.rs @@ -154,7 +154,9 @@ mod tests { assert_eq!(filter(&index, &query).count(), 1); } - // trace: FR-LIB-040 + /// One limb of FR-LIB-040 in isolation; the whole requirement is spanned by + /// `search_spans_the_file_name_and_every_free_text_metadata_field` below, which carries the + /// tag. #[test] fn matches_the_file_stem_case_insensitively() { let index = index_with(vec![nam_entry("marshall/PLEXI.nam", "", "")]); @@ -162,7 +164,7 @@ mod tests { assert_eq!(filter(&index, &Query::parse("fender")).count(), 0); } - // trace: FR-LIB-040 + /// Two of the six metadata fields in isolation; see the tagged test below for the set. #[test] fn matches_metadata_fields() { let index = index_with(vec![nam_entry("a.nam", "Plexi 1959", "a crunchy amp")]); @@ -170,6 +172,71 @@ mod tests { assert_eq!(filter(&index, &Query::parse("1959")).count(), 1); } + /// FR-LIB-040 in full: "filter the library by free-text search over file name and metadata + /// fields". *Metadata fields* is the requirement's own plural, and the set the index actually + /// carries free text in is `NamItemMetadata`'s six strings — architecture, name, modeled_by, + /// gear_type, tone_type, description. The two focused tests above exercise two of the six + /// between them; this one gives each of the six, and the file name, a token that appears + /// nowhere else, so a hit can only have come from the field it belongs to and no field can + /// pass by being carried along with a neighbour. (`sample_rate` and an IR's header metadata + /// are numbers, not free text — see this module's own doc comment; an IR is searched by file + /// name alone, which `ir_entries_are_searched_by_file_stem_only` covers.) + /// + /// The file name searched is the file's stem: the extension is the item's *kind*, which the + /// index carries as `ItemKind` rather than as searchable text, and the directories above it + /// are the library root's business rather than the item's name — both asserted below so the + /// boundary is stated rather than assumed. + // trace: FR-LIB-040 + #[test] + fn search_spans_the_file_name_and_every_free_text_metadata_field() { + let index = index_with(vec![LibraryEntry { + path: PathBuf::from("cabinets/StemToken.nam"), + kind: ItemKind::Nam, + size: 10, + mtime: FileTime::now(), + hash: None, + metadata: ItemMetadata::Nam(NamItemMetadata { + architecture: "ArchToken".to_string(), + sample_rate: Some(48_000), + name: "NameToken".to_string(), + modeled_by: "AuthorToken".to_string(), + gear_type: "GearToken".to_string(), + tone_type: "ToneToken".to_string(), + description: "DescriptionToken".to_string(), + }), + origin: Origin::Local, + }]); + + for (field, token) in [ + ("file name", "stemtoken"), + ("architecture", "archtoken"), + ("name", "nametoken"), + ("modeled_by", "authortoken"), + ("gear_type", "geartoken"), + ("tone_type", "tonetoken"), + ("description", "descriptiontoken"), + ] { + assert_eq!( + filter(&index, &Query::parse(token)).count(), + 1, + "a free-text search must match on {field}" + ); + // Case-folded in both directions, for every field rather than just the file name. + assert_eq!( + filter(&index, &Query::parse(&token.to_uppercase())).count(), + 1, + "matching on {field} must be case-insensitive" + ); + } + + // A term in none of the seven matches nothing -- otherwise the loop above would pass on + // a filter that matched everything. + assert_eq!(filter(&index, &Query::parse("absenttoken")).count(), 0); + // Not part of the item's name: the directory it sits in, and its extension. + assert_eq!(filter(&index, &Query::parse("cabinets")).count(), 0); + assert_eq!(filter(&index, &Query::parse("stemtoken.nam")).count(), 0); + } + #[test] fn every_term_must_match_and_terms_are_split_on_whitespace() { let index = index_with(vec![nam_entry("a.nam", "Plexi 1959", "crunchy")]); From 0499d2667491f53f28c04f8cb4ec997c6ae8a89f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:46:16 +0000 Subject: [PATCH 02/44] params.lock format version 2: record each parameter's shape (issues #119, #121, #122) A line gains min/max/default, or steps/default/values=. The digest rather than a count is what catches a reordering of stepped labels, which silently re-points an already-stored index. A changed shape is recorded as a stale file, not a build failure: D-10.1 reserves failure for a changed identifier or type, and several FRS section 5 ranges are stated as "at least", so widening one is legitimate. Recording it makes it a diff a reviewer reads. Also: format_version is now matched exactly rather than by prefix and is compared, so a future version is one violation instead of a pile of malformed lines and --write refuses to overwrite it; and ParamDescriptor::validate runs over every descriptor in the gate, so a self-contradicting descriptor fails xtask params-lock rather than only a test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-params/src/descriptor.rs | 131 +++++++ crates/namir-params/src/error_codes.rs | 35 +- crates/namir-params/src/id.rs | 5 +- crates/namir-params/src/lib.rs | 20 +- crates/namir-params/src/manifest.rs | 489 +++++++++++++++++++++++-- params.lock | 83 +++-- xtask/src/params_lock.rs | 185 +++++++++- 7 files changed, 853 insertions(+), 95 deletions(-) diff --git a/crates/namir-params/src/descriptor.rs b/crates/namir-params/src/descriptor.rs index d4ee2c3..4d36341 100644 --- a/crates/namir-params/src/descriptor.rs +++ b/crates/namir-params/src/descriptor.rs @@ -139,6 +139,62 @@ impl ParamDescriptor { } } + /// Checks the invariants a descriptor's own fields have to satisfy to mean anything, and + /// which nothing enforced until `params.lock` grew shape columns (issue #119): [`ParamDescriptor::new`] is a `const fn` + /// that accepts every one of these, and `format_value` merely *clamps* a bad stepped index + /// rather than reporting it, so a typo'd `StepIndex(5)` on a two-value list used to reach + /// consumers — where a direct index into `values` panics instead of clamping. + /// + /// Four invariants, one message each: + /// + /// - [`ParamKind::Stepped`]'s `values` is non-empty; + /// - its `default_index` indexes `values`; + /// - [`ParamKind::Continuous`]'s `min <= max`, both finite; + /// - its `default` is finite and lies in `min..=max`. + /// + /// This is not only a test's business: since `params.lock`'s format version 2 the manifest + /// records a parameter's range, default and stepped-value fingerprint, so an internally + /// inconsistent descriptor would be written into the checked-in manifest as fact. + /// [`crate::check_manifest`] therefore runs this over every descriptor it is given and reports + /// a failure as `params.manifest.invalid_descriptor`, which makes it a build failure through + /// `cargo run -p xtask -- params-lock` rather than only a test. + /// + /// Returns the first problem found, phrased for that violation's `detail` field. + pub fn validate(&self) -> Result<(), String> { + match self.kind { + ParamKind::Continuous { min, max, default } => { + if !min.is_finite() || !max.is_finite() { + return Err(format!("non-finite range {min}..={max}")); + } + if min > max { + return Err(format!("minimum {min} is above maximum {max}")); + } + if !default.is_finite() { + return Err(format!("non-finite default {default}")); + } + if default < min || default > max { + return Err(format!("default {default} lies outside {min}..={max}")); + } + } + ParamKind::Stepped { + values, + default_index, + } => { + if values.is_empty() { + return Err("a stepped parameter with no values".to_string()); + } + if default_index.0 as usize >= values.len() { + return Err(format!( + "default index {} is outside 0..{}", + default_index.0, + values.len() + )); + } + } + } + Ok(()) + } + /// Renders `value` as text per this descriptor's [`ValueFormat`] (FR-PARAM-010). `value` is /// in the parameter's own space: the raw `f32` for `Continuous`, or a step index (as `f32`, /// rounded and clamped) for `Stepped`. A format/kind pairing this crate didn't intend (e.g. @@ -223,6 +279,81 @@ mod tests { assert_eq!(CHANNEL_MODE.format_value(99.0), "Dual Mono"); } + #[test] + fn a_well_formed_descriptor_validates() { + assert_eq!(TRIM.validate(), Ok(())); + assert_eq!(CHANNEL_MODE.validate(), Ok(())); + } + + /// The four invariants issue #119 names, one deliberately-broken descriptor each. Each is + /// built by struct literal rather than through `ParamDescriptor::new`, only because `new` is a + /// `const fn` that would accept them all just the same -- that is the defect. + #[test] + fn each_broken_invariant_is_reported() { + let stepped_index_past_the_end = ParamDescriptor { + kind: ParamKind::Stepped { + values: &["Off", "On"], + default_index: StepIndex(5), + }, + ..CHANNEL_MODE + }; + assert!( + stepped_index_past_the_end + .validate() + .unwrap_err() + .contains("default index 5"), + ); + + let no_values = ParamDescriptor { + kind: ParamKind::Stepped { + values: &[], + default_index: StepIndex(0), + }, + ..CHANNEL_MODE + }; + assert!(no_values.validate().unwrap_err().contains("no values")); + + let inverted_range = ParamDescriptor { + kind: ParamKind::Continuous { + min: 24.0, + max: -24.0, + default: 0.0, + }, + ..TRIM + }; + assert!( + inverted_range + .validate() + .unwrap_err() + .contains("is above maximum"), + ); + + let default_outside_range = ParamDescriptor { + kind: ParamKind::Continuous { + min: -24.0, + max: 24.0, + default: 96.0, + }, + ..TRIM + }; + assert!( + default_outside_range + .validate() + .unwrap_err() + .contains("lies outside"), + ); + + let non_finite = ParamDescriptor { + kind: ParamKind::Continuous { + min: f32::NAN, + max: 24.0, + default: 0.0, + }, + ..TRIM + }; + assert!(non_finite.validate().unwrap_err().contains("non-finite")); + } + #[test] fn stepped_formatting_rounds_to_nearest_index() { assert_eq!(CHANNEL_MODE.format_value(1.4), "Stereo"); diff --git a/crates/namir-params/src/error_codes.rs b/crates/namir-params/src/error_codes.rs index 5756856..b9229ea 100644 --- a/crates/namir-params/src/error_codes.rs +++ b/crates/namir-params/src/error_codes.rs @@ -71,8 +71,9 @@ pub const DROPPED: ErrorCode = ErrorCode::new( accounted for and can never be handed to a different parameter.", ); -/// A line in the old manifest text didn't parse as either a comment, the `format_version` line, -/// or a well-formed `key id kind tombstoned` data line. +/// A line in the old manifest text didn't parse as either a comment, a well-formed +/// `format_version ` line, or a well-formed `key id kind live|tombstoned ` data line, +/// every shape column being a `name=value` pair. pub const MALFORMED_LINE: ErrorCode = ErrorCode::new( "params.manifest.malformed_line", Severity::Error, @@ -81,6 +82,34 @@ pub const MALFORMED_LINE: ErrorCode = ErrorCode::new( params-lock --write` rather than editing it by hand.", ); +/// The manifest declares a `format_version` this build cannot read: either newer than +/// [`crate::FORMAT_VERSION`], or not a number at all. Reported alone, because every other finding +/// under an unknown line grammar would be a guess — before this code existed, a future file was +/// reported as a pile of `MALFORMED_LINE`s instead. +pub const FORMAT_VERSION_UNSUPPORTED: ErrorCode = ErrorCode::new( + "params.manifest.format_version_unsupported", + Severity::Error, + "params.lock declares a format version this build cannot read: {detail}.", + "Build from a revision whose namir-params writes that format version. Do not regenerate the \ + file with an older build: that would overwrite a manifest written by newer tooling, tombstones \ + included. An *older* format version needs none of this -- it is migrated by `cargo run -p \ + xtask -- params-lock --write`.", +); + +/// A descriptor in the new set contradicts itself: a default outside its own range, a stepped +/// default index past the end of its values, a non-finite bound. `ParamDescriptor::new` is a +/// `const fn` that accepts all of these, and since format version 2 the manifest records the range, +/// the default and the stepped-value fingerprint — so an inconsistent descriptor would be written +/// into the checked-in file as fact. +pub const INVALID_DESCRIPTOR: ErrorCode = ErrorCode::new( + "params.manifest.invalid_descriptor", + Severity::Error, + "A parameter descriptor contradicts its own declared value space: {detail}.", + "Correct the descriptor in its stage's module under crates/namir-params/src. A default has to \ + lie inside the range it belongs to, and a stepped default index has to index its own values \ + list.", +); + /// One diagnosed manifest problem: a catalogued [`ErrorCode`] plus a `detail` string naming the /// specific key/id/line involved (mirrors `namir_nam::NamLoadError`'s shape). #[derive(Debug, Clone, PartialEq, Eq)] @@ -110,6 +139,8 @@ const ALL: &[ErrorCode] = &[ DUPLICATE_KEY, DROPPED, MALFORMED_LINE, + FORMAT_VERSION_UNSUPPORTED, + INVALID_DESCRIPTOR, ]; #[cfg(test)] diff --git a/crates/namir-params/src/id.rs b/crates/namir-params/src/id.rs index a0a49d7..1e983ed 100644 --- a/crates/namir-params/src/id.rs +++ b/crates/namir-params/src/id.rs @@ -36,7 +36,10 @@ impl ParamId { } } -const fn fnv1a_32(bytes: &[u8]) -> u32 { +/// FNV-1a, 32-bit, over `bytes`. `pub(crate)` so [`crate::manifest`] can fingerprint a stepped +/// parameter's value labels with the same hash this crate already commits to for keys; nothing +/// outside this crate may depend on the algorithm. +pub(crate) const fn fnv1a_32(bytes: &[u8]) -> u32 { let mut hash = FNV_OFFSET_BASIS; let mut i = 0; while i < bytes.len() { diff --git a/crates/namir-params/src/lib.rs b/crates/namir-params/src/lib.rs index fabf534..827bf66 100644 --- a/crates/namir-params/src/lib.rs +++ b/crates/namir-params/src/lib.rs @@ -11,7 +11,10 @@ //! [`ParamKind::Continuous`]), and a [`ValueFormat`]. //! - FR-PARAM-020 — [`ParamId::from_key`]'s permanent FNV-1a derivation (see `id.rs`) plus //! [`render_manifest`]/[`check_manifest`]'s enforcement that an existing entry's identifier or -//! kind never changes and a retired entry is tombstoned, never silently dropped. +//! kind never changes and a retired entry is tombstoned, never silently dropped. Since +//! `params.lock`'s format version 2 the manifest also records each parameter's range, default +//! and stepped-value fingerprint, so a change to *those* is a diff a reviewer sees rather than a +//! silent reinterpretation of every saved preset (issue #121). //! - FR-PARAM-050 — [`ParamKind::Stepped`], with named values and a [`descriptor::StepIndex`] //! value-representation type, instead of forcing discrete choices through a continuous range. //! - D-10.2 — the `stage_instance` field on every descriptor, present and zeroed now so RD-2's @@ -124,6 +127,21 @@ mod tests { } } + /// Issue #119: the duplicate check above is about a key's relationship to *other* keys, and + /// nothing checked a descriptor against itself. `ParamDescriptor::new` is a `const fn` that + /// accepts a `default` outside its own range and a `StepIndex` past the end of its own values, + /// `format_value` clamps the latter rather than reporting it, and every consumer that indexes + /// `values` directly gets a panic instead. Spans every entry, including any added after this + /// test was written — the shipped registry, not a transcribed list of it. + #[test] + fn every_registry_descriptor_satisfies_its_own_invariants() { + for d in REGISTRY { + if let Err(problem) = d.validate() { + panic!("{}: {problem}", d.key); + } + } + } + /// M14: compares against `merge_manifest(the file, REGISTRY)`, not `render_manifest(REGISTRY)`. /// The old form was the third of the three live-only comparisons that made a committed /// tombstone fail the gate permanently (FR-PARAM-020, issue #31); with no tombstone in the file diff --git a/crates/namir-params/src/manifest.rs b/crates/namir-params/src/manifest.rs index 985609a..428cc5c 100644 --- a/crates/namir-params/src/manifest.rs +++ b/crates/namir-params/src/manifest.rs @@ -9,8 +9,34 @@ //! Plain text, not JSON — a lockfile in `Cargo.lock`'s sense (line-oriented, diffable, never //! spuriously reordered), not a data-interchange document. A header comment block, a //! `format_version` line, then one line per parameter, sorted by key, each -//! ` `. Tombstoned lines are never deleted — that is the entire -//! point of a tombstone (D-10.1). +//! ` `. Tombstoned lines are never deleted — that is the +//! entire point of a tombstone (D-10.1). +//! +//! # The shape columns, and the format version that carries them (issue #121) +//! +//! Format version 1 recorded ` ` and nothing else, and +//! [`kind_tag`] reduces a kind to the bare word `continuous` or `stepped`. So a change to a +//! parameter's `min`, `max` or `default`, or to a [`ParamKind::Stepped`]'s `values` list, moved no +//! byte of `params.lock` — while silently reinterpreting every saved preset and every +//! host-normalised automation value carrying that id. D-10.1's guarantee is that the manifest is +//! *diffed* in CI; a change nothing in the file records is a change nothing can diff. +//! +//! Version 2 appends a per-kind shape (see [`shape_tag`]): +//! +//! - continuous: `min= max= default=`, each rendered by `f32`'s shortest +//! round-tripping `Debug` form so the file is byte-stable across builds; +//! - stepped: `steps= default= values=`, the last an FNV-1a fingerprint of the +//! value labels — which catches a *reordering* of the labels, the change that actually re-points +//! a stored index at a different option, and which a bare count would miss. +//! +//! **A shape change is a diff, not a violation.** D-10.1 makes a changed *identifier or type* a +//! build failure; a widened range is a legitimate edit (FRS §5 states several ranges as "at +//! least"), so recording it makes it visible and reviewable rather than forbidden. What used to +//! pass invisibly now fails `xtask params-lock` as a stale file whose regeneration a reviewer sees. +//! +//! An internally inconsistent descriptor — a `default` outside its own range, a `default_index` +//! past the end of `values` — would be written into that record as fact, so [`check_manifest`] +//! also runs [`ParamDescriptor::validate`] over every descriptor it is given (issue #119). //! //! [`render_manifest`] only ever emits `live` lines: it renders the current, in-source descriptor //! set, which by construction contains no tombstones (a tombstoned parameter has no descriptor @@ -41,23 +67,40 @@ use std::collections::BTreeMap; use crate::descriptor::{ParamDescriptor, ParamKind}; use crate::error_codes::{ - DROPPED, DUPLICATE_ID, DUPLICATE_KEY, ID_CHANGED, KIND_CHANGED, MALFORMED_LINE, - ManifestViolation, TOMBSTONE_REUSED, + DROPPED, DUPLICATE_ID, DUPLICATE_KEY, FORMAT_VERSION_UNSUPPORTED, ID_CHANGED, + INVALID_DESCRIPTOR, KIND_CHANGED, MALFORMED_LINE, ManifestViolation, TOMBSTONE_REUSED, }; /// The `params.lock` schema version, written as the manifest's `format_version` line. Bump this /// if the line format itself ever changes shape; it is not a per-parameter version. -pub const FORMAT_VERSION: u32 = 1; +/// +/// - **1** — ` `. +/// - **2** — the same, plus the per-kind shape columns [`shape_tag`] renders (issue #121). +/// +/// [`check_manifest`] reads a file declaring a version *newer* than this one as a single +/// `FORMAT_VERSION_UNSUPPORTED` violation rather than parsing it under this build's rules (issue +/// #122). An *older* one is deliberately not a violation: it is a staleness, and the whole of +/// migrating it is `cargo run -p xtask -- params-lock --write`. Making it an error instead would +/// leave the file in a state the documented regeneration command refuses to fix, which is the trap +/// issue #117 named. +pub const FORMAT_VERSION: u32 = 2; const HEADER: &str = "\ # namir-params manifest (params.lock) -- machine-generated, do not hand-edit except to flip a -# retired parameter's line from \"live\" to \"tombstoned\" (D-10.1). Regenerate with -# `cargo run -p xtask -- params-lock --write`, which calls merge_manifest(this file, REGISTRY) -# (see crates/namir-params/src/manifest.rs): the \"live\" lines are re-rendered from REGISTRY and -# every \"tombstoned\" line already here is carried through unchanged. +# retired parameter's line from \"live\" to \"tombstoned\" (D-10.1), leaving the rest of that line +# alone. Regenerate with `cargo run -p xtask -- params-lock --write`, which calls +# merge_manifest(this file, REGISTRY) (see crates/namir-params/src/manifest.rs): the \"live\" lines +# are re-rendered from REGISTRY and every \"tombstoned\" line already here is carried through +# unchanged, so that hand edit is one the gate accepts rather than one it refuses forever. +# +# Columns: key id kind live|tombstoned . One line per parameter, sorted by key. Tombstoned +# lines are retained forever -- a parameter is retired here, never deleted (FR-PARAM-020). # -# Columns: key id kind live|tombstoned. One line per parameter, sorted by key. Tombstoned lines -# are retained forever -- a parameter is retired here, never deleted (FR-PARAM-020). +# The shape columns record what the kind tag alone does not, so that a changed range, default or +# set of stepped values shows up as a diff here instead of silently reinterpreting every saved +# preset and every host-normalised automation value carrying that id: +# continuous min= max= default= +# stepped steps= default= values= "; fn kind_tag(kind: &ParamKind) -> &'static str { @@ -67,6 +110,50 @@ fn kind_tag(kind: &ParamKind) -> &'static str { } } +/// The `` columns of a manifest line: what the parameter's value space actually *is*, as +/// opposed to which of the two shapes it has (issue #121). See the module doc comment for the +/// column vocabulary and for why a change here is a diff rather than a violation. +/// +/// Floats are rendered with `f32`'s `Debug` form, which is the shortest decimal that round-trips +/// — deterministic, so the file never changes spuriously, and never a truncation that would let +/// two different ranges render the same columns. +fn shape_tag(kind: &ParamKind) -> String { + match kind { + ParamKind::Continuous { min, max, default } => { + format!("min={min:?} max={max:?} default={default:?}") + } + ParamKind::Stepped { + values, + default_index, + } => format!( + "steps={} default={} values={:08x}", + values.len(), + default_index.0, + values_digest(values) + ), + } +} + +/// FNV-1a over a stepped parameter's labels, joined by the US separator (0x1f), which no +/// display label contains. +/// A fingerprint rather than the labels themselves because a label may contain whitespace +/// ("Dual Mono"), and this file's grammar is whitespace-separated columns. +fn values_digest(values: &[&str]) -> u32 { + crate::id::fnv1a_32(values.join("\u{1f}").as_bytes()) +} + +/// One manifest line for `d` in the given `live`/`tombstoned` state, without its newline. +fn manifest_line(d: &ParamDescriptor, state: &str) -> String { + format!( + "{} {} {} {} {}", + d.key, + d.id.0, + kind_tag(&d.kind), + state, + shape_tag(&d.kind) + ) +} + /// Renders `descriptors` as `params.lock` text (D-10.1). Deterministic: sorted by key with a /// stable sort, so the file never spuriously reorders itself between otherwise-identical builds. /// Every rendered line is `live` — see the module doc comment for why tombstones don't come from @@ -78,12 +165,8 @@ pub fn render_manifest(descriptors: &[ParamDescriptor]) -> String { let mut out = String::from(HEADER); out.push_str(&format!("format_version {FORMAT_VERSION}\n")); for d in sorted { - out.push_str(&format!( - "{} {} {} live\n", - d.key, - d.id.0, - kind_tag(&d.kind) - )); + out.push_str(&manifest_line(d, "live")); + out.push('\n'); } out } @@ -108,23 +191,26 @@ pub fn render_manifest(descriptors: &[ParamDescriptor]) -> String { /// [`check_manifest`]'s `MALFORMED_LINE` that reports it with the offending text; carrying the /// bad line forward here would make `--write` cement a typo into the checked-in file. pub fn merge_manifest(old: &str, new: &[ParamDescriptor]) -> String { - let (old_entries, _) = parse_manifest(old); + let parsed = parse_manifest(old); let live_keys: BTreeMap<&str, ()> = new.iter().map(|d| (d.key, ())).collect(); let mut lines: Vec<(&str, String)> = new .iter() - .map(|d| { - ( - d.key, - format!("{} {} {} live", d.key, d.id.0, kind_tag(&d.kind)), - ) - }) + .map(|d| (d.key, manifest_line(d, "live"))) .collect(); - for (key, entry) in &old_entries { + for (key, entry) in &parsed.entries { if entry.tombstoned && !live_keys.contains_key(key.as_str()) { + // The recorded shape is carried through verbatim, exactly like the id and the kind: a + // retired parameter has no descriptor left to re-render one from, and what its range + // or its named values *were* is the historical fact the tombstone exists to keep. A + // line written under format version 1 has none, and is carried through without one. + let shape = match &entry.shape { + Some(shape) => format!(" {shape}"), + None => String::new(), + }; lines.push(( key.as_str(), - format!("{} {} {} tombstoned", key, entry.id, entry.kind), + format!("{} {} {} tombstoned{}", key, entry.id, entry.kind, shape), )); } } @@ -145,29 +231,85 @@ struct OldEntry { id: u32, kind: String, tombstoned: bool, + /// The line's `` columns, joined by single spaces, or `None` for a line written under + /// format version 1 (which had none). Never re-derived: see [`merge_manifest`]. + shape: Option, +} + +/// What a manifest's `format_version` line said. Distinguishing *absent* from *unreadable* matters: +/// an absent version is a version-1 file, which regeneration migrates, while an unreadable one is a +/// file this build cannot claim to understand at all. +enum DeclaredVersion { + Absent, + Value(u32), + Unreadable(String), } -fn parse_manifest(text: &str) -> (BTreeMap, Vec) { +struct ParsedManifest { + entries: BTreeMap, + violations: Vec, + version: DeclaredVersion, +} + +fn parse_manifest(text: &str) -> ParsedManifest { let mut entries = BTreeMap::new(); let mut violations = Vec::new(); + let mut version = DeclaredVersion::Absent; for line in text.lines() { let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("format_version") { + if trimmed.is_empty() || trimmed.starts_with('#') { continue; } let fields: Vec<&str> = trimmed.split_whitespace().collect(); + + // The version line is recognised by its key **exactly**, not by prefix: `starts_with` here + // used to swallow any line beginning `format_version`, so `format_version_2 7` was skipped + // in silence rather than reported (issue #122). And the value is now read rather than + // discarded -- see `check_manifest`. + if fields[0] == "format_version" { + match fields.as_slice() { + [_, value] if matches!(version, DeclaredVersion::Absent) => { + version = match value.parse::() { + Ok(v) => DeclaredVersion::Value(v), + Err(_) => DeclaredVersion::Unreadable((*value).to_string()), + }; + } + _ => violations.push(ManifestViolation { + code: MALFORMED_LINE, + detail: format!("'{line}'"), + }), + } + continue; + } + let parsed = match fields.as_slice() { - [key, id, kind, tombstone] => { + [key, id, kind, tombstone, shape @ ..] => { let id = id.parse::().ok(); let tombstoned = match *tombstone { "live" => Some(false), "tombstoned" => Some(true), _ => None, }; - match (id, tombstoned) { - (Some(id), Some(tombstoned)) => Some((*key, id, *kind, tombstoned)), + // Every shape column is a `=` pair. Checked rather than assumed, so a + // typo in the one hand edit D-10.1 permits is reported against its own line instead + // of being carried forward by `merge_manifest` as if it were data. + let shape_ok = shape.iter().all(|token| { + let mut halves = token.splitn(2, '='); + matches!( + (halves.next(), halves.next()), + (Some(name), Some(value)) if !name.is_empty() && !value.is_empty() + ) + }); + match (id, tombstoned, shape_ok) { + (Some(id), Some(tombstoned), true) => Some(( + *key, + id, + *kind, + tombstoned, + (!shape.is_empty()).then(|| shape.join(" ")), + )), _ => None, } } @@ -175,13 +317,14 @@ fn parse_manifest(text: &str) -> (BTreeMap, Vec { + Some((key, id, kind, tombstoned, shape)) => { entries.insert( key.to_string(), OldEntry { id, kind: kind.to_string(), tombstoned, + shape, }, ); } @@ -192,7 +335,11 @@ fn parse_manifest(text: &str) -> (BTreeMap, Vec (BTreeMap, Vec Result<(), Vec> { - let (old_entries, mut violations) = parse_manifest(old); + let ParsedManifest { + entries: old_entries, + mut violations, + version, + } = parse_manifest(old); + + // A version *newer* than this build's, or one that is not a number at all. Not a staleness: + // there is no regeneration that fixes it, and `--write`ing over it would destroy a file written + // by tooling that knows more than this build does. An *older* version is deliberately absent + // from this rule -- see `FORMAT_VERSION`'s own doc comment. + match version { + DeclaredVersion::Value(found) if found > FORMAT_VERSION => { + return Err(vec![ManifestViolation { + code: FORMAT_VERSION_UNSUPPORTED, + detail: format!( + "the file declares format_version {found}; this build writes and reads \ + {FORMAT_VERSION}" + ), + }]); + } + DeclaredVersion::Unreadable(found) => { + return Err(vec![ManifestViolation { + code: FORMAT_VERSION_UNSUPPORTED, + detail: format!("format_version '{found}' is not a number"), + }]); + } + DeclaredVersion::Value(_) | DeclaredVersion::Absent => {} + } + + // Before any comparison against `old`: a descriptor that contradicts itself would otherwise be + // recorded in the manifest's shape columns as fact (issue #119). + for d in new { + if let Err(problem) = d.validate() { + violations.push(ManifestViolation { + code: INVALID_DESCRIPTOR, + detail: format!("key '{}': {problem}", d.key), + }); + } + } let tombstoned_ids: BTreeMap = old_entries .iter() @@ -332,7 +527,7 @@ mod tests { fn render_manifest_is_sorted_by_key_and_has_a_header() { let text = render_manifest(&[CHANNEL_MODE, TRIM, GATE_THRESHOLD]); assert!(text.starts_with("# namir-params manifest")); - assert!(text.contains("format_version 1\n")); + assert!(text.contains(&format!("format_version {FORMAT_VERSION}\n"))); let gate_pos = text.find("gate.threshold").unwrap(); let out_pos = text.find("out.channel_mode").unwrap(); @@ -573,6 +768,226 @@ mod tests { assert!(!merged.contains("oops"), "{merged}"); } + // --- the shape columns (issue #121) -------------------------------------------------------- + + /// `TRIM` with a different `ParamKind`, keeping its key, id and every other field. This is the + /// edit issue #121 is about: same identifier, same type, a different value space. + fn trim_with(kind: ParamKind) -> ParamDescriptor { + ParamDescriptor { kind, ..TRIM } + } + + #[test] + fn a_changed_range_moves_the_manifest() { + let old = render_manifest(&[TRIM]); + for widened in [ + ParamKind::Continuous { + min: -30.0, + max: 24.0, + default: 0.0, + }, + ParamKind::Continuous { + min: -24.0, + max: 36.0, + default: 0.0, + }, + ] { + let new = trim_with(widened); + assert_ne!( + render_manifest(&[new]), + old, + "a changed range must move params.lock, or nothing can diff it" + ); + // ...and the file is therefore reported stale rather than silently accepted, while + // still being a legitimate edit rather than a violation. + assert!(check_manifest(&old, &[new]).is_ok()); + assert_ne!(merge_manifest(&old, &[new]), old); + } + } + + #[test] + fn a_changed_default_moves_the_manifest() { + let old = render_manifest(&[TRIM]); + let new = trim_with(ParamKind::Continuous { + min: -24.0, + max: 24.0, + default: -6.0, + }); + assert_ne!(render_manifest(&[new]), old); + assert!(old.contains("default=0.0"), "{old}"); + assert!(render_manifest(&[new]).contains("default=-6.0")); + } + + #[test] + fn a_changed_stepped_values_list_moves_the_manifest() { + let old = render_manifest(&[CHANNEL_MODE]); + + // One more option: the count column alone would catch this one. + let added = ParamDescriptor { + kind: ParamKind::Stepped { + values: &["Mono", "Stereo", "Dual Mono"], + default_index: StepIndex(0), + }, + ..CHANNEL_MODE + }; + assert_ne!(render_manifest(&[added]), old); + + // The same two options, reordered: the count is unchanged, and every preset that stored + // index 0 now means the other option. This is what the fingerprint column is for. + let reordered = ParamDescriptor { + kind: ParamKind::Stepped { + values: &["Stereo", "Mono"], + default_index: StepIndex(0), + }, + ..CHANNEL_MODE + }; + assert_ne!( + render_manifest(&[reordered]), + old, + "a reordered values list must move params.lock" + ); + + // And a changed default index, with the same list. + let other_default = ParamDescriptor { + kind: ParamKind::Stepped { + values: &["Mono", "Stereo"], + default_index: StepIndex(1), + }, + ..CHANNEL_MODE + }; + assert_ne!(render_manifest(&[other_default]), old); + } + + #[test] + fn a_tombstones_shape_columns_survive_regeneration() { + // A retired parameter has no descriptor left to re-render its range from, so what it *was* + // is only in the file. `merge_manifest` must carry it verbatim, like the id and the kind. + let old = with_tombstone(&render_manifest(&[TRIM, GATE_THRESHOLD]), "gate.threshold"); + let merged = merge_manifest(&old, &[TRIM]); + assert!( + merged.contains(&format!( + "gate.threshold {} continuous tombstoned min=-80.0 max=0.0 default=-50.0", + GATE_THRESHOLD.id.0 + )), + "{merged}" + ); + assert_eq!(merge_manifest(&merged, &[TRIM]), merged); + } + + #[test] + fn a_shape_column_that_is_not_a_name_value_pair_is_malformed() { + let old = format!( + "{}zz.retired 42 continuous tombstoned min=-1.0 oops\n", + render_manifest(&[TRIM]) + ); + let violations = check_manifest(&old, &[TRIM]).expect_err("a bad shape column must fail"); + assert!(violations.iter().any(|v| v.code.id == MALFORMED_LINE.id)); + } + + // --- format_version (issue #122) ------------------------------------------------------------- + + #[test] + fn a_future_format_version_is_reported_as_a_version_mismatch_not_a_pile_of_malformed_lines() { + let future = format!( + "{}\nsome.key 7 continuous live shape-this-build-cannot-read\n", + render_manifest(&[TRIM]).replace( + &format!("format_version {FORMAT_VERSION}"), + &format!("format_version {}", FORMAT_VERSION + 1) + ) + ); + let violations = check_manifest(&future, &[TRIM]).expect_err("a future version must fail"); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert_eq!(violations[0].code.id, FORMAT_VERSION_UNSUPPORTED.id); + assert!( + violations[0] + .detail + .contains(&(FORMAT_VERSION + 1).to_string()), + "{:?}", + violations[0] + ); + } + + #[test] + fn an_unreadable_format_version_is_reported_as_such() { + let text = render_manifest(&[TRIM]).replace( + &format!("format_version {FORMAT_VERSION}"), + "format_version two", + ); + let violations = check_manifest(&text, &[TRIM]).expect_err("must fail"); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert_eq!(violations[0].code.id, FORMAT_VERSION_UNSUPPORTED.id); + } + + #[test] + fn a_key_merely_beginning_format_version_is_not_skipped_in_silence() { + // The prefix test this replaced swallowed any such line without reading it. + let text = format!("{}format_version_2 7\n", render_manifest(&[TRIM])); + let violations = check_manifest(&text, &[TRIM]).expect_err("must be reported"); + assert!(violations.iter().any(|v| v.code.id == MALFORMED_LINE.id)); + + // A second, well-formed version line is not data either. + let two = format!("{}format_version 1\n", render_manifest(&[TRIM])); + let violations = check_manifest(&two, &[TRIM]).expect_err("must be reported"); + assert!(violations.iter().any(|v| v.code.id == MALFORMED_LINE.id)); + } + + #[test] + fn an_older_format_version_is_a_staleness_a_regeneration_fixes_not_a_violation() { + // A version-1 file: four columns, no shape. It must still *pass* the identifier rules -- + // making it an error would leave it in a state `params-lock --write` refuses to fix, which + // is issue #117's trap -- and regenerating it must migrate it to this build's format. + let v1 = format!( + "# namir-params manifest\nformat_version 1\ntrim.gain_db {} continuous live\n", + TRIM.id.0 + ); + assert!(check_manifest(&v1, &[TRIM]).is_ok()); + + let migrated = merge_manifest(&v1, &[TRIM]); + assert!(migrated.contains(&format!("format_version {FORMAT_VERSION}\n"))); + assert_eq!(migrated, render_manifest(&[TRIM])); + + // A version-1 *tombstone* survives the migration too, shapeless as it is. + let v1_tombstone = format!("{v1}zz.retired_example 4242424242 continuous tombstoned\n"); + assert!(check_manifest(&v1_tombstone, &[TRIM]).is_ok()); + let migrated = merge_manifest(&v1_tombstone, &[TRIM]); + assert!( + migrated.contains("zz.retired_example 4242424242 continuous tombstoned\n"), + "{migrated}" + ); + assert_eq!(merge_manifest(&migrated, &[TRIM]), migrated); + } + + // --- descriptor invariants (issue #119) ------------------------------------------------------ + + #[test] + fn a_descriptor_that_contradicts_itself_is_rejected() { + let bad_index = ParamDescriptor { + kind: ParamKind::Stepped { + values: &["Mono", "Stereo"], + default_index: StepIndex(5), + }, + ..CHANNEL_MODE + }; + let violations = check_manifest("", &[bad_index]).expect_err("must be rejected"); + assert!( + violations + .iter() + .any(|v| v.code.id == INVALID_DESCRIPTOR.id) + ); + + let default_outside_range = trim_with(ParamKind::Continuous { + min: -24.0, + max: 24.0, + default: 96.0, + }); + let violations = + check_manifest("", &[default_outside_range]).expect_err("must be rejected"); + assert!( + violations + .iter() + .any(|v| v.code.id == INVALID_DESCRIPTOR.id && v.detail.contains("trim.gain_db")) + ); + } + #[test] fn malformed_old_lines_are_reported_but_do_not_panic() { let old = "not a valid manifest line at all\n"; diff --git a/params.lock b/params.lock index 8e3d11d..c90d0ff 100644 --- a/params.lock +++ b/params.lock @@ -1,40 +1,47 @@ # namir-params manifest (params.lock) -- machine-generated, do not hand-edit except to flip a -# retired parameter's line from "live" to "tombstoned" (D-10.1). Regenerate with -# `cargo run -p xtask -- params-lock --write`, which calls merge_manifest(this file, REGISTRY) -# (see crates/namir-params/src/manifest.rs): the "live" lines are re-rendered from REGISTRY and -# every "tombstoned" line already here is carried through unchanged. +# retired parameter's line from "live" to "tombstoned" (D-10.1), leaving the rest of that line +# alone. Regenerate with `cargo run -p xtask -- params-lock --write`, which calls +# merge_manifest(this file, REGISTRY) (see crates/namir-params/src/manifest.rs): the "live" lines +# are re-rendered from REGISTRY and every "tombstoned" line already here is carried through +# unchanged, so that hand edit is one the gate accepts rather than one it refuses forever. # -# Columns: key id kind live|tombstoned. One line per parameter, sorted by key. Tombstoned lines -# are retained forever -- a parameter is retired here, never deleted (FR-PARAM-020). -format_version 1 -eq.enabled 3800874476 stepped live -eq.high_pass_enabled 26969931 stepped live -eq.high_pass_freq_hz 4269819439 continuous live -eq.high_shelf_freq_hz 940854822 continuous live -eq.high_shelf_gain_db 2509494269 continuous live -eq.low_pass_enabled 1607157495 stepped live -eq.low_pass_freq_hz 2846104715 continuous live -eq.low_shelf_freq_hz 2273380658 continuous live -eq.low_shelf_gain_db 801997857 continuous live -eq.mid_freq_hz 1012857793 continuous live -eq.mid_gain_db 3175486258 continuous live -eq.mid_q 2519697679 continuous live -gate.attack_ms 1904263145 continuous live -gate.enabled 834452757 stepped live -gate.hold_ms 3834628618 continuous live -gate.release_ms 4133041258 continuous live -gate.threshold_db 746066514 continuous live -global.bypass 2600577824 stepped live -global.output_ceiling_db 2553390476 continuous live -ir.enabled 4045421189 stepped live -ir.high_cut_enabled 1597249221 stepped live -ir.high_cut_freq_hz 3178613153 continuous live -ir.level_db 3704864057 continuous live -ir.low_cut_enabled 1089690959 stepped live -ir.low_cut_freq_hz 1056762835 continuous live -nam.enabled 1703077744 stepped live -nam.normalize_enabled 2036451968 stepped live -nam.normalize_offset_db 3562711459 continuous live -out.gain_db 3486062367 continuous live -trim.dc_blocker_enabled 1770498595 stepped live -trim.gain_db 1371108501 continuous live +# Columns: key id kind live|tombstoned . One line per parameter, sorted by key. Tombstoned +# lines are retained forever -- a parameter is retired here, never deleted (FR-PARAM-020). +# +# The shape columns record what the kind tag alone does not, so that a changed range, default or +# set of stepped values shows up as a diff here instead of silently reinterpreting every saved +# preset and every host-normalised automation value carrying that id: +# continuous min= max= default= +# stepped steps= default= values= +format_version 2 +eq.enabled 3800874476 stepped live steps=2 default=1 values=1d6b388a +eq.high_pass_enabled 26969931 stepped live steps=2 default=0 values=1d6b388a +eq.high_pass_freq_hz 4269819439 continuous live min=20.0 max=500.0 default=80.0 +eq.high_shelf_freq_hz 940854822 continuous live min=1000.0 max=12000.0 default=3000.0 +eq.high_shelf_gain_db 2509494269 continuous live min=-15.0 max=15.0 default=0.0 +eq.low_pass_enabled 1607157495 stepped live steps=2 default=0 values=1d6b388a +eq.low_pass_freq_hz 2846104715 continuous live min=1000.0 max=20000.0 default=18000.0 +eq.low_shelf_freq_hz 2273380658 continuous live min=40.0 max=500.0 default=100.0 +eq.low_shelf_gain_db 801997857 continuous live min=-15.0 max=15.0 default=0.0 +eq.mid_freq_hz 1012857793 continuous live min=200.0 max=5000.0 default=1000.0 +eq.mid_gain_db 3175486258 continuous live min=-15.0 max=15.0 default=0.0 +eq.mid_q 2519697679 continuous live min=0.2 max=5.0 default=0.707 +gate.attack_ms 1904263145 continuous live min=0.1 max=50.0 default=1.0 +gate.enabled 834452757 stepped live steps=2 default=1 values=1d6b388a +gate.hold_ms 3834628618 continuous live min=0.0 max=500.0 default=30.0 +gate.release_ms 4133041258 continuous live min=1.0 max=2000.0 default=100.0 +gate.threshold_db 746066514 continuous live min=-100.0 max=0.0 default=-70.0 +global.bypass 2600577824 stepped live steps=2 default=0 values=1d6b388a +global.output_ceiling_db 2553390476 continuous live min=-60.0 max=12.0 default=0.0 +ir.enabled 4045421189 stepped live steps=2 default=1 values=1d6b388a +ir.high_cut_enabled 1597249221 stepped live steps=2 default=0 values=1d6b388a +ir.high_cut_freq_hz 3178613153 continuous live min=1000.0 max=20000.0 default=8000.0 +ir.level_db 3704864057 continuous live min=-24.0 max=24.0 default=0.0 +ir.low_cut_enabled 1089690959 stepped live steps=2 default=0 values=1d6b388a +ir.low_cut_freq_hz 1056762835 continuous live min=20.0 max=500.0 default=80.0 +nam.enabled 1703077744 stepped live steps=2 default=1 values=1d6b388a +nam.normalize_enabled 2036451968 stepped live steps=2 default=1 values=1d6b388a +nam.normalize_offset_db 3562711459 continuous live min=-12.0 max=12.0 default=0.0 +out.gain_db 3486062367 continuous live min=-60.0 max=12.0 default=0.0 +trim.dc_blocker_enabled 1770498595 stepped live steps=2 default=1 values=1d6b388a +trim.gain_db 1371108501 continuous live min=-24.0 max=24.0 default=0.0 diff --git a/xtask/src/params_lock.rs b/xtask/src/params_lock.rs index 0ae0db8..6238dfd 100644 --- a/xtask/src/params_lock.rs +++ b/xtask/src/params_lock.rs @@ -26,6 +26,23 @@ //! //! With no tombstone in the file, `merge_manifest` is byte-identical to `render_manifest`, so this //! change moves nothing in today's `params.lock` beyond its header text. +//! +//! # What the diff can see (format version 2) +//! +//! Until format version 2 a manifest line recorded `key id kind live|tombstoned`, and the kind was +//! the bare word `continuous` or `stepped`. A change to a parameter's minimum, maximum, default or +//! set of stepped values therefore moved no byte of the file and this gate stayed green — while +//! silently reinterpreting every saved preset and every host-normalised automation value carrying +//! that id (issue #121). The line now carries the range, the default and a fingerprint of the +//! stepped labels, so such a change lands here as a **stale file**: not a violation (D-10.1 reserves +//! the build failure for a changed identifier or type, and a widened range is a legitimate edit), +//! but a regeneration whose diff a reviewer reads. +//! +//! Two things follow for `--write`. A file declaring an **older** format version is migrated by it, +//! deliberately: leaving the file in a state the documented regeneration command refuses to fix is +//! the trap issue #117 named. A file declaring a **newer** one is refused in both modes, since +//! `--write` would overwrite a manifest — tombstones included — written by tooling this build does +//! not understand. // The gate now executes both of the method's conjuncts against the real REGISTRY and the real // checked-in file: `params_lock_gate_*` below drive the diff, the tombstone round-trip and each @@ -58,13 +75,12 @@ pub fn check_or_write(repo_root: &Path, write: bool) -> Result<(bool, String), S // identifier would defeat the mechanism this check exists to defend. if let Err(violations) = namir_params::check_manifest(&actual, namir_params::REGISTRY) { let mut message = format!( - "{} violates D-10.1's identifier rules. This is not a staleness a regeneration \ - fixes -- revert the source change, or retire the parameter by flipping its existing \ - line to `tombstoned` (never by deleting it, and never by reusing its id):\n", + "{} violates D-10.1's manifest rules. None of these is a staleness a regeneration \ + fixes, so each carries its own remedy rather than a shared `--write`:\n", lock_path.display() ); for violation in &violations { - message.push_str(&format!(" - {violation}\n")); + message.push_str(&format!(" - {violation}\n {}\n", violation.code.remedy)); } return Err(message); } @@ -194,13 +210,34 @@ mod tests { format!("{manifest}{line}\n") } + /// `manifest` without the line for `key`. Whole lines, because since format version 2 a line + /// carries shape columns after its `live` word: deleting the text up to that word would leave + /// the tail behind as a malformed line and change what the test is measuring. + fn without_key(manifest: &str, key: &str) -> String { + manifest + .lines() + .filter(|line| !line.starts_with(&format!("{key} "))) + .map(|line| format!("{line}\n")) + .collect() + } + + /// The whole manifest line for `key`. + fn line_for(manifest: &str, key: &str) -> String { + manifest + .lines() + .find(|line| line.starts_with(&format!("{key} "))) + .unwrap_or_else(|| panic!("no line for {key}")) + .to_string() + } + #[test] fn params_lock_gate_keeps_a_committed_tombstone_green_and_write_no_longer_deletes_it() { // The end-to-end verification issue #31 asks for. `zz.retired_example` is a key REGISTRY // has never carried, standing in for a parameter that once existed and has been retired: // its line is in the file, flipped to `tombstoned`, and its descriptor is gone from the // live set. - let tombstone = "zz.retired_example 4242424242 continuous tombstoned"; + let tombstone = + "zz.retired_example 4242424242 continuous tombstoned min=-1.0 max=1.0 default=0.0"; let dir = scratch("tombstone", &with_line(&live_manifest(), tombstone)); // 1. The gate is green with the tombstone committed. (Before M14 this failed permanently: @@ -225,11 +262,11 @@ mod tests { // The half of FR-PARAM-020 that protects a saved project: a key the manifest already // retired coming back live. `--write` is checked too -- a regeneration flag that could // rewrite its way past this would make the tombstone decorative. - let mut text = live_manifest(); - text = text.replace("trim.gain_db 1371108501 continuous live", ""); + let live = live_manifest(); + let tombstone = line_for(&live, "trim.gain_db").replace(" live ", " tombstoned "); let dir = scratch( "reuse", - &with_line(&text, "trim.gain_db 1371108501 continuous tombstoned"), + &with_line(&without_key(&live, "trim.gain_db"), &tombstone), ); for write in [false, true] { @@ -241,10 +278,7 @@ mod tests { } // Nothing was written: the file still carries the tombstone it started with. let after = fs::read_to_string(dir.join("params.lock")).unwrap(); - assert!( - after.contains("trim.gain_db 1371108501 continuous tombstoned"), - "{after}" - ); + assert!(after.contains(&tombstone), "{after}"); fs::remove_dir_all(&dir).ok(); } @@ -254,9 +288,10 @@ mod tests { // The other clause of the same sentence. A key whose recorded id no longer matches what // its key derives is `ID_CHANGED` -- the failure that silently corrupts every saved // project that used it. - let text = live_manifest().replace( - "trim.gain_db 1371108501 continuous live", - "trim.gain_db 999999999 continuous live", + let live = live_manifest(); + let text = live.replace( + &line_for(&live, "trim.gain_db"), + &line_for(&live, "trim.gain_db").replace("1371108501", "999999999"), ); let dir = scratch("id-changed", &text); let err = check_or_write(&dir, false).expect_err("a changed id must fail"); @@ -274,7 +309,7 @@ mod tests { "dropped", &with_line( &live_manifest(), - "zz.retired_example 4242424242 continuous live", + "zz.retired_example 4242424242 continuous live min=-1.0 max=1.0 default=0.0", ), ); let err = check_or_write(&dir, false).expect_err("a silent drop must fail"); @@ -301,6 +336,124 @@ mod tests { fs::remove_dir_all(&dir).ok(); } + // --- what the diff can see, and the format version (issues #121, #122, #117) --------------- + + #[test] + fn params_lock_gate_reports_a_changed_range_as_a_stale_file() { + // Issue #121, end to end. REGISTRY is a `const` and cannot be edited from a test, so the + // change is made from the file's side, which is the same comparison: a manifest recording + // a range other than the one REGISTRY declares. Under format version 1 there was no column + // to disagree in and this file was reported up to date. + let live = live_manifest(); + let stale = live.replace( + &line_for(&live, "trim.gain_db"), + &line_for(&live, "trim.gain_db").replace("min=-24.0", "min=-30.0"), + ); + assert_ne!(stale, live, "the range must be recorded to be changeable"); + let dir = scratch("range", &stale); + + let (up_to_date, message) = check_or_write(&dir, false).unwrap(); + assert!(!up_to_date, "{message}"); + assert!(message.contains("min=-30.0"), "{message}"); + assert!(message.contains("min=-24.0"), "{message}"); + + // And it is a staleness, not a violation: regeneration is the fix, and it restores the + // range REGISTRY actually declares. + assert!(check_or_write(&dir, true).unwrap().0); + let after = fs::read_to_string(dir.join("params.lock")).unwrap(); + assert!(after.contains("min=-24.0"), "{after}"); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn params_lock_gate_reports_a_changed_stepped_values_list_as_a_stale_file() { + // The same for FR-PARAM-050's shape: the fingerprint column moves when the labels do, so a + // reordering -- which re-points every stored index at a different option -- cannot pass. + let live = live_manifest(); + let stale = live.replace( + &line_for(&live, "gate.enabled"), + &line_for(&live, "gate.enabled").replace("values=", "values=f"), + ); + let dir = scratch("values", &stale); + let (up_to_date, message) = check_or_write(&dir, false).unwrap(); + assert!(!up_to_date, "{message}"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn params_lock_gate_refuses_a_manifest_from_a_future_format_version_in_both_modes() { + // Issue #122. Parsed under this build's rules, a future file reads as a pile of malformed + // lines; named as what it is, it reads as one violation. And `--write` must not overwrite + // it -- that would destroy a manifest, tombstones included, written by newer tooling. + let future = live_manifest().replace( + &format!("format_version {}", namir_params::FORMAT_VERSION), + &format!("format_version {}", namir_params::FORMAT_VERSION + 1), + ); + let dir = scratch("future", &future); + + for write in [false, true] { + let err = check_or_write(&dir, write).expect_err("a future version must fail"); + assert!( + err.contains("params.manifest.format_version_unsupported"), + "write={write}: {err}" + ); + assert!( + !err.contains("params.manifest.malformed_line"), + "write={write}: reported as malformed lines rather than a version: {err}" + ); + } + assert_eq!(fs::read_to_string(dir.join("params.lock")).unwrap(), future); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn params_lock_gate_migrates_a_format_version_1_file_on_write() { + // The other direction, and issue #117's rule applied to the version bump itself: an older + // file is a staleness the documented regeneration command fixes, never a state the gate + // refuses forever. Its tombstone survives the migration, shapeless as it was written. + let v1: String = live_manifest() + .lines() + .map(|line| { + if line.starts_with('#') { + line.to_string() + } else if line == format!("format_version {}", namir_params::FORMAT_VERSION) { + "format_version 1".to_string() + } else { + // Back to four columns: key id kind live. + line.split_whitespace() + .take(4) + .collect::>() + .join(" ") + } + }) + .map(|line| format!("{line}\n")) + .collect(); + let dir = scratch( + "v1", + &with_line(&v1, "zz.retired_example 4242424242 continuous tombstoned"), + ); + + let (up_to_date, message) = check_or_write(&dir, false).unwrap(); + assert!(!up_to_date, "{message}"); + + assert!(check_or_write(&dir, true).unwrap().0); + let after = fs::read_to_string(dir.join("params.lock")).unwrap(); + assert!( + after.contains(&format!("format_version {}", namir_params::FORMAT_VERSION)), + "{after}" + ); + assert!(after.contains("min=-24.0"), "{after}"); + assert!( + after.contains("zz.retired_example 4242424242 continuous tombstoned\n"), + "{after}" + ); + assert!(check_or_write(&dir, false).unwrap().0); + + fs::remove_dir_all(&dir).ok(); + } + #[test] fn the_real_checked_in_params_lock_passes_the_gate() { // The gate as CI runs it, against the real repository root -- the positive control every From b9ec45d71a10f048db099796e96240cdc09421f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:47:51 +0000 Subject: [PATCH 03/44] Refuse a manual-test document with no readable verdict (issue #34) M14 already taught the gate to read a **Result: line; what was missing was what happens when there is not one. A missing verdict, a verdict line opening with no token, and a PASS contradicted by its own sentence were all treated as "unreadable, therefore uncovered" -- relaxable by --allow-uncovered, and recorded nowhere for the author to fix. They now abort the run upstream of --write, --allow-uncovered and every exit-status term, on the same footing as a malformed trace annotation: a bad input rather than a coverage gap. Ten documents gained or corrected a verdict line stating what their own prose already said. No verdict was promoted: the six Verify: M Musts that record no pass (FR-IO-030, FR-IO-050, FR-UI-030, FR-UI-040, FR-UI-050, FR-UI-070) are the same six before and after, and the required half of the gate stays green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- docs/02-architecture.md | 45 +++ docs/manual-tests/README.md | 56 ++++ .../fr-clap-030-audio-ports-negotiation.md | 3 +- .../fr-clap-040-latency-restart.md | 3 +- docs/manual-tests/fr-clap-060-host-bypass.md | 3 +- .../fr-clap-090-multi-instance-memory.md | 3 +- .../manual-tests/fr-clap-100-gui-embedding.md | 3 +- docs/manual-tests/fr-io-070-device-removal.md | 4 +- .../fr-nam-020-real-lstm-models.md | 6 +- .../fr-ui-010-standalone-window-renders.md | 5 +- docs/manual-tests/fr-ui-110-brand-mark.md | 5 + .../nfr-perf-030-startup-to-audible.md | 3 +- xtask/src/main.rs | 83 +++++ xtask/src/traceability.rs | 300 ++++++++++++++---- 14 files changed, 443 insertions(+), 79 deletions(-) create mode 100644 docs/manual-tests/README.md diff --git a/docs/02-architecture.md b/docs/02-architecture.md index db6a2da..4356c1d 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -2762,6 +2762,50 @@ discovering it**: a harness that wants the host-side halves of `audio-ports`/`pa `latency` must first make `xtask attribution` resolve dependency kind per shipped path rather than per unified-resolve node, or observe those extensions another way. +*Consequence (added M15, 2026-08-28, from issue #34 — a manual-test document now has to say whether +it was run, and `xtask traceability` reads that rather than the file name).* This decision makes a +`Verify: M` Must's manual document its **entire** traced artifact, and `check_partial_verify_code` +refuses a `// trace-partial:` naming such a Must — so between "no document" and "fully covered" +there was no disposition the tool could express, and a document existing was the whole of the test. +It credited `**Result: NOT EXECUTED**` and `**Result: FAIL**` exactly as it credited a clean pass: +the gate printed `clean -- all 130 Must requirements are covered` while six of those Musts' scripts +recorded no pass at all. The *Rejected — teaching `xtask traceability` to accept a manual-test +document as coverage for `Verify: I`* argument above already named the mechanism in as many words +("it cannot check whether a document's claim to have been executed is true"); it was true of the +`M` arm too, where the document is not a proposal but the standing rule. + +**The verdict convention.** Every file under `docs/manual-tests/` — not only the ones a `Verify: M` +Must resolves to — must carry at least one **verdict line**: a line beginning `**Result:` whose +first words are one of exactly four upper-case tokens, `PASS`, `FAIL`, `PARTIAL` or `NOT EXECUTED`, +followed by whatever prose the author wants. Only `PASS` credits a requirement. Where a document +carries several verdict lines the least favourable wins, so a later run recording one executed step +cannot promote a script that is otherwise unexecuted. The convention is documented for its authors +in `docs/manual-tests/README.md`; `VERDICT_TOKENS` in `xtask/src/traceability.rs` is the only +definition the tool reads. + +*Why a missing or malformed verdict is a hard error, not a gap.* A document with no verdict line, a +verdict line opening with something other than a token, or a `PASS` contradicted by its own sentence +aborts the whole `traceability` run, upstream of `--write`, of `--allow-uncovered` and of every +exit-status term — the same standing D-23.1's malformed annotations have, for the same reason: it is +a bad **input**, not a coverage gap. Left as a gap it would be relaxable by a flag; left as a credit +it would be the defect this note closes. The pre-M15 parser downgraded a self-contradicting `PASS` +silently, which got the outcome right and told the author nothing about which half of their sentence +won. + +*Why the check covers supplementary documents too.* For any code other than `M` the document is +supplementary evidence and the gate never reads its verdict for coverage — but its executed-ness is +exactly as easy to misread by a human, and the `Verify:` code of a requirement is not frozen (FRS +§1.5 freezes identifiers, as the *Rejected — amending the `Verify` code* argument above records). +Eight of the twenty-six live documents carried no verdict line when this landed, five of them +supplementary documents for Musts. Only `README.md` is exempt, by exact name, because it is the +convention rather than a run. + +*What this does not change.* No requirement became less met, and no verdict was promoted to keep a +build green: the six Musts this makes visible — FR-IO-030, FR-IO-050, FR-UI-030, FR-UI-040, +FR-UI-050 and FR-UI-070 — were already in exactly this state, recorded in their own documents in +plain English. D-18.5's split is untouched: they are uncovered Musts, which is the half +`--allow-uncovered` relaxes and which becomes required at the flip. + --- **Decision D-18.7 (added M9b's P0 decision pass, 2026-08-12; resolves the blocker D-18.6 above @@ -3713,3 +3757,4 @@ drift was findable. | 0.32 | 2026-08-12 | **FR-NAM-060 measured for the first time, and both of Namir's resamplers failed it as configured.** D-9.3 has always said the resampling configuration is "verified by a direct measurement test, not by trusting the library's defaults"; nothing had ever measured either site, and the defaults were wrong in the same way at both. `rubato` places an antialiasing filter's cutoff at a fraction of the lower Nyquist that *depends on the filter's length*, so an under-length filter loses passband rather than merely widening its transition. The NAM stage's `FftFixedInOut` pair measured **-15 dB at 20 kHz** for a 48 kHz model at a 192 kHz engine rate (-5.6 dB for a 44.1 kHz model at 96 kHz), because its FFT — and therefore its filter — was a flat 256 *engine* frames, which is 64 frames in the model's domain at that ratio. `namir-ir`'s `resample_mono`, a `SincFixedIn` at `sinc_len = 256`, measured **-17.7 dB** of stopband attenuation at 192 -> 48 kHz against the 100 dB bar FR-IR-030 imports, a windowed sinc's transition band extending past the lower Nyquist where it aliases. Both are fixed by configuration alone, the first branch of the three the milestone allowed: `MIN_RESAMPLE_FFT_FRAMES` sizes each FFT to >= 256 frames **in the lower rate's domain**, and the IR path moves from `rubato`'s sinc family to the same FFT resampler. Measured after: **ripple <= 2e-5 dB and stopband attenuation >= 129 dB** across ten engine/model pairs and nine IR source/engine pairs, both directions and the round trip, against 0.1 dB and 100 dB. Nothing changes at a 48 kHz engine rate, including against a 44.1 kHz model, where the old and new sizing rules coincide; what the fix costs is latency at engine rates *above* the model rate, now a constant ~5.3 ms internal block rather than a constant 256 frames. The instrument is `namir_fixtures::resample_response`, shared by both crates so one yardstick measures both, calibrated against an identity converter, an analytic one-pole low-pass, and a deliberately under-configured resampler it must fail. **Neither requirement closes completely**: "0.1 dB up to 20 kHz **or the Nyquist frequency, whichever is lower**" is unsatisfiable by any resampler once the lower rate drops below ~40 kHz, which `SampleRate::new` permits, so both tags are `trace-partial:` and the clause is referred to the 1.0 exit gate as an FRS question rather than a coverage gap. | | 0.33 | 2026-08-12 | **M14 Phase 0: two risk rows answered in place, no decision rewritten, no code.** **R-13's stated test has fired and its reinterpretation is now written down at the row itself**, which is the point of the exercise — the partial count went **56 -> 68** against a row that said a count not falling by M12 means the mechanism is being used as a bypass. The arithmetic is recorded there: the twenty *uncovered* Musts M9a left are now zero, M9b converted twelve of them and then demoted two of its own plain tags on finding they over-claimed, and M14's A2 pass demoted two more. Every movement made the ledger weaker and truer, which is the opposite of the failure the row predicted — so the count is **demoted from a test to an indicator**, the uncovered count and the per-milestone re-reading of `uncovered:` fields are named as the replacement tests, and **the row stays open at Medium** rather than being declared mitigated, because no cheap mechanical discriminator between the two kinds of partial has been designed. **R-11 (signing) is recorded as still open and explicitly not decided** — issue #23 turns on whether 1.0 is a public release, which is an owner's question, and the signed CI path is unbuilt either way. **R-9 is unchanged and stays reopened.** Eighteen `*Consequence*` notes land in the FRS rather than here (see `01-functional-requirements.md` change log 0.8): the sub-40 kHz clause at FR-NAM-060/FR-IR-030, FR-CHAIN-070's Should dropped, NFR-PORT-030's method kept as a door-open check, FR-STATE-040's compound method, seven accepted limitations and five items recorded as still open. **No decision in this document is amended and no new D-number is added** — Phase 0 was a disposition pass over existing decisions, and where one is affected the note sits at the requirement it governs. | | 0.34 | 2026-08-12 | **M14 Phase 4b: A2 is compared against `NeuralAmpModelerCore` for the first time, and the comparison holds.** Two generated A2 fixtures (`a2_full.nam`, `a2_lite.nam`, seed 30, D-19.1) rendered through the pinned reference build (`3cde95c`, `-DNAM_USE_INLINE_GEMM -DNAM_ENABLE_A2_FAST=OFF`, built outside the repository) over the same `input_10s.wav` the two existing goldens use, asserted in-process: **A2-Full -132.58 dB, A2-Lite -126.46 dB**. `FR-NAM-030` and `FR-NAM-150` are promoted from `trace-partial:` to plain `trace:` **by closing their `uncovered:` fields, not by promoting the tags** — the golden set now spans all three configurations this crate runs, and FR-NAM-150's probe clause is met by the 10-second signal rather than by `a2_fixtures.rs`'s 4 000-sample probe, which was *shorter* than A2's 6 346-sample receptive field and is raised to 20 000 in the same pass. The golden bar tightens from -85 dB to FR-NAM-030's own **-90 dB**, because a plain tag cannot be carried by an assertion looser than the requirement it claims to verify; all four fixtures clear it by ≥36 dB, and the headroom that spends is recorded at the constant (M10's `Standard`-shape cross-check sat at -90.3 to -90.9 dB). **FR-NAM-110's method is performed for the first time**: `crates/namir-nam/tests/latency.rs` drives an impulse through every architecture, differences it against the model's own zero-input response, and cross-correlates — the previous evidence was two tests reading an accessor whose body is the literal `0` and asserting it equalled `0`, which would have passed unchanged had inference introduced delay. Its tag stays `trace-partial:`, narrowed to the residue in `namir-engine` (`NamStage`'s `SlotResampler` latency, asserted only as `> 0`) and re-booked M8 → M14. **R-9 is narrowed, not retired**, severity High → Medium: the silent-wrong-weight-order failure it was raised about is now excluded by a real-reference comparison, and this pass also resolves the contradiction in its own reopening text — M10's recorded "A2 Full and A2 Lite at -90.31 dB each" cannot have been an A2 measurement, since the two shapes measure -132.58 and -126.46. What stays open is stated rather than absorbed: no genuine trainer-produced A2 export has ever been loaded, so a *shared* misreading of the schema between generator, parser and reference target is invisible to every test in the tree; and upstream's default `NAM_ENABLE_A2_FAST=ON` path is not what these renders exercise — a rationale for excluding it is now recorded at `golden_reference.rs`'s header where before there was none, which is not the same as a measurement, and none was taken. Partial count 68 → 66. | +| 0.35 | 2026-08-28 | **A manual-test document now has to say whether it was run, and the traceability gate reads that instead of the file name (issue #34).** D-18.6 gains a `*Consequence (added M15, 2026-08-28)*` note holding the verdict convention: every file under `docs/manual-tests/` carries a line beginning `**Result:` opening with one of `PASS`, `FAIL`, `PARTIAL` or `NOT EXECUTED`; only `PASS` credits a requirement; the worst line in a document wins; and a missing, tokenless or self-contradicting verdict is a **hard error** that aborts the run upstream of `--write`, `--allow-uncovered` and every exit-status term, on D-23.1's malformed-annotation footing — a bad input, not a coverage gap. `docs/manual-tests/README.md` is added as the authors' copy of the rule and is the one file exempt from it. Eight live documents carried no verdict line and were given one recording what their own prose already said; two carried a verdict line no token opened (`fr-ui-010`'s self-contradicting `PASS`, corrected to `PARTIAL`, and `fr-io-070`'s second line). No verdict was promoted and no requirement became more met: the six Musts left uncovered — FR-IO-030, FR-IO-050, FR-UI-030, FR-UI-040, FR-UI-050 and FR-UI-070 — are the same six their documents already recorded as NOT EXECUTED, PARTIAL or FAIL. | diff --git a/docs/manual-tests/README.md b/docs/manual-tests/README.md new file mode 100644 index 0000000..c0355ab --- /dev/null +++ b/docs/manual-tests/README.md @@ -0,0 +1,56 @@ +# Manual-test documents — the verdict convention + +Every file in this directory is one requirement's manual test: the script a human runs, and the +record of what happened when someone ran it. `xtask traceability` reads these files, so the record +has to be machine-readable as well as readable. + +This file is the convention. It is the one file here that is *not* a manual test, and the only name +`xtask` exempts from the rule below (`VERDICT_EXEMPT_FILES`, `xtask/src/traceability.rs`). + +## The rule + +**Every document in this directory must carry at least one verdict line.** A verdict line is a line +whose text *begins* `**Result:` and whose first words after that marker are one of exactly four +tokens, in upper case: + +| Token | Means | +|---|---| +| `PASS` | The whole script was run, and every step passed. | +| `FAIL` | The script was run and something it asserts does not hold. | +| `PARTIAL` | Some of the script was run; some of it was not, or some of it failed. | +| `NOT EXECUTED` | None of the script has been run. | + +Everything after the token is ordinary prose — say what was run, on what machine, by whom, and what +was not. The token is what the tool reads; the prose is what the next person reads. + +```markdown +**Result: PASS.** All six steps executed on the §2 reference machine, 2026-08-27. + +**Result: PARTIAL.** Steps 1-2 executed; step 3 needs a display and was not run. + +**Result: NOT EXECUTED this session (no Linux/macOS hardware available).** +``` + +Four consequences worth knowing before you write one: + +- **Only `PASS` credits its requirement.** For a `Verify: M` Must, this document *is* the traced + artifact (D-18.6), so anything else leaves that requirement uncovered in `docs/03-test-plan.md` + and in the gate's own uncovered list. That is the point: before M15 the gate matched a filename + and printed `clean -- all 130 Must requirements are covered` while six of those Musts' scripts + recorded `NOT EXECUTED`, `PARTIAL` or `FAIL` (issue #34). +- **A missing or malformed verdict is a hard error** that aborts the whole `xtask traceability` + run, in the same way and for the same reason a malformed `// trace:` annotation does: it is a bad + input, not a coverage gap, and no flag — `--write` and `--allow-uncovered` included — reaches past + it. +- **The worst verdict in a document wins**, not the first. A document may carry more than one + verdict line (a later run recording one step of a script the rest of which is still unexecuted); + the gate takes the least favourable. +- **`PASS` may not contradict itself.** A `PASS` line that goes on to say some part was `NOT + EXECUTED` / `NOT RUN` is refused, not quietly downgraded. Write `PARTIAL` and keep the sentence. + +## What does not change + +Recording a `FAIL`, a `PARTIAL` or a `NOT EXECUTED` honestly is the normal, expected state of a +document here, and has been for milestones at a time — see this directory's own history. Never +promote a verdict to make a gate green. Run the script, or leave the record as it is and let the +requirement read uncovered. diff --git a/docs/manual-tests/fr-clap-030-audio-ports-negotiation.md b/docs/manual-tests/fr-clap-030-audio-ports-negotiation.md index 2cb757e..1ba7bb3 100644 --- a/docs/manual-tests/fr-clap-030-audio-ports-negotiation.md +++ b/docs/manual-tests/fr-clap-030-audio-ports-negotiation.md @@ -48,7 +48,8 @@ or expects the plugin to, is host UI/routing behaviour no headless validator exe ## Executed run (this session) -**Automated half executed, real-host half not executed.** `clap-validator validate` (both +**Result: PARTIAL.** Automated half executed, real-host half not executed. +`clap-validator validate` (both `--in-process` and out-of-process/default modes) ran against the built `namir_clap.dll` in this session's own environment: **44 tests run, 32 passed, 0 failed, 0 warnings, 12 skipped, exit code 0**, including every `process-audio-*` and `layout-audio-ports-*` test group. This agent session diff --git a/docs/manual-tests/fr-clap-040-latency-restart.md b/docs/manual-tests/fr-clap-040-latency-restart.md index 74f1751..5f76078 100644 --- a/docs/manual-tests/fr-clap-040-latency-restart.md +++ b/docs/manual-tests/fr-clap-040-latency-restart.md @@ -65,7 +65,8 @@ this process. ## Executed run (this session) -**Not executed.** This agent session has no way to load a real DAW project, set a session sample +**Result: NOT EXECUTED.** This agent session has no way to load a real DAW project, set a +session sample rate deliberately mismatched from a model's declared rate, or observe a host's PDC indicator — see `docs/manual-tests/fr-ui-010-standalone-window-renders.md`'s identical limitation note. What *is* verified automatically: `clap-validator`'s full suite (32 passed, 0 failed, 0 warnings) confirms diff --git a/docs/manual-tests/fr-clap-060-host-bypass.md b/docs/manual-tests/fr-clap-060-host-bypass.md index b70ad0f..153546f 100644 --- a/docs/manual-tests/fr-clap-060-host-bypass.md +++ b/docs/manual-tests/fr-clap-060-host-bypass.md @@ -60,7 +60,8 @@ human ear", which is what FR-CLAP-060 actually asks to be verified. ## Executed run (this session) -**Not executed** (requires a real host and audible confirmation). This agent session has no way to +**Result: NOT EXECUTED** (requires a real host and audible confirmation). This agent session +has no way to play audio through a host or listen for a click — see `docs/manual-tests/fr-ui-010-standalone-window-renders.md`'s identical limitation note. What *is* verified this session: the `IS_BYPASS` flag is present and correctly shaped (unit test, passing), diff --git a/docs/manual-tests/fr-clap-090-multi-instance-memory.md b/docs/manual-tests/fr-clap-090-multi-instance-memory.md index 7eefc20..b9944b2 100644 --- a/docs/manual-tests/fr-clap-090-multi-instance-memory.md +++ b/docs/manual-tests/fr-clap-090-multi-instance-memory.md @@ -70,7 +70,8 @@ actually asks to be measured. ## Executed run (this session) -**Not executed** (real-host memory measurement). This agent session has no host to load multiple +**Result: NOT EXECUTED** (real-host memory measurement). This agent session has no host to +load multiple plugin instances into and no way to observe a host process's memory footprint — see `docs/manual-tests/fr-ui-010-standalone-window-renders.md`'s identical limitation note. What *is* verified automatically and stands as strong indirect evidence: every unit-level sharing test named diff --git a/docs/manual-tests/fr-clap-100-gui-embedding.md b/docs/manual-tests/fr-clap-100-gui-embedding.md index d8aebe1..21ee29b 100644 --- a/docs/manual-tests/fr-clap-100-gui-embedding.md +++ b/docs/manual-tests/fr-clap-100-gui-embedding.md @@ -56,7 +56,8 @@ without an actual host process and a screen. ## Executed run (this session) -**Not executed** (steps 2–3, requiring a visible window and mouse/keyboard interaction). This +**Result: PARTIAL.** Steps 2–3 not executed (they require a visible window and mouse/keyboard +interaction); step 1 was executed later and is recorded in its own section below. This agent session has no way to interact with a real window — see `docs/manual-tests/fr-ui-010-standalone-window-renders.md`'s identical limitation note, which `spikes/s4-clack-clap`'s own S-4 record already established the same embedding mechanism works for diff --git a/docs/manual-tests/fr-io-070-device-removal.md b/docs/manual-tests/fr-io-070-device-removal.md index c56579f..45eb0c1 100644 --- a/docs/manual-tests/fr-io-070-device-removal.md +++ b/docs/manual-tests/fr-io-070-device-removal.md @@ -127,8 +127,8 @@ device" — is **unbuilt**, which this file already recorded as a known gap; wha person has now watched it not happen on real hardware. FR-UI-010's "differing only in the presence of the audio-device panel" presumes a panel that does not exist either. -**Result: step 2 EXECUTED 2026-08-27, and it fails its naming clause while passing the -crash/hang/clean-stop clauses. Steps 1 and 3 remain NOT EXECUTED**, step 1 for want of a failable +**Result: PARTIAL.** Step 2 executed 2026-08-27, and it fails its naming clause while passing the +crash/hang/clean-stop clauses. **Steps 1 and 3 remain NOT EXECUTED**, step 1 for want of a failable device (R-5's residual risk, unchanged) and step 3 because the capability it exercises is unbuilt. FR-IO-070 as a whole is therefore **not met**: the requirement's "allow the user to select another device" clause has no implementation, and its "report the condition" clause reports a condition diff --git a/docs/manual-tests/fr-nam-020-real-lstm-models.md b/docs/manual-tests/fr-nam-020-real-lstm-models.md index 71807aa..2c94ce9 100644 --- a/docs/manual-tests/fr-nam-020-real-lstm-models.md +++ b/docs/manual-tests/fr-nam-020-real-lstm-models.md @@ -81,7 +81,8 @@ better of the two outcomes that comment anticipated. ## Result -**PASS**, for the claim this test exists to check: **real, third-party LSTM `.nam` exports parse and +**Result: PASS.** For the claim this test exists to check: **real, third-party LSTM `.nam` +exports parse and run end-to-end through the plugin path in a real host.** That claim had never been tested before this run, and it is the half of FR-NAM-020 the automated suite structurally cannot reach. @@ -191,7 +192,8 @@ value. ### Result -**PASS**, for the claim this section exists to check: **for the seven real LSTM models tested, +**Result: PASS.** For the claim this section exists to check: **for the seven real LSTM models +tested, spanning `num_layers` 1-4 and a spread of `hidden_size`, `namir-nam`'s LSTM inference matches `NeuralAmpModelerCore`'s own reference render to well inside FR-NAM-030's -90 dB floor** -- the worst of the seven margins is still 24.28 dB of headroom (`LSTM-4-001`), and the rest cluster diff --git a/docs/manual-tests/fr-ui-010-standalone-window-renders.md b/docs/manual-tests/fr-ui-010-standalone-window-renders.md index c927dc8..3786342 100644 --- a/docs/manual-tests/fr-ui-010-standalone-window-renders.md +++ b/docs/manual-tests/fr-ui-010-standalone-window-renders.md @@ -61,5 +61,6 @@ nothing overlapping) still needs a human to actually look at the screen once. Le honestly-unexecuted part of this script, per this project's manual-test convention of recording what was and wasn't actually run rather than asserting a result nobody observed. -**Result: PASS for steps 1–2 (executed). Step 3 requires a human with a display — not executed this -session.** +**Result: PARTIAL.** PASS for steps 1–2 (executed). Step 3 requires a human with a display — +not executed this session. (Verdict token corrected to `PARTIAL` at M15: the sentence was always +this one, and `PASS` was never what it recorded — see `docs/manual-tests/README.md`.) diff --git a/docs/manual-tests/fr-ui-110-brand-mark.md b/docs/manual-tests/fr-ui-110-brand-mark.md index c450d89..9f49273 100644 --- a/docs/manual-tests/fr-ui-110-brand-mark.md +++ b/docs/manual-tests/fr-ui-110-brand-mark.md @@ -228,6 +228,11 @@ executed**. ## Status after M13 +**Result: PARTIAL.** The brand-mark clause is closed and observed; the executable-icon clause is +built but has never been seen on an executable, and the window-icon clause is unknown. Steps 1-4 +were not executed in the build environment and steps 5-7 have not been run at all — the three +paragraphs below say which is which. + **Brand-mark clause: closed** (M12, observed). **Executable-icon clause: the artifact is built, generated, gated and validated as a Windows icon; diff --git a/docs/manual-tests/nfr-perf-030-startup-to-audible.md b/docs/manual-tests/nfr-perf-030-startup-to-audible.md index ac2e55e..3200adf 100644 --- a/docs/manual-tests/nfr-perf-030-startup-to-audible.md +++ b/docs/manual-tests/nfr-perf-030-startup-to-audible.md @@ -52,7 +52,8 @@ connected and monitoring audible, and with a guitar or DI source plugged into th ## Executed run (this session, 2026-08-11) -**Partially executed. The by-ear step was not executed and cannot be by an agent session.** +**Result: PARTIAL.** Partially executed: the by-ear step was not executed and cannot be by an +agent session. Executed, on the §2 reference machine (AMD Ryzen 9 5950X, 63.9 GB, Windows 11 build 26200), with a PreSonus AudioBox 22VSL as both input and output device: diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5508b78..0d9d7fc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -656,6 +656,20 @@ fn traceability_outcome(root: &Path, write: bool, allow_uncovered: bool) -> Trac // "stale" false-positive this session found the hard way. manual_test_docs.sort_by(|a, b| a.0.cmp(&b.0)); + // Issue #34: a manual-test document's verdict is read, and a document that does not carry a + // readable one aborts the run here -- upstream of the `--write` branch, of `--allow-uncovered` + // and of every exit-status term, for exactly the reasons the malformed-annotation refusal below + // gives. A document whose result cannot be read is a bad input, not a coverage gap: left as a + // gap it would be relaxable by a flag, and left as a credit it would be the defect this check + // exists to remove -- `xtask traceability` printed `clean -- all 130 Must requirements are + // covered` while six of those Musts' scripts recorded NOT EXECUTED, PARTIAL or FAIL. + for (name, content) in &manual_test_docs { + if let Err(e) = traceability::check_manual_verdict(name, content) { + println!("traceability: docs/manual-tests/{name} {e}"); + return TraceabilityRun::failed(); + } + } + // (crate_root, crate_name-per-first-path-component) -- xtask has no further nesting, so its // own directory name is used directly rather than derived per file. let mut files_with_crate: Vec<(PathBuf, String)> = Vec::new(); @@ -1903,6 +1917,75 @@ mod tests { } } + #[test] + fn a_manual_document_with_no_readable_verdict_is_refused_end_to_end() { + // Issue #34, end to end. The document exists and is named for its requirement, which is + // all the pre-M15 gate ever asked of it -- so without this check the run goes green with + // FR-CHAIN-010 resolved by a file nobody has run. It is refused rather than counted as a + // gap because it is a malformed input: `--write` must not write a plan built from a + // verdict the tool could not read, and `--allow-uncovered` must not relax it. + for (name, body) in [ + ("verdict-missing", "# FR-CHAIN-010\n\nRun it and see.\n"), + ( + "verdict-tokenless", + "**Result: ran it, seemed fine.** No token here.\n", + ), + ( + "verdict-self-contradicting", + "**Result: PASS.** Step 3 was not executed this session.\n", + ), + ] { + let dir = synthetic_root(name, 1); + std::fs::write( + dir.join("docs/manual-tests/fr-chain-010-signal-chain.md"), + body, + ) + .unwrap(); + + let run = traceability_outcome(&dir, true, true); + assert!(!run.ok, "{name}"); + assert!( + !dir.join("docs/03-test-plan.md").exists(), + "{name}: a plan must never be written from a verdict the tool refused" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + } + + #[test] + fn a_manual_document_recording_no_pass_leaves_its_must_uncovered_end_to_end() { + // The other half of issue #34, and the one that moves a number: a verdict the tool *can* + // read, saying the script did not pass. That is a coverage gap, not a malformed input -- + // so the required half of the gate still holds (the plan regenerates, §14 agrees) and + // it is `--allow-uncovered` alone that decides the exit status. + let dir = synthetic_root("verdict-not-executed", 1); + std::fs::write( + dir.join("docs/manual-tests/fr-chain-010-signal-chain.md"), + "**Result: NOT EXECUTED.** Needs a display and a human.\n", + ) + .unwrap(); + + assert!( + !traceability_outcome(&dir, true, false).ok, + "an unexecuted script is not coverage" + ); + assert!( + traceability_outcome(&dir, true, true).ok, + "and it is a coverage gap, which is exactly what --allow-uncovered relaxes" + ); + let plan = std::fs::read_to_string(dir.join("docs/03-test-plan.md")).unwrap(); + assert!( + plan.contains( + "**UNRESOLVED** — `docs/manual-tests/fr-chain-010-signal-chain.md` \ + records `NOT EXECUTED.`" + ), + "the plan names the document and what it records:\n{plan}" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn r_13s_printed_count_names_exactly_the_plans_partial_rows() { // R-13's mitigation (d) is a number put in front of whoever runs the gate, and (b) is the diff --git a/xtask/src/traceability.rs b/xtask/src/traceability.rs index dea349f..5afed49 100644 --- a/xtask/src/traceability.rs +++ b/xtask/src/traceability.rs @@ -684,17 +684,23 @@ pub struct Report { /// a document whose result is not known, and an unknown result must not be a pass at a 1.0 gate. /// /// A line that opens `PASS` but goes on to say some part was not executed is **not** a pass here -/// (`fr-ui-010-standalone-window-renders.md` is the live instance: "PASS for steps 1–2 (executed). -/// Step 3 requires a human with a display — not executed this session"). The document's author -/// wrote both halves; taking the headline word alone would discard the half that matters. +/// (`fr-ui-010-standalone-window-renders.md` was the live instance until M15: "PASS for steps 1–2 +/// (executed). Step 3 requires a human with a display — not executed this session"). The +/// document's author wrote both halves; taking the headline word alone would discard the half that matters. As +/// of M15 that shape is a **hard error** rather than a silent downgrade — see +/// [`parse_manual_verdict`] — because the verdict token is what the gate reads and a token +/// contradicted by its own sentence is a malformed verdict, not a verdict to interpret. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ManualVerdict { /// The script was run and passed, with no clause reported unexecuted. Pass, - /// A verdict was found and it is not a clean pass — `NOT EXECUTED`, `PARTIAL`, `FAIL`, or a - /// `PASS` qualified by an unexecuted step. Carries the verdict text as written. + /// A verdict was found and it is not a clean pass — `NOT EXECUTED`, `PARTIAL` or `FAIL`. + /// Carries the verdict text as written. NotAPass(String), - /// No line this parser recognises as a verdict. Carries a fixed explanation rather than a + /// The document's verdict could not be read at all: no verdict line, or one this parser + /// refuses. Produced only by [`manual_test_verdict`]'s lenient wrapper — a real run never + /// reaches it, because [`check_manual_verdict`] aborts first — and it exists so that even a + /// bypassed validation cannot end in a *credit*. Carries a fixed explanation rather than a /// quotation, there being nothing to quote. Unreadable, } @@ -740,22 +746,117 @@ const VERDICT_MARKER: &str = "**Result"; /// the whole-file matching M13 removed from [`declared_requirement_ids`]'s neighbouring arm: the /// lines read are still only those *beginning* with [`VERDICT_MARKER`], never prose that mentions /// a result in passing. +/// +/// **The lenient wrapper.** This never fails: a verdict [`parse_manual_verdict`] refuses becomes +/// [`ManualVerdict::Unreadable`], which credits nothing. The strict form is what the gate calls +/// (through [`check_manual_verdict`]), one file at a time, upstream of every exit-status term; this +/// one exists so that [`build_report`] -- which has no file name to name in an error and is called +/// directly by tests with arbitrary text -- still cannot turn a malformed verdict into a credit. pub fn manual_test_verdict(content: &str) -> ManualVerdict { - content - .lines() - .map(str::trim_start) - .filter(|line| line.starts_with(VERDICT_MARKER)) - .map(classify_verdict_line) - .reduce(ManualVerdict::worse_of) - .unwrap_or(ManualVerdict::Unreadable) + parse_manual_verdict(content).unwrap_or(ManualVerdict::Unreadable) +} + +/// The four verdict tokens a manual-test document's verdict line may open with (M15, issue #34). +/// +/// Upper case, exactly as written here. The convention is documented for its authors in +/// `docs/manual-tests/README.md` and in `docs/02-architecture.md` D-18.6's +/// *Consequence (added M15, 2026-08-28)* note; this array is the only definition the tool reads. +pub const VERDICT_TOKENS: [&str; 4] = ["PASS", "FAIL", "PARTIAL", "NOT EXECUTED"]; + +/// Files under `docs/manual-tests/` that are not manual-test scripts and so carry no verdict. +/// Exactly one today: the README that documents the convention itself. Matched by exact file name, +/// so the exemption cannot be widened by a naming accident. +const VERDICT_EXEMPT_FILES: [&str; 1] = ["README.md"]; + +/// The gate's own entry point: `Ok(())` if `file_name`'s document carries a well-formed verdict, +/// `Err()` if it does not (issue #34, M15). +/// +/// **Refusing is chosen over inferring**, and for the same reason [`scan_annotations`] refuses a +/// malformed annotation rather than dropping it: a document whose verdict cannot be read is a bad +/// *input*, not a coverage gap, so it must abort the run rather than move a coverage count that +/// `--allow-uncovered` can then relax. Silently treating it as uncovered would be softer than the +/// tree deserves in one direction and unexplained in the other -- the author would learn that their +/// requirement had gone red, but not that the cause was a missing four-word line. +/// +/// Every file the loader reads is checked, not only the ones a `Verify: M` Must resolves to. Two +/// reasons. A document written for a `Verify: I`/`G`/`B`/`S` requirement is D-18.6 supplementary +/// evidence whose own executed-ness is exactly as easy to misread as a traced one's -- five of the +/// eight documents that carried no verdict line at all when this check was written were of that +/// kind, and four of those five are Musts. And the set is not static: a `Verify:` code can change, +/// at which point a document that never had to state a verdict would start crediting a Must. +pub fn check_manual_verdict(file_name: &str, content: &str) -> Result<(), String> { + if VERDICT_EXEMPT_FILES.contains(&file_name) { + return Ok(()); + } + parse_manual_verdict(content).map(|_| ()) +} + +/// Reads `content`'s verdict strictly: the worst of its verdict lines, or an error naming what is +/// wrong with the document. +/// +/// The three refusals, each of them a document that cannot be believed rather than a document +/// recording bad news: +/// +/// 1. **No verdict line at all.** Eight of the twenty-six live documents were in this state when +/// this was written, several of them recording "not executed" in prose their own heading made +/// perfectly clear to a human and invisible to the gate. +/// 2. **A verdict line opening with something other than a [`VERDICT_TOKENS`] token.** The token is +/// the machine-readable half of the convention; without it the gate would be back to reading +/// English, which is how `**Result: step 2 EXECUTED ... and it fails its naming clause**` came +/// to exist and would have to be adjudicated. +/// 3. **A `PASS` token contradicted by its own sentence** (`NOT EXECUTED`/`NOT RUN` later on the +/// same line). The pre-M15 parser downgraded this to `NotAPass` silently, which was right about +/// the outcome and wrong about the cause: the author owes the document a `PARTIAL`, and being +/// told so is how the next reader of that line learns which half won. +pub fn parse_manual_verdict(content: &str) -> Result { + let mut verdict: Option = None; + for line in content.lines().map(str::trim_start) { + if !line.starts_with(VERDICT_MARKER) { + continue; + } + let one = classify_verdict_line(line)?; + verdict = Some(match verdict { + None => one, + Some(seen) => seen.worse_of(one), + }); + } + verdict.ok_or_else(|| { + format!( + "carries no verdict line -- a manual-test document must have a line beginning \ + `{VERDICT_MARKER}:` whose first words are one of {}, so the gate reads what the run \ + recorded rather than that the file exists (issue #34). See \ + docs/manual-tests/README.md", + joined_tokens() + ) + }) +} + +/// `PASS, FAIL, PARTIAL or NOT EXECUTED`, for the error messages. +fn joined_tokens() -> String { + let (last, rest) = VERDICT_TOKENS.split_last().expect("tokens are non-empty"); + format!("{} or {last}", rest.join(", ")) } /// One `**Result` line's own verdict, with no view of the rest of the document. -fn classify_verdict_line(line: &str) -> ManualVerdict { +fn classify_verdict_line(line: &str) -> Result { + // Both real spellings reduce to the same body: `**Result: PASS.** ...` and `**Result:** PASS.` + // The stripped set is the punctuation the marker can be dressed in, never a word. let body = line .trim_start_matches(VERDICT_MARKER) - .trim_start_matches(':') - .trim(); + .trim_start_matches(|c: char| c == ':' || c == '*' || c.is_whitespace()); + + let Some(token) = VERDICT_TOKENS + .iter() + .find(|token| opens_with_token(body, token)) + else { + return Err(format!( + "verdict line `{}` does not open with a verdict token -- write one of {} (upper case) \ + immediately after `{VERDICT_MARKER}:`, then say the rest in prose. See \ + docs/manual-tests/README.md", + truncate_for_message(line), + joined_tokens() + )); + }; // Two different spans, deliberately. // @@ -763,21 +864,43 @@ fn classify_verdict_line(line: &str) -> ManualVerdict { // that is the verdict the author set apart, and the prose that follows it on the same physical // line is the start of a paragraph, not part of the verdict. // - // What is *classified* is the whole line, because a qualifier can sit outside the bold run - // ("**Result: PASS.** Step 3 was not executed"), and reading only the emphasised half would - // discard exactly the clause that decides the question. + // What is *checked* for a self-contradiction is the whole line, because a qualifier can sit + // outside the bold run ("**Result: PASS.** Step 3 was not executed"), and reading only the + // emphasised half would discard exactly the clause that decides the question. let quoted = body.split("**").next().unwrap_or(body).trim(); - if quoted.is_empty() { - return ManualVerdict::Unreadable; + if *token != "PASS" { + return Ok(ManualVerdict::NotAPass(quoted.to_string())); } let upper = body.to_uppercase(); - let qualified = upper.contains("NOT EXECUTED") || upper.contains("NOT RUN"); - if upper.starts_with("PASS") && !qualified { - ManualVerdict::Pass - } else { - ManualVerdict::NotAPass(quoted.to_string()) + if upper.contains("NOT EXECUTED") || upper.contains("NOT RUN") { + return Err(format!( + "verdict line `{}` opens `PASS` and then records something not executed -- the token \ + is what the gate reads, so a verdict that contradicts itself is refused rather than \ + quietly downgraded. Write `PARTIAL` and keep the sentence. See \ + docs/manual-tests/README.md", + truncate_for_message(line) + )); + } + Ok(ManualVerdict::Pass) +} + +/// Whether `body` opens with `token` as a whole word: the token must be followed by the end of the +/// line or by something that is not a letter or digit, so `PARTIALLY` is not `PARTIAL` and +/// `PASSABLE` is not `PASS`. +fn opens_with_token(body: &str, token: &str) -> bool { + body.strip_prefix(token) + .is_some_and(|rest| rest.chars().next().is_none_or(|c| !c.is_alphanumeric())) +} + +/// A verdict line, cut to something an error message can carry on one screen. +fn truncate_for_message(line: &str) -> String { + const LIMIT: usize = 72; + if line.chars().count() <= LIMIT { + return line.to_string(); } + let head: String = line.chars().take(LIMIT).collect(); + format!("{head}...") } /// The plan cell and the reason line for a `Verify: M` Must whose document does not record a pass. @@ -786,9 +909,12 @@ fn manual_verdict_reason(verdict: &ManualVerdict) -> Option { match verdict { ManualVerdict::Pass => None, ManualVerdict::NotAPass(text) => Some(format!("records `{text}`")), + // Unreachable in a real run: `check_manual_verdict` aborts the whole gate on a document + // this state comes from. Kept because a `build_report` call that skipped that check must + // still not credit the requirement. ManualVerdict::Unreadable => Some(format!( - "carries no line beginning `{VERDICT_MARKER}`, so its result is unknown -- which is \ - not a pass" + "carries no readable line beginning `{VERDICT_MARKER}`, so its result is unknown -- \ + which is not a pass" )), } } @@ -1888,42 +2014,80 @@ mod tests { } #[test] - fn a_pass_qualified_by_an_unexecuted_step_is_not_a_pass() { - // `fr-ui-010-standalone-window-renders.md`'s own verdict. Reading the headline word alone - // would discard the half of the sentence that matters, which is the direction of error - // this check exists to refuse. - assert!(matches!( - manual_test_verdict(&manual_doc( - "**Result: PASS for steps 1–2 (executed). Step 3 requires a human with a display \ - — not executed this session.**" - )), - ManualVerdict::NotAPass(_) - )); + fn a_pass_qualified_by_an_unexecuted_step_is_refused_outright() { + // `fr-ui-010-standalone-window-renders.md`'s own verdict until M15, when the token + // convention made this shape a hard error and the document was corrected to `PARTIAL`. + // Reading the headline word alone would discard the half of the sentence that matters; + // downgrading it silently, as the pre-M15 parser did, gets the outcome right and tells the + // author nothing about which half won. + let doc = manual_doc( + "**Result: PASS for steps 1–2 (executed). Step 3 requires a human with a display \ + — not executed this session.**", + ); + let err = parse_manual_verdict(&doc).expect_err("a self-contradicting PASS is refused"); + assert!(err.contains("contradicts itself"), "{err}"); + // And the lenient wrapper still cannot turn it into a credit. + assert_eq!(manual_test_verdict(&doc), ManualVerdict::Unreadable); + } + + #[test] + fn a_document_with_no_verdict_line_is_refused_never_a_pass() { + // Eight of the twenty-six live documents were in this state when the check was written. + // Silence is not a pass -- and since M15 it is not a quiet gap either. + for content in [ + // No outcome section at all. + "# A script with no outcome section\n\nSteps: 1, 2, 3.\n", + // A mid-paragraph mention: the marker must begin its line, like every other marker + // this module reads. + "The **Result: PASS.** claim below is prose, not a verdict.\n", + ] { + let err = parse_manual_verdict(content).expect_err("no verdict line"); + assert!(err.contains("carries no verdict line"), "{err}"); + assert_eq!(manual_test_verdict(content), ManualVerdict::Unreadable); + } } #[test] - fn a_document_with_no_verdict_line_is_unreadable_never_a_pass() { - // Eight of the twenty-six live documents are in this state. Silence is not a pass. - assert_eq!( - manual_test_verdict("# A script with no outcome section\n\nSteps: 1, 2, 3.\n"), - ManualVerdict::Unreadable - ); - // Nor is a marker with nothing after it. - assert_eq!( - manual_test_verdict("**Result:**\n"), - ManualVerdict::Unreadable - ); - // A qualifier outside the bold run still decides the verdict, even though only the bold - // run is quoted back. + fn a_verdict_line_without_one_of_the_four_tokens_is_refused() { + // `fr-io-070-device-removal.md`'s second verdict line until M15: a real, carefully-written + // sentence that no parser should have to adjudicate. The token is the machine-readable + // half of the convention, and its absence is a malformed input. + for line in [ + "**Result: step 2 EXECUTED 2026-08-27, and it fails its naming clause.**", + // A marker with nothing after it. + "**Result:**", + // Lower case is not the token: the convention says upper case, so that a document + // saying `pass` in passing cannot become a verdict. + "**Result: pass, all six steps.**", + // A longer word that merely starts with a token is not that token. + "**Result: PASSABLE, with reservations.**", + ] { + let doc = manual_doc(line); + let err = parse_manual_verdict(&doc).expect_err("no verdict token"); + assert!(err.contains("does not open with a verdict token"), "{err}"); + assert_eq!(manual_test_verdict(&doc), ManualVerdict::Unreadable); + } + // The other real spelling, with the colon inside the bold run, is accepted. assert_eq!( - manual_test_verdict("**Result: PASS.** Step 3 was not executed this session.\n"), - ManualVerdict::NotAPass("PASS.".to_string()) + manual_test_verdict(&manual_doc("**Result:** PASS, all six steps.")), + ManualVerdict::Pass ); - // Nor a mid-paragraph mention: the marker must begin its line, like every other marker - // this module reads. - assert_eq!( - manual_test_verdict("The **Result: PASS.** claim below is prose, not a verdict.\n"), - ManualVerdict::Unreadable + } + + #[test] + fn the_readme_is_the_one_file_exempt_from_carrying_a_verdict() { + // It documents the convention rather than recording a run; every other file in the + // directory is checked, whatever `Verify:` code its requirement carries, because a + // supplementary document's executed-ness is exactly as easy to misread as a traced one's. + let convention = "# Manual-test documents\n\nWrite `**Result: PASS.**` when it passes.\n"; + assert!(check_manual_verdict("README.md", convention).is_ok()); + assert!(check_manual_verdict("fr-chain-010-signal-chain.md", convention).is_err()); + assert!( + check_manual_verdict( + "fr-chain-010-signal-chain.md", + &manual_doc("**Result: PASS.**") + ) + .is_ok() ); } @@ -1937,21 +2101,20 @@ mod tests { // The shape the live tree has: conservative line first. Unchanged by this rule. assert_eq!( manual_test_verdict( - "**Result: NOT EXECUTED against a real failable device.**\n\n **Result: step 2 EXECUTED and passing.**\n" + "**Result: NOT EXECUTED against a real failable device.**\n\n **Result: PARTIAL.** Step 2 executed and passing.\n" ), ManualVerdict::NotAPass("NOT EXECUTED against a real failable device.".to_string()) ); // The shape that would have been credited: pass first, disqualification second. assert_eq!( manual_test_verdict( - "**Result: PASS, all six steps.**\n\n **Result: steps 7-9 NOT EXECUTED -- no second interface available.**\n" + "**Result: PASS, all six steps.**\n\n **Result: NOT EXECUTED** -- steps 7-9, no second interface available.\n" ), - ManualVerdict::NotAPass( - "steps 7-9 NOT EXECUTED -- no second interface available.".to_string() - ) + ManualVerdict::NotAPass("NOT EXECUTED".to_string()) ); - // An unreadable line beats a pass elsewhere: a line the parser cannot make sense of is not - // evidence, and crediting the document on a different one discards it in silence. + // A refused line anywhere in the document beats a pass elsewhere: since M15 the whole + // document is refused rather than credited on its other line, and the lenient wrapper's + // fallback is `Unreadable`, never a credit. assert_eq!( manual_test_verdict("**Result: PASS, all six steps.**\n\n**Result:**\n"), ManualVerdict::Unreadable @@ -2024,10 +2187,13 @@ mod tests { "fr-chain-010-signal-chain.md".to_string(), "# A script with no outcome section\n".to_string(), )]; + // The defensive path, and the reason `ManualVerdict::Unreadable` still exists after M15: + // a real run cannot get here (`check_manual_verdict` aborts on this document first), and a + // `build_report` call that skipped that check must still fall to uncovered, never a credit. let report = build_report(&reqs, &docs, &HashMap::new(), &HashMap::new()); assert_eq!(report.missing.len(), 1); let (_, reason) = report.manual_unexecuted.get("FR-CHAIN-010").unwrap(); - assert!(reason.contains("no line beginning"), "{reason}"); + assert!(reason.contains("no readable line beginning"), "{reason}"); } #[test] From 6644aeb26de2aceaf78a0f01ab0190b1751ca0a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:49:22 +0000 Subject: [PATCH 04/44] Drive three tests through the real path they claimed to cover (#114, #102, #89) FR-STATE-020's corpus had no preset with a references section, so a writer and reader that agreed with each other on a wrong key name went undetected. Adds section 9's worked example as hand-authored bytes, both slots, embedded present and absent, with a real BLAKE3 hash. Renaming a key in both to_value and from_value now fails these two tests and nothing else. The UI intent test called host.dispatch itself. It now runs four real NamirUi::frame passes over a shared egui Context and drags the widget; dropping the dispatch in frame() fails it, which the old body survived. #89's premise is stale -- 608fdde landed the real-callback harness at M14. What remained is the integer-format path: a converter wrapping the real callback, which is what a device invokes when it will not take f32, ran under no harness. It does now, at both i32 and I24. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-app/src/audio_io/convert.rs | 86 +++++++++- crates/namir-app/src/stream.rs | 131 ++++++++------ crates/namir-state/tests/corpus.rs | 79 +++++++++ .../unreleased-v1/references.namirpreset | 25 +++ crates/namir-ui/src/app.rs | 162 ++++++++++++++++-- 5 files changed, 404 insertions(+), 79 deletions(-) create mode 100644 crates/namir-state/tests/corpus/unreleased-v1/references.namirpreset diff --git a/crates/namir-app/src/audio_io/convert.rs b/crates/namir-app/src/audio_io/convert.rs index 58a2e2e..a555c94 100644 --- a/crates/namir-app/src/audio_io/convert.rs +++ b/crates/namir-app/src/audio_io/convert.rs @@ -378,14 +378,16 @@ mod tests { } } - /// NFR-RT-010, proved rather than asserted: neither converter allocates once built, in either - /// format, including on a callback several times the pre-sized scratch length (the path that - /// would have to grow a buffer if the chunking were wrong). + /// NFR-RT-010 for the conversion arithmetic itself: neither converter allocates once built, in + /// either format, including on a callback several times the pre-sized scratch length (the path + /// that would have to grow a buffer if the chunking were wrong). /// - /// The closures the converters hold are the RT-safe shape [`crate::stream`] actually installs — - /// they write into or read from the buffer they are handed and nothing else. A closure that - /// allocated would make this test fail for its own reason, which is the point: the harness - /// covers the whole callback body, not only the arithmetic. + /// **The closures held here are stand-ins, not the ones [`crate::stream`] installs** — they + /// write into or read from the buffer they are handed and nothing else, so what this test + /// isolates is the converter's own chunking and arithmetic. (This doc comment used to claim + /// they *were* the shape `crate::stream` installs, which was never something this test checked: + /// issue #89.) The real pair is driven, converters and all, by + /// [`the_real_stream_callbacks_run_allocation_free_inside_both_converters`] below. #[test] fn neither_converter_allocates_once_the_stream_is_built() { let mut phase = 0.0f32; @@ -433,4 +435,74 @@ mod tests { in_i32.len() + in_i24.len() ); } + + /// **The callbacks a real `cpal` stream actually invokes, run through the real converters, + /// under D-7.5's allocation harness.** Issue #89: `audio_section` had exactly one caller in + /// this crate, and the closures it wrapped were the stand-ins above; M14 put + /// [`crate::stream`]'s own callbacks under the harness *bare*, and this closes the last gap + /// between the two — the integer-format path, where the callback the device calls is a + /// converter wrapping the very closure `crate::stream::open` built. + /// + /// Composed exactly as `crate::audio_io::cpal_impl::build_converting_input`/ + /// `build_converting_output` compose it: the callback [`crate::stream::open`] handed the + /// backend, moved into [`InputConverter`]/[`OutputConverter`] with the same + /// `cpal_impl::scratch_samples` length a real open would pre-size (a whole 512-frame default + /// block, deliberately larger than the engine's own `max_block_size`, so both chunking loops — + /// the converter's and the stream callback's — run more than once per callback). + /// + /// The first callback of each direction is driven *outside* the harness: `build_output`'s first + /// invocation elevates the thread's priority once (D-13.2), which a real stream also pays once. + #[test] + fn the_real_stream_callbacks_run_allocation_free_inside_both_converters() { + const MAX_BLOCK: usize = 64; + let backend = crate::stream::FakeBackend::new(); + let xruns = std::sync::Arc::new(crate::xrun::XrunCounter::new()); + let _streams = crate::stream::open( + crate::stream::fake_duplex_setup(&backend, MAX_BLOCK), + crate::stream::default_test_engine(MAX_BLOCK), + std::sync::Arc::clone(&xruns), + |_, _| {}, + ) + .unwrap(); + + let input_cb = backend.input_data.lock().unwrap().take().unwrap(); + let output_cb = backend.output_data.lock().unwrap().take().unwrap(); + + // One mono capture channel, two playback channels -- `fake_duplex_setup`'s own params, + // with `buffer_frames: None`, which is what makes `block_frames` answer its default. + let input_scratch = crate::audio_io::block_frames(None); + let output_scratch = crate::audio_io::block_frames(None) * 2; + let mut input = InputConverter::new(input_cb, input_scratch); + let mut output = OutputConverter::new(output_cb, output_scratch); + + // Device buffers longer than the scratch, so the converters chunk too. Both lengths stay a + // whole number of frames (the output side is even), which is the invariant chunking has to + // preserve for the interleave phase to survive a chunk boundary. + let in_i32 = [123_456_789i32; 1400]; + let in_i24 = [I24::new(1_234_567).unwrap(); 1400]; + let mut out_i32 = [0i32; 2800]; + let mut out_i24 = [I24::new(0).unwrap(); 2800]; + + // Warm-up, un-asserted: see this test's own doc comment. + input.drain(&in_i32); + output.fill(&mut out_i32); + + let mut saw_output = false; + for _ in 0..8 { + audio_section(|| input.drain(&in_i32)); + audio_section(|| output.fill(&mut out_i32)); + saw_output |= out_i32.iter().any(|c| *c != 0); + audio_section(|| input.drain(&in_i24)); + audio_section(|| output.fill(&mut out_i24)); + saw_output |= out_i24.iter().any(|c| c.inner() != 0); + } + + // The run has to have produced real audio somewhere, or every assertion above would have + // held over callbacks that returned early -- the same guard `crate::stream`'s own harness + // test uses, and for the same reason. + assert!( + saw_output, + "every output callback produced silence -- nothing above was actually exercised" + ); + } } diff --git a/crates/namir-app/src/stream.rs b/crates/namir-app/src/stream.rs index 3e2edb5..0cdd518 100644 --- a/crates/namir-app/src/stream.rs +++ b/crates/namir-app/src/stream.rs @@ -488,68 +488,85 @@ impl AudioBackend for FakeBackend { } } +/// A duplex [`StreamSetup`] over `backend`: one mono input channel, two output channels, 48 kHz, +/// shared mode. `pub(crate)` for the same reason [`FakeBackend`] itself is — the tests in +/// `crate::audio_io::convert` drive the very callbacks this setup produces, and a second copy of +/// the setup would be free to drift away from the one every other test uses. #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::AtomicUsize; +pub(crate) fn fake_duplex_setup(backend: &FakeBackend, max_block_size: usize) -> StreamSetup<'_> { + fake_duplex_setup_with_share_mode(backend, max_block_size, ShareMode::Shared) +} - fn setup(backend: &FakeBackend, max_block_size: usize) -> StreamSetup<'_> { - setup_with_share_mode(backend, max_block_size, ShareMode::Shared) +/// As [`fake_duplex_setup`], with the share mode both directions are opened with chosen by the +/// caller. +#[cfg(test)] +pub(crate) fn fake_duplex_setup_with_share_mode( + backend: &FakeBackend, + max_block_size: usize, + share_mode: ShareMode, +) -> StreamSetup<'_> { + StreamSetup { + backend, + input_host: HostInfo { + name: "fake".to_string(), + }, + input_device: DeviceInfo { + name: "in".to_string(), + is_default: true, + }, + input_params: StreamParams { + sample_rate_hz: 48_000, + buffer_frames: None, + channels: 1, + share_mode, + }, + output_host: HostInfo { + name: "fake".to_string(), + }, + output_device: DeviceInfo { + name: "out".to_string(), + is_default: true, + }, + output_params: StreamParams { + sample_rate_hz: 48_000, + buffer_frames: None, + channels: 2, + share_mode, + }, + channel_config: ChannelConfig::MonoToStereo, + input_channel_index: 0, + output_channel_left: 0, + output_channel_right: 1, + max_block_size, } +} - fn setup_with_share_mode( - backend: &FakeBackend, - max_block_size: usize, - share_mode: ShareMode, - ) -> StreamSetup<'_> { - StreamSetup { - backend, - input_host: HostInfo { - name: "fake".to_string(), - }, - input_device: DeviceInfo { - name: "in".to_string(), - is_default: true, - }, - input_params: StreamParams { - sample_rate_hz: 48_000, - buffer_frames: None, - channels: 1, - share_mode, - }, - output_host: HostInfo { - name: "fake".to_string(), - }, - output_device: DeviceInfo { - name: "out".to_string(), - is_default: true, - }, - output_params: StreamParams { - sample_rate_hz: 48_000, - buffer_frames: None, - channels: 2, - share_mode, - }, - channel_config: ChannelConfig::MonoToStereo, - input_channel_index: 0, - output_channel_left: 0, - output_channel_right: 1, - max_block_size, - } - } +/// A real default chain, split into the [`AudioEngine`] half [`open`] runs from the output +/// callback. `pub(crate)` for the same reason [`fake_duplex_setup`] is. +#[cfg(test)] +pub(crate) fn default_test_engine(max_block_size: usize) -> AudioEngine { + let c = namir_engine::PrepareContext::new( + namir_core::SampleRate::new(48_000).unwrap(), + max_block_size, + ChannelConfig::MonoToStereo, + ) + .unwrap(); + let chain = namir_engine::build_default_chain(&c).unwrap(); + let (engine, _endpoint) = namir_engine::split(chain, namir_engine::RingCapacities::default()); + engine +} - fn engine(max_block_size: usize) -> AudioEngine { - let c = namir_engine::PrepareContext::new( - namir_core::SampleRate::new(48_000).unwrap(), - max_block_size, - ChannelConfig::MonoToStereo, - ) - .unwrap(); - let chain = namir_engine::build_default_chain(&c).unwrap(); - let (engine, _endpoint) = - namir_engine::split(chain, namir_engine::RingCapacities::default()); - engine - } +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + // The three helpers below live at module level (and `pub(crate)`) so `audio_io::convert`'s + // tests can build the same duplex path this module's own tests do; aliased back to their + // original names here so every test body below reads as it always has. + use super::{ + default_test_engine as engine, fake_duplex_setup as setup, + fake_duplex_setup_with_share_mode as setup_with_share_mode, + }; /// Wiring proof: input capture reaches the output buffer, duplicated into both channels /// (`ChannelConfig::MonoToStereo`), with no crash and no underrun when supply matches demand. diff --git a/crates/namir-state/tests/corpus.rs b/crates/namir-state/tests/corpus.rs index 005b778..3920ddf 100644 --- a/crates/namir-state/tests/corpus.rs +++ b/crates/namir-state/tests/corpus.rs @@ -29,6 +29,7 @@ const MANIFEST: &[&str] = &[ "unreleased-v1/unknown-fields.namirpreset", "unreleased-v1/future-version.namirpreset", "unreleased-v1/legacy-global-section.namirpreset", + "unreleased-v1/references.namirpreset", ]; fn corpus_dir() -> PathBuf { @@ -149,6 +150,84 @@ fn legacy_global_section_restores_bypass_and_ceiling() { assert_eq!(state.params.get("trim.gain_db"), Some(2.0)); } +/// Section 7 of `docs/04-state-and-preset-format.md`, read from hand-authored bytes rather than +/// from anything this crate's own writer produced: both reference slots, every documented field +/// of each, and FR-STATE-080's `embedded` object. Until this fixture existed, `references` was +/// exercised only by writer-to-reader round trips inside this crate — the exact weakness this +/// file's module doc names as its own reason to exist, since a reader that is accidentally +/// *stricter* than the documented format still agrees with the writer that shares its +/// assumptions. +/// +/// `nam` carries §9's worked example verbatim (its `embedded.data`, its `display_name`, and a +/// foreign-platform `absolute`); `ir` is the same shape with no `embedded`, so the optional field +/// is exercised present *and* absent from on-disk bytes. `nam`'s `hash` is the real BLAKE3 hash of +/// the embedded payload, which is what makes P7's "identity is the content hash" checkable here +/// rather than merely a well-formed hex string. +#[test] +fn references_restore_every_documented_field_of_both_slots() { + let bytes = read_corpus_file("unreleased-v1/references.namirpreset"); + let (state, warnings) = namir_state::State::read(&bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + + let nam = state.nam.expect("references.nam is present in the fixture"); + assert_eq!( + nam.hash.to_string(), + "dc57749e025523f24f989853b68405829607c4c84942579df0c3368694a531e3" + ); + assert_eq!( + nam.library_relative.as_ref().map(|p| p.as_str()), + Some("marshall/plexi.nam") + ); + // §7.1: `absolute` is verbatim and opaque -- a Windows-authored path is carried through this + // Linux/macOS-or-Windows reader unparsed, backslashes and drive letter intact. + assert_eq!( + nam.absolute.as_deref(), + Some("C:\\Users\\erwan\\Models\\plexi.nam") + ); + assert_eq!(nam.display_name, "plexi.nam"); + let embedded = nam.embedded.expect("the nam slot carries an embedded copy"); + assert_eq!(embedded.media_type, "application/vnd.namir.nam+json"); + assert_eq!( + embedded.data, + br#"{"fake":"minimal nam-shaped json for corpus seeding"}"# + ); + // P7: the recorded identity really is the content hash of the bytes carried alongside it. + assert_eq!(nam.hash, namir_core::ContentHash::of(&embedded.data)); + + let ir = state.ir.expect("references.ir is present in the fixture"); + assert_eq!( + ir.hash.to_string(), + "175b38765489b554a27a588061510a764a62f844dae9dfca6710eeda59055d13" + ); + assert_eq!( + ir.library_relative.as_ref().map(|p| p.as_str()), + Some("cabs/1960a.wav") + ); + assert_eq!( + ir.absolute.as_deref(), + Some("/home/erwan/irs/cabs/1960a.wav") + ); + assert_eq!(ir.display_name, "1960a.wav"); + assert!( + ir.embedded.is_none(), + "`embedded` is optional -- the ir slot must load without one" + ); +} + +/// The other direction of the same claim: what this build *writes* for a reference is +/// byte-for-byte the shape the hand-authored fixture holds. A reader-only tolerance (accepting a +/// field the writer never emits, or emitting one §7 does not document) would pass +/// [`references_restore_every_documented_field_of_both_slots`] and fail here. +#[test] +fn writing_the_references_fixture_back_reproduces_its_documented_section() { + let bytes = read_corpus_file("unreleased-v1/references.namirpreset"); + let (state, _warnings) = namir_state::State::read(&bytes).unwrap(); + + let on_disk: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let written: serde_json::Value = serde_json::from_slice(&state.write()).unwrap(); + assert_eq!(written["references"], on_disk["references"]); +} + /// A document from a build newer than this one (`format_version: 2`, greater than /// `namir_state::FORMAT_VERSION`) must not be rejected outright -- D-11.2's stated purpose is /// exactly this case. `migrate.rs`, landing later in this milestone, will add the specific diff --git a/crates/namir-state/tests/corpus/unreleased-v1/references.namirpreset b/crates/namir-state/tests/corpus/unreleased-v1/references.namirpreset new file mode 100644 index 0000000..7c65dcb --- /dev/null +++ b/crates/namir-state/tests/corpus/unreleased-v1/references.namirpreset @@ -0,0 +1,25 @@ +{ + "format_version": 1, + "parameters": { + "trim.gain_db": 3.0 + }, + "references": { + "ir": { + "absolute": "/home/erwan/irs/cabs/1960a.wav", + "display_name": "1960a.wav", + "hash": "175b38765489b554a27a588061510a764a62f844dae9dfca6710eeda59055d13", + "library_relative": "cabs/1960a.wav" + }, + "nam": { + "absolute": "C:\\Users\\erwan\\Models\\plexi.nam", + "display_name": "plexi.nam", + "embedded": { + "data": "eyJmYWtlIjoibWluaW1hbCBuYW0tc2hhcGVkIGpzb24gZm9yIGNvcnB1cyBzZWVkaW5nIn0=", + "encoding": "base64", + "media_type": "application/vnd.namir.nam+json" + }, + "hash": "dc57749e025523f24f989853b68405829607c4c84942579df0c3368694a531e3", + "library_relative": "marshall/plexi.nam" + } + } +} diff --git a/crates/namir-ui/src/app.rs b/crates/namir-ui/src/app.rs index 109fec6..d9102e2 100644 --- a/crates/namir-ui/src/app.rs +++ b/crates/namir-ui/src/app.rs @@ -250,7 +250,7 @@ mod tests { use super::*; use crate::host::RecordingHost; use namir_params::REGISTRY; - use namir_params::stages::trim; + use namir_params::stages::gate; fn headless_frame(view: &mut ViewState, snapshot: &UiSnapshot, intents: &mut Vec) { let ctx = egui::Context::default(); @@ -389,27 +389,159 @@ mod tests { ); } - /// A `SetParam` intent from a section round-trips to the host via `NamirUi::frame`'s real - /// `UiHost::dispatch` path -- checked here at the `RecordingHost` level (private-field access - /// is available because this `tests` module is a descendant of `app`, where `NamirUi::host` - /// is declared). + /// The `RawInput` a headless frame runs on, `events` swapped in per call -- the same shape + /// [`headless_frame`] builds, exposed separately because driving an interaction needs several + /// consecutive frames against one shared [`egui::Context`] rather than one throwaway frame. + /// `time` advances so `egui`'s own click/drag timing logic sees successive instants. + fn frame_input(time: f64, events: Vec) -> egui::RawInput { + egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(960.0, 640.0), + )), + events, + ..Default::default() + } + } + + /// Every text `render` painted this frame, with the rect it occupies on screen -- how a test + /// finds a *real* control to interact with without this module exposing a widget id or a + /// layout constant to it. `egui` emits one `Shape::Text` per painted galley (nested inside + /// `Shape::Vec` for a panel's contents), so a control's own name and its formatted value are + /// both locatable by the exact string the user reads on screen. + fn painted_texts(output: &egui::FullOutput) -> Vec<(String, egui::Rect)> { + fn walk(shape: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match shape { + egui::Shape::Text(text) => { + out.push((text.galley.text().to_string(), text.visual_bounding_rect())); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + walk(shape, out); + } + } + _ => {} + } + } + let mut texts = Vec::new(); + for clipped in &output.shapes { + walk(&clipped.shape, &mut texts); + } + texts + } + + /// The rect of the one shape whose painted text is exactly `needle`. Asserts uniqueness: a + /// string that appears twice on screen (a section heading that repeats a control's name, say) + /// would otherwise silently pick whichever came first and make the interaction below land + /// somewhere other than the control this test means to drive. + fn unique_text_rect(output: &egui::FullOutput, needle: &str) -> egui::Rect { + let matches: Vec = painted_texts(output) + .into_iter() + .filter(|(text, _)| text == needle) + .map(|(_, rect)| rect) + .collect(); + assert_eq!( + matches.len(), + 1, + "expected exactly one control painting {needle:?} this frame, found {} -- if a \ + default value changed so two controls now read the same text, drive a different \ + control rather than relaxing this", + matches.len() + ); + matches[0] + } + + /// **The glue `NamirUi::frame` is: snapshot → [`render`] → collect intents → `UiHost::dispatch`.** + /// Driven end to end here by dragging a real control in a real frame, rather than by calling + /// `dispatch` directly -- which is what this test used to do, and which only re-tested + /// `RecordingHost`'s own `Vec::push` while leaving the one path it claimed to cover untested + /// (issue #102). + /// + /// The control is located by the text it actually paints (`unique_text_rect`), so nothing here + /// depends on a layout constant or a widget id this module would have to expose: the pointer + /// lands wherever `render` really put the Gate Threshold `DragValue` this frame. Three frames, + /// because that is what a drag is: press, move (the frame the value changes on), release. #[test] fn dispatched_intents_from_a_frame_reach_the_host() { + let snapshot = UiSnapshot::default(); + let before = snapshot + .params + .get(gate::THRESHOLD_DB.key) + .expect("gate.threshold_db is a REGISTRY entry"); let host = RecordingHost { - snapshot: UiSnapshot::default(), + snapshot, dispatched: Vec::new(), }; let mut namir_ui = NamirUi::new(host); - namir_ui.host.dispatch(UiIntent::SetParam { - key: trim::GAIN_DB.key, - value: 3.0, - }); + let ctx = egui::Context::default(); + + // Frame 0, no input: find where `render` put the control's value, by its own painted text. + let output = ctx.run_ui(frame_input(0.0, Vec::new()), |ui| namir_ui.frame(ui)); + let value_rect = unique_text_rect(&output, &gate::THRESHOLD_DB.format_value(before)); + let pos = value_rect.center(); + + // Frame 1: press on it. Pressing alone changes nothing, so nothing may reach the host yet. + let _ = ctx.run_ui( + frame_input( + 0.1, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + ), + |ui| namir_ui.frame(ui), + ); + assert!( + namir_ui.host.dispatched.is_empty(), + "a press with no movement must not dispatch anything, got {:?}", + namir_ui.host.dispatched + ); + + // Frame 2: drag right. This is the frame `DragValue::changed()` fires on, so this is the + // frame `render` appends a `SetParam` and `frame` hands it to the host. + let moved = pos + egui::vec2(40.0, 0.0); + let _ = ctx.run_ui( + frame_input(0.2, vec![egui::Event::PointerMoved(moved)]), + |ui| namir_ui.frame(ui), + ); + let dispatched = namir_ui.host.dispatched.clone(); assert_eq!( - namir_ui.host.dispatched, - vec![UiIntent::SetParam { - key: trim::GAIN_DB.key, - value: 3.0 - }] + dispatched.len(), + 1, + "the drag frame must dispatch exactly one intent, got {dispatched:?}" + ); + let UiIntent::SetParam { key, value } = dispatched[0] else { + panic!("dragging a control must dispatch SetParam, got {dispatched:?}"); + }; + assert_eq!(key, gate::THRESHOLD_DB.key); + // Deliberately not an exact figure: how many dB a 40-pixel drag is worth is `egui`'s own + // mapping of `DragValue::speed`, not a property of this crate. What this crate owns is + // that a rightward drag raises *this* control's value and reports it to the host. + assert!( + value > before, + "a rightward drag must raise the value: {value} vs {before}" + ); + + // Frame 3: release. The intent already dispatched stands; nothing new is invented on the + // way out of the gesture. + let _ = ctx.run_ui( + frame_input( + 0.3, + vec![egui::Event::PointerButton { + pos: moved, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ), + |ui| namir_ui.frame(ui), ); + assert_eq!(namir_ui.host.dispatched, dispatched); } } From 95bf9d76e22a236d83c02c339ed8e6e5837c29b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:49:22 +0000 Subject: [PATCH 05/44] Make three tests able to fail (#125, #55, #50) FR-GATE-020's test used a bare decaying envelope with no carrier, so nothing it asserted rested on hysteresis. It now drives an 82.41 Hz E2 carrier and sweeps hold_ms across the FRS range, with a falsifier at the shortest hold. This exposes a real DSP defect, recorded rather than papered over: at hold settings below ~5 ms the gate does not meet FR-GATE-020. The detector's 1 ms symmetric one-pole lets the rectified carrier ripple ~9 dB p-p, wider than the shipped 3 dB hysteresis, giving 62 close events at hold 0. FR-GATE-010's table admits 0 ms, so this is in range. The tag is demoted to trace-partial with the gap named. FR-IR-030 skipped its stopband assertion whenever the measurement was None. Now unwrapped and asserted for all 24 pairs -- measured: every pair reports Some, so the assertion is unconditional. The .nam fuzz target only parsed; it now runs new_state and process_block at three block sizes. NFR-SEC-010 demoted to trace-partial: probe_metadata, the weights-free read the library scanner runs on every indexed file, is still reached by no target. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-dsp/src/gate.rs | 112 +++++++++++++++--- crates/namir-ir/src/convolver.rs | 37 ++++-- .../namir-nam/fuzz/fuzz_targets/load_nam.rs | 72 +++++++++-- 3 files changed, 183 insertions(+), 38 deletions(-) diff --git a/crates/namir-dsp/src/gate.rs b/crates/namir-dsp/src/gate.rs index 962a60e..d647743 100644 --- a/crates/namir-dsp/src/gate.rs +++ b/crates/namir-dsp/src/gate.rs @@ -261,38 +261,116 @@ mod tests { assert_eq!(gate.status(), GateStatus::Closed); } - // trace: FR-GATE-020 - #[test] - fn hysteresis_prevents_chatter_on_a_slowly_decaying_signal() { + /// E2, the low-E fundamental of a standard-tuned guitar: the lowest note the instrument + /// ahead of this gate actually produces, and the hardest case for the detector, whose + /// rectified ripple grows as the carrier frequency falls towards its own time constant. + const LOW_E_HZ: f64 = 82.41; + + /// FR-GATE-010's hold range (0..500 ms): both ends, the default, and the values between where + /// hold and hysteresis trade off. The test below filters this list rather than spanning it, + /// and the two settings the filter drops are the subject of its `// uncovered:` field. + const FRS_HOLD_MS: [f32; 8] = [0.0, 1.0, 5.0, 10.0, 30.0, 100.0, 250.0, 500.0]; + + /// Counts transitions into `Closing` — FR-GATE-020's "close event" — while a low-E note + /// decaying from -10 dBFS to about -97 dBFS over 5 s passes down through the gate's + /// threshold (-70 dBFS) and its hysteresis band. + /// + /// The carrier is the whole point. A bare decaying envelope, which is what this test used + /// before, is monotonic at the detector's output, so it crosses the close threshold once + /// whatever the parameters are: it reports one close event with the hysteresis removed + /// entirely, and so cannot falsify the requirement it was annotated for (issue #125). A real + /// note is a carrier, the detector sees its rectified ripple, and the ripple is what + /// hysteresis exists to bridge. + fn close_events_on_a_decaying_low_e(hold_ms: f32, hysteresis_db: f32) -> u32 { let sample_rate = 48_000u32; let mut gate = NoiseGate::new(sr(sample_rate)); - // A smoothly, slowly decaying "envelope" (no carrier ripple, so the only reason for - // multiple close events would be missing hysteresis, not envelope-follower artefacts). - // Starts well above threshold, decays over several seconds down through the threshold - // region and on to silence. - let total = sample_rate as usize * 5; - // Starts comfortably above the open threshold (-70 dBFS) and, over the 5 s window, decays - // to well below the close threshold (threshold - hysteresis) — about -97 dBFS by the end - // — so the envelope actually crosses the hysteresis band once, rather than asymptoting - // to a level still above it. + gate.set_params(GateParams { + hold_ms, + hysteresis_db, + ..GateParams::default() + }); + + // Starts comfortably above the open threshold and, over the 5 s window, decays to well + // below the close threshold (threshold - hysteresis) — so the note actually crosses the + // hysteresis band once, rather than asymptoting to a level still above it. let start_linear = namir_core::db_to_linear(-10.0); let tau = (sample_rate as f64) * 0.5; // slow decay relative to detector/attack times. + let radians_per_sample = 2.0 * std::f64::consts::PI * LOW_E_HZ / sample_rate as f64; let mut closing_transitions = 0u32; let mut prev_status = gate.status(); - for n in 0..total { - let amplitude = start_linear * (-(n as f64) / tau).exp() as f32; - let mut sample = [amplitude]; + for n in 0..sample_rate as usize * 5 { + let envelope = start_linear * (-(n as f64) / tau).exp() as f32; + let mut sample = [envelope * (radians_per_sample * n as f64).sin() as f32]; gate.process(&mut sample); if gate.status() == GateStatus::Closing && prev_status != GateStatus::Closing { closing_transitions += 1; } prev_status = gate.status(); } + closing_transitions + } + + // trace-partial: FR-GATE-020 + // uncovered: FR-GATE-020 — the method ("exactly one close event") is asserted over + // uncovered: FR-GATE-010's hold range from 5 ms up. At 0 and 1 ms the same decaying low-E + // uncovered: note produces 62 and 61 close events with the shipped 3 dB gap: the 1 ms + // uncovered: detector ripples about 9 dB peak-to-peak on an 82 Hz carrier and the gap is + // uncovered: narrower than the ripple. That is a gate defect rather than a test gap — 12 dB + // uncovered: of hysteresis, or a detector whose release is slow relative to the lowest + // uncovered: program frequency, produces exactly one at every hold — so the two settings are + // uncovered: left unasserted rather than pinned to today's numbers; closes M8 + #[test] + fn hysteresis_prevents_chatter_on_a_decaying_low_e_note() { + // (a) The requirement's own method, over the hold settings it holds for. 5 ms is the + // shortest one, and at 5 ms it is hysteresis and not hold that carries it — see (b). + for hold_ms in FRS_HOLD_MS.into_iter().filter(|&ms| ms >= 5.0) { + let events = close_events_on_a_decaying_low_e(hold_ms, 3.0); + assert_eq!( + events, 1, + "hold {hold_ms} ms: expected exactly one transition into Closing, got {events}" + ); + } + // (b) The falsifier for (a)'s shortest hold: with the hysteresis gap removed and nothing + // else changed, the same stimulus chatters. Without this the assertion above would + // rest on hold — every value from 10 ms up reports one close event at 0 dB of + // hysteresis, because a hold longer than the ripple period absorbs the ripple by + // itself, which is the second half of what made the old test unfalsifiable. + let without_hysteresis = close_events_on_a_decaying_low_e(5.0, 0.0); + assert!( + without_hysteresis > 1, + "hold 5 ms with no hysteresis should chatter, got {without_hysteresis} close events \ + — the assertion above is then resting on hold, not on hysteresis" + ); + + // (c) At the bottom of FR-GATE-010's hold range hysteresis is the only mechanism left, so + // this is where the requirement's own sentence — "the level at which the gate closes + // shall be measurably below the level at which it opens" — is what is measured. + // Widening the gap must reduce the chatter monotonically, and a gap wider than the + // detector's ripple must remove it entirely. + let sweep: Vec<(f32, u32)> = [0.0f32, 1.0, 3.0, 6.0, 12.0, 24.0] + .into_iter() + .map(|db| (db, close_events_on_a_decaying_low_e(0.0, db))) + .collect(); + assert!( + sweep[0].1 > 1, + "hold 0 ms with no hysteresis should chatter, got {} close events", + sweep[0].1 + ); + for pair in sweep.windows(2) { + let ((narrow_db, narrow), (wide_db, wide)) = (pair[0], pair[1]); + assert!( + wide <= narrow, + "widening hysteresis from {narrow_db} dB to {wide_db} dB raised the close-event \ + count from {narrow} to {wide}" + ); + } + let (widest_db, widest) = *sweep.last().unwrap(); assert_eq!( - closing_transitions, 1, - "expected exactly one transition into Closing, got {closing_transitions}" + widest, 1, + "hold 0 ms at {widest_db} dB of hysteresis — wider than the detector's ripple on this \ + carrier — should close exactly once, got {widest}" ); } diff --git a/crates/namir-ir/src/convolver.rs b/crates/namir-ir/src/convolver.rs index 2d0b3cb..eb71e31 100644 --- a/crates/namir-ir/src/convolver.rs +++ b/crates/namir-ir/src/convolver.rs @@ -1710,19 +1710,32 @@ mod tests { }, passband.summary() ); - if let Some(stopband_db) = stopband.stopband_db { - assert!( - stopband_db <= -100.0, - "FR-NAM-060 requires 100 dB of stopband attenuation from {} upward; {label} \ - measured {}", - if restated { - "0.5 x the lower rate" - } else { - "the lower Nyquist" - }, + // Every pair in the list has an out-of-band region, in both directions: a + // down-conversion has input frequencies above the output Nyquist that must alias away, + // and an up-conversion has output bins above the input Nyquist where images must not + // appear. `measure` reports `None` only when there is no such region at all, which + // takes `source_hz == engine_hz` and is true of no pair here. So a `None` would mean + // the instrument had stopped measuring, not that this pair had nothing to measure -- + // and letting it skip the assertion, as this test did until issue #55, retires the + // 100 dB half of the bar for every pair at once while the test still reports green. + let stopband_db = stopband.stopband_db.unwrap_or_else(|| { + panic!( + "{label}: no stopband figure was measured, but this pair has an out-of-band \ + region -- {}", stopband.summary() - ); - } + ) + }); + assert!( + stopband_db <= -100.0, + "FR-NAM-060 requires 100 dB of stopband attenuation from {} upward; {label} \ + measured {}", + if restated { + "0.5 x the lower rate" + } else { + "the lower Nyquist" + }, + stopband.summary() + ); } } diff --git a/crates/namir-nam/fuzz/fuzz_targets/load_nam.rs b/crates/namir-nam/fuzz/fuzz_targets/load_nam.rs index eac22b4..0eb9609 100644 --- a/crates/namir-nam/fuzz/fuzz_targets/load_nam.rs +++ b/crates/namir-nam/fuzz/fuzz_targets/load_nam.rs @@ -1,16 +1,70 @@ -//! `cargo fuzz` target for `namir_nam::load` (NFR-QUAL-040, docs/03-implementation-roadmap.md §5 -//! M1 quick win). The bar this holds `load` to is exactly NFR-QUAL-040's: "shall not panic, hang, -//! over-allocate or read out of bounds on any input" — not "always reject garbage". `Ok` and `Err` -//! are both acceptable outcomes for arbitrary bytes; rejecting malformed input cleanly is -//! `namir_nam`'s own job via its `NamLoadError` catalogue, already exercised by -//! `crates/namir-nam/tests/fixtures.rs`'s `rejects_mutated_variants_without_panicking`. This -//! target's job is continuous exploration of the input space that test can't reach by itself. +//! `cargo fuzz` target for the `.nam` reader and the inference path a loaded model feeds +//! (NFR-QUAL-040, docs/03-implementation-roadmap.md §5 M1 quick win). The bar this holds them to +//! is exactly NFR-QUAL-040's: "shall not panic, hang, over-allocate or read out of bounds on any +//! input" — not "always reject garbage". `Ok` and `Err` are both acceptable outcomes for +//! arbitrary bytes; rejecting malformed input cleanly is `namir_nam`'s own job via its +//! `NamLoadError` catalogue, already exercised by `crates/namir-nam/tests/fixtures.rs`'s +//! `rejects_mutated_variants_without_panicking`. This target's job is continuous exploration of +//! the input space that test can't reach by itself. +//! +//! # Why it does not stop at `load` +//! +//! It did until issue #50, and the tag below said otherwise. A `.nam` file's dimensions are +//! validated against `wavenet.rs`/`lstm.rs`'s NFR-SEC-020 ceilings during `load`, but the +//! allocations and the indexing those dimensions *drive* happen afterwards, in +//! `PreparedNam::new_state` (WaveNet's per-layer causal-convolution history, sized +//! `channels * (kernel_size - 1) * dilation`; LSTM's per-cell `h`/`c`; either way the reusable +//! scratch, sized by the block) and in `PreparedNam::process_block`, which is where every read of +//! a weight and every write into that history actually happens. An out-of-bounds read or an +//! over-allocation traceable to a malicious file lives in those two functions rather than in the +//! JSON parse, and NFR-SEC-010 names exactly those failure modes — so a target that never +//! constructs a state and never processes a block was claiming the requirement it reached least. +//! +//! # The block sizes, and why they are not fuzzed +//! +//! `new_state`'s `max_block_size` is Namir's own value, not the attacker's — the host's block +//! size, or the standalone app's — so it is not taken from the input the way `load_ir.rs`'s +//! engine rate and block size are selected by leading bytes. Each is run in turn instead, which +//! also keeps every byte of `data` the candidate `.nam` file: `namir-fixtures`' mutation corpus +//! writes whole files, and a leading selector byte would shift every retained corpus entry out of +//! being one (NFR-QUAL-040's "with a corpus retained in the repository"). +//! +//! More than one call per state is deliberate: WaveNet's history buffer is a ring, so the second +//! and later blocks are the ones that exercise its wrap, and a block of 1 wraps on every sample. -// trace: NFR-QUAL-040, NFR-SEC-010 +// trace: NFR-QUAL-040 #![no_main] use libfuzzer_sys::fuzz_target; +/// Block sizes run against every model that loads: the smallest a host can present, a typical +/// one, and one large enough that a block spans more than the receptive field of a small model. +const BLOCK_SIZES: [usize; 3] = [1, 64, 512]; + +/// Calls per state — see the module comment's note on the history ring. +const BLOCKS_PER_STATE: usize = 3; + +/// One period of the stimulus, repeated to fill a block: full-scale in both directions, silence, +/// and a denormal, so the inference path is driven at the edges of `f32` rather than at one level. +const STIMULUS: [f32; 6] = [0.0, 1.0, -1.0, 0.5, -0.5, 1e-38]; + +// trace-partial: NFR-SEC-010 +// uncovered: NFR-SEC-010 — within the `.nam` kind this target reaches `load` and the +// uncovered: `new_state`/`process_block` path it feeds, and nothing else: `probe_metadata`, the +// uncovered: weights-free read `namir-library`'s scanner runs over every file it indexes, is the +// uncovered: `.nam` analogue of the separately-fuzzed `probe_wav` and is reached by no target in +// uncovered: this crate; closes M8 fuzz_target!(|data: &[u8]| { - let _ = namir_nam::load(data); + let Ok(prepared) = namir_nam::load(data) else { + return; + }; + + for block in BLOCK_SIZES { + let mut state = prepared.new_state(block); + let input: Vec = (0..block).map(|i| STIMULUS[i % STIMULUS.len()]).collect(); + let mut out = vec![0.0f32; block]; + for _ in 0..BLOCKS_PER_STATE { + prepared.process_block(&mut state, &input, &mut out); + } + } }); From 3a5f51585f420c0c26c0034c4246b45c54ecbc59 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:51:18 +0000 Subject: [PATCH 06/44] Make three fixture tests independent of what they check (#134, #135, #132) The determinism test called generate_shared_corpus twice, and the second call was a cache hit of the first -- it compared a build against itself. It now builds two independent corpora into distinct roots under its own seed and compares all 10,000 files byte for byte, re-hashing each. Injecting a per-build counter into the seed fails the new test; the old shape passed it. The mutation corpus could not produce a null or wrong-typed field, which is the shape of the real post-M6 parser bug. Adds NullField and RetypeField, appended to ALL so seeds 0-3 stay byte-identical, with a test asserting some seed reaches "metadata.": null over a real generated .nam. reference_infer_lstm was a paraphrase of the implementation it checks. It is re-derived from NAM/lstm.cpp at the pinned commit, structured like the C++ rather than like namir-nam, with four analytic tests that discriminate the plausible misreadings. Two residues are now stated in mod.rs instead of a claim of independence: the re-derivation was not blind, and the multi-layer facts rest on analytic tests rather than an external render. Float summation order is deliberately preserved -- generate_lstm calibrates from this function's RMS, so a last-ulp change would move every generated LSTM fixture including the pinned golden. Verified identical by hash. Checked all four risk items against upstream: none was a real misreading. The defect was that nothing established that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-fixtures/src/library.rs | 83 +++- crates/namir-fixtures/src/mutate.rs | 224 ++++++++- crates/namir-fixtures/src/nam/lstm_infer.rs | 470 +++++++++++++++--- crates/namir-fixtures/src/nam/mod.rs | 18 +- .../examples/generate_ir_fuzz_corpus.rs | 9 +- .../fuzz/corpus/probe_wav/mutated_4.bin | Bin 0 -> 172 bytes .../fuzz/corpus/probe_wav/mutated_5.bin | Bin 0 -> 172 bytes .../fuzz/corpus/load_nam/mutated_4.bin | 1 + .../fuzz/corpus/load_nam/mutated_5.bin | 1 + 9 files changed, 709 insertions(+), 97 deletions(-) create mode 100644 crates/namir-ir/fuzz/corpus/probe_wav/mutated_4.bin create mode 100644 crates/namir-ir/fuzz/corpus/probe_wav/mutated_5.bin create mode 100644 crates/namir-nam/fuzz/corpus/load_nam/mutated_4.bin create mode 100644 crates/namir-nam/fuzz/corpus/load_nam/mutated_5.bin diff --git a/crates/namir-fixtures/src/library.rs b/crates/namir-fixtures/src/library.rs index f88b4a8..26939b7 100644 --- a/crates/namir-fixtures/src/library.rs +++ b/crates/namir-fixtures/src/library.rs @@ -633,18 +633,61 @@ mod tests { /// differences, so tests share one cache entry instead of each cold-building their own. const TEST_SEED: u64 = 12_345; + /// Determinism, checked against two *independent* builds — deliberately not two + /// [`generate_shared_corpus`] calls. That version of this test (through M14) called the + /// public entry point twice and compared the results, which cannot fail: the second call is a + /// cache hit that re-reads the first call's own `_manifest.json`, so both sides of every + /// assertion came from one build and genuine non-determinism anywhere in `derive_seed`, + /// `ir_variant_bytes` or `nam_variant_bytes` would have shipped silently. This builds twice + /// into two distinct roots via `build_corpus_into` (bypassing the cache entirely), then + /// compares the manifests byte-for-byte *and* every generated file's bytes on disk. + /// + /// It uses its own seed rather than `TEST_SEED` so it never publishes into, reads from, or + /// races the shared cache entry the rest of this module's tests share. #[test] fn generating_twice_with_the_same_seed_is_byte_identical() { - let a = generate_shared_corpus(TEST_SEED).expect("first generation"); - let b = generate_shared_corpus(TEST_SEED).expect("second generation (cache hit)"); - - assert_eq!(a.root, b.root); - assert_eq!(a.entries.len(), b.entries.len()); - for (ea, eb) in a.entries.iter().zip(b.entries.iter()) { - assert_eq!(ea.path, eb.path); - assert_eq!(ea.kind, eb.kind); - assert_eq!(ea.content_hash, eb.content_hash); + const DETERMINISM_SEED: u64 = 24_680; + let key = cache_key(DETERMINISM_SEED); + let base = workspace_target_dir() + .join("namir-fixtures-determinism-test") + .join("generating_twice_with_the_same_seed_is_byte_identical"); + let _ = fs::remove_dir_all(&base); + + let roots = ["build_a", "build_b"].map(|name| base.join(name)); + for root in &roots { + fs::create_dir_all(root).expect("create an independent build root"); + build_corpus_into(root, DETERMINISM_SEED, &key).expect("independent build"); } + let (a, b) = (&roots[0], &roots[1]); + + let manifest_a = fs::read(a.join(MANIFEST_FILE_NAME)).expect("build a's manifest"); + let manifest_b = fs::read(b.join(MANIFEST_FILE_NAME)).expect("build b's manifest"); + assert_eq!( + manifest_a, manifest_b, + "two independent builds of seed {DETERMINISM_SEED} disagree on their manifests" + ); + + // The manifest agreeing is necessary but not sufficient: it records hashes this generator + // computed itself, so the files on disk are compared directly as well. + let manifest: Manifest = serde_json::from_slice(&manifest_a).expect("manifest parses back"); + assert_eq!(manifest.entries.len(), TOTAL_COUNT); + for entry in &manifest.entries { + let bytes_a = fs::read(a.join(&entry.rel_path)).expect("build a's file"); + let bytes_b = fs::read(b.join(&entry.rel_path)).expect("build b's file"); + assert_eq!( + bytes_a, bytes_b, + "{} differs between two independent builds of the same seed", + entry.rel_path + ); + assert_eq!( + ContentHash::of(&bytes_a).to_string(), + entry.hash, + "{}'s manifest hash does not match its bytes on disk", + entry.rel_path + ); + } + + let _ = fs::remove_dir_all(&base); } /// Regression test for the CI-only failure this crate's own dev sandbox could never @@ -732,10 +775,30 @@ mod tests { } } + /// Uniqueness, checked by re-hashing the bytes on disk rather than by trusting the manifest. + /// The manifest's hashes are this generator's own claims about files it wrote; hashing what + /// is actually there is what makes this a check on the *corpus* instead of a check on the + /// manifest's internal consistency (through M14 it was the latter). #[test] fn every_content_hash_in_the_shared_corpus_is_unique() { let corpus = generate_shared_corpus(TEST_SEED).expect("generate"); - let unique: HashSet<_> = corpus.entries.iter().map(|e| e.content_hash).collect(); + let mut unique: HashSet = HashSet::with_capacity(corpus.entries.len()); + for entry in &corpus.entries { + let bytes = fs::read(&entry.path) + .unwrap_or_else(|e| panic!("reading {}: {e}", entry.path.display())); + let hash = ContentHash::of(&bytes); + assert_eq!( + hash, + entry.content_hash, + "{}: the manifest's hash does not match the file's actual bytes", + entry.path.display() + ); + assert!( + unique.insert(hash), + "{} shares a content hash with an earlier file", + entry.path.display() + ); + } assert_eq!( unique.len(), corpus.entries.len(), diff --git a/crates/namir-fixtures/src/mutate.rs b/crates/namir-fixtures/src/mutate.rs index 944e876..14084e0 100644 --- a/crates/namir-fixtures/src/mutate.rs +++ b/crates/namir-fixtures/src/mutate.rs @@ -22,14 +22,37 @@ pub enum Mutation { /// Parses as JSON and corrupts one random numeric leaf (negate, zero, or scale it by a huge /// factor). Falls back to a byte flip if the buffer doesn't parse as JSON or has no numbers. CorruptNumber, + /// Parses as JSON and replaces one random object field's *value* with `null`, leaving the key + /// present. Falls back to a byte flip if the buffer doesn't parse as a JSON object. + /// + /// This is the shape of the real post-M6 `.nam` parser bug (a metadata field exported as + /// JSON `null` rather than omitted): [`Mutation::DropField`] removes the key entirely, which + /// a `#[serde(default)]`/`Option` field absorbs, and [`Mutation::CorruptNumber`] only ever + /// rewrites a number as another number — neither can ever produce `"name": null`, so before + /// this variant existed no generated fixture and no seeded fuzz corpus entry reached that + /// region at all. + NullField, + /// Parses as JSON and replaces one random object field's *value* with a value of a different + /// JSON type (a string where a number was, a number where a string was, a scalar where a + /// container was). Falls back to a byte flip if the buffer doesn't parse as a JSON object. + /// + /// The type-confusion sibling of [`Mutation::NullField`]: a deserializer that is careful + /// about missing fields and out-of-range numbers can still be careless about a field whose + /// type is simply wrong. + RetypeField, } -/// All four kinds, in a stable order — useful for building a corpus that covers each once. -pub const ALL: [Mutation; 4] = [ +/// All six kinds, in a stable order — useful for building a corpus that covers each once. The +/// order is append-only on purpose: [`seeded_corpus`] derives each variant's seed from its index +/// here, so inserting a kind anywhere but the end would silently change every later variant's +/// bytes (and therefore every checked-in fuzz corpus file generated from it). +pub const ALL: [Mutation; 6] = [ Mutation::ByteFlip, Mutation::Truncate, Mutation::DropField, Mutation::CorruptNumber, + Mutation::NullField, + Mutation::RetypeField, ]; /// Applies `mutation` to `data` with a seeded RNG; same `(data, mutation, seed)` always produces @@ -41,6 +64,8 @@ pub fn mutate(data: &[u8], mutation: Mutation, seed: u64) -> Vec { Mutation::Truncate => truncate(data, &mut rng), Mutation::DropField => drop_field(data, &mut rng), Mutation::CorruptNumber => corrupt_number(data, &mut rng), + Mutation::NullField => null_field(data, &mut rng), + Mutation::RetypeField => retype_field(data, &mut rng), } } @@ -157,6 +182,85 @@ fn corrupt_number(data: &[u8], rng: &mut impl Rng) -> Vec { serde_json::to_vec(&value).unwrap_or_else(|_| data.to_vec()) } +/// The object that owns `container_ptr`, as a mutable map — the shared half of `drop_field`, +/// `null_field` and `retype_field`'s "reach into the tree and edit one field" step. An empty +/// pointer addresses the document root. +fn object_mut<'a>( + value: &'a mut Value, + container_ptr: &str, +) -> Option<&'a mut serde_json::Map> { + let container = if container_ptr.is_empty() { + Some(value) + } else { + value.pointer_mut(container_ptr) + }; + match container { + Some(Value::Object(map)) => Some(map), + _ => None, + } +} + +/// The current value of `container_ptr`'s `key` field, addressed the same way +/// [`collect_object_keys`] built the pointer. +fn child<'a>(value: &'a Value, container_ptr: &str, key: &str) -> Option<&'a Value> { + value.pointer(&format!("{container_ptr}/{key}")) +} + +/// Replaces one random object field's value with `null`, keeping the key. See +/// [`Mutation::NullField`] for why this is a distinct kind rather than a case of `DropField`. +fn null_field(data: &[u8], rng: &mut impl Rng) -> Vec { + let Ok(mut value) = serde_json::from_slice::(data) else { + return byte_flip(data, rng); + }; + let mut keys = Vec::new(); + collect_object_keys(&value, "", &mut keys); + // Writing `null` over a field that is already `null` would be a no-op "mutation" that emits + // the input unchanged — never a useful corpus entry. + keys.retain(|(container, key)| !matches!(child(&value, container, key), Some(Value::Null))); + if keys.is_empty() { + return byte_flip(data, rng); + } + let (container_ptr, key) = keys[rng.gen_range(0..keys.len())].clone(); + if let Some(map) = object_mut(&mut value, &container_ptr) { + map.insert(key, Value::Null); + } + serde_json::to_vec(&value).unwrap_or_else(|_| data.to_vec()) +} + +/// The wrong-typed replacement for `value`: a string where a number was, a number where a string +/// was, a scalar where a container was. Deterministic given the field that was picked — the RNG's +/// only job in [`retype_field`] is choosing *which* field to hit. +fn retyped(value: &Value) -> Value { + match value { + Value::Null => Value::Bool(true), + Value::Bool(_) => Value::String("true".to_string()), + Value::Number(n) => Value::String(n.to_string()), + Value::String(_) => Value::Number(0.into()), + Value::Array(_) | Value::Object(_) => Value::Number(0.into()), + } +} + +/// Replaces one random object field's value with a value of a different JSON type, keeping the +/// key. See [`Mutation::RetypeField`]. +fn retype_field(data: &[u8], rng: &mut impl Rng) -> Vec { + let Ok(mut value) = serde_json::from_slice::(data) else { + return byte_flip(data, rng); + }; + let mut keys = Vec::new(); + collect_object_keys(&value, "", &mut keys); + if keys.is_empty() { + return byte_flip(data, rng); + } + let (container_ptr, key) = keys[rng.gen_range(0..keys.len())].clone(); + let Some(replacement) = child(&value, &container_ptr, &key).map(retyped) else { + return byte_flip(data, rng); + }; + if let Some(map) = object_mut(&mut value, &container_ptr) { + map.insert(key, replacement); + } + serde_json::to_vec(&value).unwrap_or_else(|_| data.to_vec()) +} + #[cfg(test)] mod tests { use super::*; @@ -249,7 +353,12 @@ mod tests { #[test] fn non_json_input_falls_back_to_byte_flip_for_structural_mutations() { let data = b"not json at all".to_vec(); - for m in [Mutation::DropField, Mutation::CorruptNumber] { + for m in [ + Mutation::DropField, + Mutation::CorruptNumber, + Mutation::NullField, + Mutation::RetypeField, + ] { let mutated = mutate(&data, m, 1); assert_eq!(mutated.len(), data.len()); assert_ne!(mutated, data); @@ -272,4 +381,113 @@ mod tests { let corpus = seeded_corpus(&data, 5); assert_eq!(corpus.len(), ALL.len()); } + + /// Every `(container, key)` pair in `value` whose value is JSON `null`, as a set of + /// `"/container/key"` pointers — the shape the real post-M6 parser bug had. + fn null_valued_pointers(value: &Value) -> Vec { + let mut keys = Vec::new(); + collect_object_keys(value, "", &mut keys); + keys.into_iter() + .filter(|(c, k)| matches!(child(value, c, k), Some(Value::Null))) + .map(|(c, k)| format!("{c}/{k}")) + .collect() + } + + #[test] + fn null_field_writes_json_null_into_a_slot_that_held_a_string() { + // `architecture` is the sample document's only string field, so "some seed nulls a + // string" is checkable exactly rather than statistically. + let data = sample_nam_json(); + let hit = (0..50u64).any(|seed| { + let mutated = mutate(&data, Mutation::NullField, seed); + let after: Value = serde_json::from_slice(&mutated).expect("still valid JSON"); + after.pointer("/architecture") == Some(&Value::Null) + }); + assert!( + hit, + "expected at least one seed (of 50) to null the `architecture` string" + ); + } + + #[test] + fn null_field_keeps_the_key_and_never_merely_re_nulls_an_existing_null() { + let data = sample_nam_json(); + let original: Value = serde_json::from_slice(&data).unwrap(); + assert!( + null_valued_pointers(&original).is_empty(), + "the sample document is expected to start with no nulls at all" + ); + for seed in 0..25u64 { + let mutated = mutate(&data, Mutation::NullField, seed); + let after: Value = serde_json::from_slice(&mutated).expect("still valid JSON"); + assert_ne!(original, after, "seed {seed}: NullField was a no-op"); + assert_eq!( + null_valued_pointers(&after).len(), + 1, + "seed {seed}: expected exactly one field to become null" + ); + } + } + + #[test] + fn retype_field_replaces_one_value_with_a_different_json_type() { + let data = sample_nam_json(); + let original: Value = serde_json::from_slice(&data).unwrap(); + let mut seen_number_as_string = false; + let mut seen_string_as_number = false; + for seed in 0..50u64 { + let mutated = mutate(&data, Mutation::RetypeField, seed); + let after: Value = serde_json::from_slice(&mutated).expect("still valid JSON"); + assert_ne!(original, after, "seed {seed}: RetypeField was a no-op"); + if after.pointer("/sample_rate").is_some_and(Value::is_string) { + seen_number_as_string = true; + } + if after.pointer("/architecture").is_some_and(Value::is_number) { + seen_string_as_number = true; + } + } + assert!( + seen_number_as_string, + "expected some seed to put a string where `sample_rate`'s number was" + ); + assert!( + seen_string_as_number, + "expected some seed to put a number where `architecture`'s string was" + ); + } + + /// The regression this whole pair of mutation kinds exists for: a corpus seeded from a *real* + /// generated `.nam` fixture must be able to produce the shape of the real post-M6 parser bug + /// — a metadata field present but set to JSON `null` rather than omitted. Before `NullField` + /// existed this was unreachable: `DropField` removes the key and `CorruptNumber` only ever + /// rewrites a number as another number, so no seed of any count could have passed this. + #[test] + fn a_seeded_corpus_of_a_generated_fixture_reaches_a_null_metadata_field() { + let model = crate::nam::generate(crate::nam::WaveNetShape::Nano, 1) + .expect("nano fixture should generate"); + let bytes = model.to_json_bytes(); + + let metadata_fields = [ + "name", + "modeled_by", + "gear_type", + "tone_type", + "description", + ]; + let hit = (0..60u64).any(|seed| { + seeded_corpus(&bytes, seed).iter().any(|variant| { + let Ok(after) = serde_json::from_slice::(variant) else { + return false; + }; + metadata_fields + .iter() + .any(|f| after.pointer(&format!("/metadata/{f}")) == Some(&Value::Null)) + }) + }); + assert!( + hit, + "no seed produced a `\"metadata.\": null` document — the exact shape of the \ + real post-M6 parser bug" + ); + } } diff --git a/crates/namir-fixtures/src/nam/lstm_infer.rs b/crates/namir-fixtures/src/nam/lstm_infer.rs index 77c67f5..8292077 100644 --- a/crates/namir-fixtures/src/nam/lstm_infer.rs +++ b/crates/namir-fixtures/src/nam/lstm_infer.rs @@ -1,17 +1,81 @@ -//! A minimal, whole-signal LSTM forward pass used *only* to validate generated fixtures (the -//! RMS/degeneracy check D-19.1 requires of the generator itself) and as `namir-nam`'s -//! cross-implementation numeric-parity oracle for its own LSTM implementation — the LSTM -//! counterpart of `infer.rs`. Deliberately not shared with `namir-engine`, not written for the RT -//! audio path (no blockwise state carried across calls, allocates freely), and only handles the -//! subset this crate's [`super::generate_lstm`] ever emits (`input_size == in_channels == -//! out_channels == 1`, per `namir-nam`'s own scope restriction — see that crate's `lstm.rs`). +//! A minimal LSTM forward pass used *only* to validate generated fixtures (the RMS/degeneracy +//! check D-19.1 requires of the generator itself) and as `namir-nam`'s cross-implementation +//! numeric-parity oracle for its own LSTM implementation — the LSTM counterpart of `infer.rs`. +//! Deliberately not shared with `namir-engine`, not written for the RT audio path (allocates +//! freely, no scratch reuse), and only handles the subset this crate's [`super::generate_lstm`] +//! ever emits (`input_size == in_channels == out_channels == 1`, per `namir-nam`'s own scope +//! restriction — see that crate's `lstm.rs`). //! -//! Operation order and flat-weight-array layout follow the same facts `namir-nam`'s `lstm.rs` -//! module doc comment records from reading `NeuralAmpModelerCore`'s `NAM/lstm.h`/`NAM/lstm.cpp` -//! directly: per layer, `W` (row-major, `(4*hidden_size) x (cell_input_size+hidden_size)`), `b`, -//! a learned initial hidden state `h0`, a learned initial cell state `c0`; then, after every -//! layer, `head_weight` (`out_channels x hidden_size`, row-major) and `head_bias` — with **no** -//! trailing scalar afterward (unlike WaveNet's `head_scale`). +//! # Provenance: derived from `NeuralAmpModelerCore`, not from `namir-nam` +//! +//! **This module is derived from `NeuralAmpModelerCore` directly** — `NAM/lstm.h` and +//! `NAM/lstm.cpp` at the pinned commit `3cde95c354d5ba6da01316cad90b05cfc4855053`, the same +//! commit `crates/namir-nam/tests/golden_reference.rs`'s renders are built from — in the same +//! spirit `a2_infer.rs` states for A2 (R-9, `docs/02-architecture.md` §22). Until M14 it was not: +//! its doc comment said its layout and operation order "follow the same facts `namir-nam`'s +//! `lstm.rs` module doc comment records", i.e. it was written from *that crate's reading* of the +//! format rather than from the format's own source. A parity test between two ports of one +//! reading cannot see a misreading they share, which is what this re-derivation fixes. +//! +//! Every fact this module relies on, each cited to the upstream line that establishes it (line +//! numbers are at the pinned commit): +//! +//! - **Per-cell weight consumption order** (`LSTMCell::LSTMCell`, `lstm.cpp:9-29`): `W` first, +//! row-major, `(4*hidden_size)` rows by `(cell_input_size + hidden_size)` columns +//! (`lstm.cpp:12`, `:19-21` — "Assign in row-major because that's how PyTorch goes"); then `b`, +//! `4*hidden_size` floats (`:22-23`); then a **learned** initial hidden state `h0`, +//! `hidden_size` floats written straight into `xh`'s tail (`:24-26`) — not a zero init; then a +//! **learned** initial cell state `c0`, `hidden_size` floats (`:27-28`). +//! - **The state vector is `xh = [x ; h]`** with the hidden state living in its tail, which is +//! also how a cell's hidden state is read back out (`lstm.h:32-35`, +//! `_xh.tail(_get_hidden_size())`). `_get_input_size()` is derived as `xh.size() - hidden_size` +//! (`lstm.h:60`). +//! - **Gate order within the `4*hidden_size` pre-activation vector** (`LSTMCell::process_`, +//! `lstm.cpp:42-45`): `i` at offset `0`, `f` at `hidden_size`, `g` at `2*hidden_size`, `o` at +//! `3*hidden_size`. +//! - **The update itself** (`lstm.cpp:61-66`, the `using_fast_tanh == false` branch — the exact +//! one, which is what this crate ports, matching `namir-nam`'s own choice and FR-NAM-030's +//! accuracy floor): `ifgo = W @ xh + b` (`:39-40`), then +//! `c[k] = sigmoid(f[k]) * c[k] + sigmoid(i[k]) * tanh(g[k])` for every `k` **first** +//! (`:61-63`), and only then `h[k] = sigmoid(o[k]) * tanh(c[k])` reading the just-updated `c` +//! (`:65-66`). `sigmoid` is the exact `1 / (1 + expf(-x))` (`activations.h:64-67`), not the +//! `fast_sigmoid` approximation. +//! - **Layer chaining** (`LSTM::_process_sample`, `lstm.cpp:153-155`): layer 0 takes the model's +//! raw input for the current sample; layer `i > 0` takes layer `i-1`'s hidden state from the +//! *same* sample, with no delay. Hence layer `i > 0`'s cell is constructed with +//! `cell_input_size == hidden_size`, not `input_size` (`lstm.cpp:79-80`). +//! - **Head** (`LSTM::LSTM`, `lstm.cpp:84-98`, applied at `:160-167`): after **every** layer's +//! weights, `head_weight` (`out_channels x hidden_size`, row-major) then `head_bias` +//! (`out_channels`), and `output = head_weight @ h_last + head_bias`. +//! - **No trailing scalar** after `head_bias`: upstream asserts the weight vector is exactly +//! exhausted at that point (`assert(it == weights.end())`, `lstm.cpp:100`) — unlike WaveNet's +//! trailing `head_scale`. This module asserts the same thing for the same reason. +//! +//! Not ported, and not needed by anything here: upstream's zero-layer passthrough +//! (`lstm.cpp:141-151` — this crate's generator always emits at least one layer), its +//! `fast_tanh`/`fast_sigmoid` branch (`:48-58`), and `GetPrewarmSamples` (`:127-134`), which is a +//! caller-side warm-up recommendation rather than part of the model's definition. +//! +//! **What this provenance does and does not buy.** The facts above are now checked against the +//! source that defines them rather than against another Rust port of it, and `tests` below pins +//! each one against hand-computed arithmetic so a later edit cannot quietly drift back. What it +//! cannot claim is blind independence: this rewrite replaced an existing port, so its author had +//! seen `namir-nam`'s. The remaining external check on the shared-misreading risk is +//! `namir-nam/tests/golden_reference.rs`'s `lstm_tiny` render, which is a real +//! `NeuralAmpModelerCore` output — and it exercises a **one-layer** model, so the multi-layer +//! facts above (`cell_input_size == hidden_size` for layer `i > 0`, same-sample chaining) are +//! covered by this module's analytic tests and by no external render. +//! +//! # Float summation order is deliberately preserved +//! +//! Both accumulations below (the `W @ xh + b` row dot product, and the head) start from the bias +//! and add products into it, which is not upstream's association (`_ifgo = W * xh` then +//! `+= b`; `_output = head_weight * h` then `+= head_bias`). The difference is last-ulp only, but +//! [`super::generate_lstm`]'s calibration pass measures *this* function's output RMS to pick its +//! head-rescale factor, so a last-ulp change here changes the weights of every generated LSTM +//! fixture — including the checked-in `crates/namir-nam/tests/golden/lstm_tiny.nam`, whose +//! reference render was produced from those exact bytes. Preserving the order keeps every +//! generated fixture byte-identical across this rewrite. use super::{LstmConfig, LstmModel}; @@ -28,68 +92,91 @@ impl<'a> WeightReader<'a> { } } +/// `activations::sigmoid`, `activations.h:64-67`. fn sigmoid(x: f32) -> f32 { 1.0 / (1.0 + (-x).exp()) } -struct CellWeights<'a> { +/// One LSTM cell: upstream's `LSTMCell` (`lstm.h:17-61`), weights and evolving state together, +/// with the hidden state living in `xh`'s tail exactly as it does there. +struct LstmCell<'a> { + /// This cell's own input width — `input_size` for layer 0, `hidden_size` for every later + /// layer (`lstm.cpp:79-80`). input_size: usize, hidden_size: usize, + /// `(4*hidden_size)` rows by `(input_size + hidden_size)` columns, row-major. w: &'a [f32], b: &'a [f32], - h0: &'a [f32], - c0: &'a [f32], + /// `[x ; h]`: the current input in `[0, input_size)`, the hidden state in the tail. Seeded + /// from the learned `h0`. + xh: Vec, + /// Cell state, seeded from the learned `c0`. + c: Vec, + /// Scratch for the four gates' pre-activations, `4*hidden_size` long. + ifgo: Vec, } -/// Runs one LSTM cell over a whole signal at once. `input`: flat `[input_size * n]`. Returns the -/// hidden state at every time step, flat `[hidden_size * n]` — becomes the next layer's `input`. -fn run_cell(w: &CellWeights, input: &[f32], n: usize) -> Vec { - let hidden_size = w.hidden_size; - let input_size = w.input_size; - let cols = input_size + hidden_size; +impl<'a> LstmCell<'a> { + /// `LSTMCell`'s constructor, `lstm.cpp:9-29`: `W`, `b`, `h0` (into `xh`'s tail), `c0`. + fn read(input_size: usize, hidden_size: usize, r: &mut WeightReader<'a>) -> Self { + let w = r.take(4 * hidden_size * (input_size + hidden_size)); + let b = r.take(4 * hidden_size); + let h0 = r.take(hidden_size); + let c0 = r.take(hidden_size); - let mut h = w.h0.to_vec(); - let mut c = w.c0.to_vec(); - let mut xh = vec![0f32; cols]; - let mut ifgo = vec![0f32; 4 * hidden_size]; - let mut h_seq = vec![0f32; hidden_size * n]; + let mut xh = vec![0f32; input_size + hidden_size]; + xh[input_size..].copy_from_slice(h0); - for t in 0..n { - for i in 0..input_size { - xh[i] = input[i * n + t]; + Self { + input_size, + hidden_size, + w, + b, + xh, + c: c0.to_vec(), + ifgo: vec![0f32; 4 * hidden_size], } - xh[input_size..].copy_from_slice(&h); + } + + /// `LSTMCell::process_`, `lstm.cpp:31-68` (the exact-math branch). + fn process(&mut self, x: &[f32]) { + let (input_size, hidden_size) = (self.input_size, self.hidden_size); + let cols = input_size + hidden_size; + self.xh[..input_size].copy_from_slice(x); - for (row, slot) in ifgo.iter_mut().enumerate() { - let mut acc = w.b[row]; - let w_row = &w.w[row * cols..(row + 1) * cols]; - for (wv, xv) in w_row.iter().zip(xh.iter()) { + // ifgo = W @ xh + b (`lstm.cpp:39-40`; see this module's note on summation order). + for (row, slot) in self.ifgo.iter_mut().enumerate() { + let mut acc = self.b[row]; + for (wv, xv) in self.w[row * cols..(row + 1) * cols].iter().zip(&self.xh) { acc += wv * xv; } *slot = acc; } + // Gate offsets, `lstm.cpp:42-45`. let (i_off, f_off, g_off, o_off) = (0, hidden_size, 2 * hidden_size, 3 * hidden_size); + // Every c[k] first (`lstm.cpp:61-63`) ... for k in 0..hidden_size { - let f_gate = sigmoid(ifgo[f_off + k]); - let i_gate = sigmoid(ifgo[i_off + k]); - let g_val = ifgo[g_off + k].tanh(); - c[k] = f_gate * c[k] + i_gate * g_val; + self.c[k] = sigmoid(self.ifgo[f_off + k]) * self.c[k] + + sigmoid(self.ifgo[i_off + k]) * self.ifgo[g_off + k].tanh(); } + // ... then every h[k], from the just-updated c (`lstm.cpp:65-66`). for k in 0..hidden_size { - let o_gate = sigmoid(ifgo[o_off + k]); - h[k] = o_gate * c[k].tanh(); - } - for k in 0..hidden_size { - h_seq[k * n + t] = h[k]; + self.xh[input_size + k] = sigmoid(self.ifgo[o_off + k]) * self.c[k].tanh(); } } - h_seq + + /// `LSTMCell::get_hidden_state`, `lstm.h:32-35`: the tail of `xh`. + fn hidden_state(&self) -> &[f32] { + &self.xh[self.input_size..] + } } /// Runs `model` over `input` (mono, `input_size == 1`) and returns the mono output (`out_channels -/// == 1`). Panics on malformed weight counts — acceptable here because this function only ever -/// runs against this crate's own generator output, never external input. +/// == 1`) — upstream's `LSTM` constructor (`lstm.cpp:70-101`) followed by `LSTM::process` / +/// `LSTM::_process_sample` (`:103-125`, `:136-168`). Panics on malformed weight counts — +/// acceptable here because this function only ever runs against this crate's own generator +/// output, never external input. pub(super) fn run(model: &LstmModel, input: &[f32]) -> Vec { let n = input.len(); let cfg: &LstmConfig = &model.config; @@ -98,46 +185,275 @@ pub(super) fn run(model: &LstmModel, input: &[f32]) -> Vec { pos: 0, }; - let mut cur: Vec = input.to_vec(); - for i in 0..cfg.num_layers { - let cell_input_size = if i == 0 { - cfg.input_size - } else { - cfg.hidden_size - }; - let hidden_size = cfg.hidden_size; - let rows = 4 * hidden_size; - let cols = cell_input_size + hidden_size; - let w = r.take(rows * cols); - let b = r.take(rows); - let h0 = r.take(hidden_size); - let c0 = r.take(hidden_size); - let cw = CellWeights { - input_size: cell_input_size, - hidden_size, - w, - b, - h0, - c0, - }; - cur = run_cell(&cw, &cur, n); - } + // `lstm.cpp:79-80`: layer 0's cell input is the model input, every later layer's is a hidden + // state. + let mut layers: Vec = (0..cfg.num_layers) + .map(|i| { + let cell_input_size = if i == 0 { + cfg.input_size + } else { + cfg.hidden_size + }; + LstmCell::read(cell_input_size, cfg.hidden_size, &mut r) + }) + .collect(); + assert!( + !layers.is_empty(), + "zero-layer LSTM: upstream's passthrough branch is not ported (see the module doc \ + comment); this crate's generator never emits one" + ); let head_weight = r.take(cfg.out_channels * cfg.hidden_size); let head_bias = r.take(cfg.out_channels); + // `lstm.cpp:100`'s `assert(it == weights.end())`: no trailing scalar, unlike WaveNet. assert_eq!(r.pos, model.weights.len(), "unexpected weight count"); let mut out = vec![0f32; cfg.out_channels * n]; - for oc in 0..cfg.out_channels { - let hw = &head_weight[oc * cfg.hidden_size..(oc + 1) * cfg.hidden_size]; - let b = head_bias[oc]; - for t in 0..n { - let mut acc = b; + let mut hop = vec![0f32; cfg.hidden_size]; + for t in 0..n { + // `_process_sample`, `lstm.cpp:153-155`: all layers advance within one sample, each + // reading the previous layer's hidden state as it stands after that same sample. + let x: Vec = (0..cfg.input_size).map(|ch| input[ch * n + t]).collect(); + layers[0].process(&x); + for i in 1..layers.len() { + hop.copy_from_slice(layers[i - 1].hidden_state()); + layers[i].process(&hop); + } + + // `lstm.cpp:160-167`: head applied to the last layer's hidden state. + let h = layers[cfg.num_layers - 1].hidden_state(); + for oc in 0..cfg.out_channels { + let hw = &head_weight[oc * cfg.hidden_size..(oc + 1) * cfg.hidden_size]; + let mut acc = head_bias[oc]; for k in 0..cfg.hidden_size { - acc += hw[k] * cur[k * n + t]; + acc += hw[k] * h[k]; } out[oc * n + t] = acc; } } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::nam::NamMetadata; + + /// The smallest model shape that still exercises the whole contract, wrapped so a test only + /// has to supply a weight vector. + fn model(num_layers: usize, hidden_size: usize, weights: Vec) -> LstmModel { + LstmModel { + version: "0.5.5".to_string(), + architecture: "LSTM".to_string(), + config: LstmConfig { + num_layers, + input_size: 1, + hidden_size, + in_channels: 1, + out_channels: 1, + }, + weights, + sample_rate: 48_000, + metadata: NamMetadata { + name: "lstm_infer analytic test".to_string(), + modeled_by: "namir-fixtures".to_string(), + gear_type: "amp".to_string(), + tone_type: "clean".to_string(), + description: "hand-computed contract fixture".to_string(), + }, + } + } + + /// One `hidden_size == 1` cell step, written out as scalar arithmetic straight from + /// `lstm.cpp:39-66` — deliberately a *different formulation* from [`LstmCell::process`] + /// (no offsets, no loops, gates named), so it agrees with the implementation only if both + /// read the format the same way. `w` is `[[w_i_x, w_i_h], [w_f..], [w_g..], [w_o..]]` + /// flattened row-major, exactly as upstream stores it. + fn hand_step( + w: [f32; 8], + b: [f32; 4], + h_prev: f32, + c_prev: f32, + x: f32, + gate_order: [usize; 4], + ) -> (f32, f32) { + // `gate_order[j]` is the row the j-th gate (i, f, g, o) is read from — the correct + // reading is `[0, 1, 2, 3]`; the other permutations are the misreadings the assertions + // below require this fixture to be able to tell apart. + let pre = |row: usize| b[row] + w[2 * row] * x + w[2 * row + 1] * h_prev; + let (zi, zf, zg, zo) = ( + pre(gate_order[0]), + pre(gate_order[1]), + pre(gate_order[2]), + pre(gate_order[3]), + ); + let c = sigmoid(zf) * c_prev + sigmoid(zi) * zg.tanh(); + let h = sigmoid(zo) * c.tanh(); + (h, c) + } + + const W1: [f32; 8] = [0.5, -0.25, 0.75, 0.125, -0.5, 0.625, 0.25, -0.75]; + const B1: [f32; 4] = [0.1, -0.2, 0.3, -0.4]; + const H0: f32 = 0.35; + const C0: f32 = -0.15; + const HEAD_W: f32 = 1.5; + const HEAD_B: f32 = -0.05; + + fn single_layer_weights() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&W1); + v.extend_from_slice(&B1); + v.push(H0); + v.push(C0); + v.push(HEAD_W); + v.push(HEAD_B); + v + } + + /// Pins the whole single-layer contract — gate order, the c-then-h update, `h0`/`c0` being + /// *learned weights* rather than zero init, the head, and the absence of a trailing scalar — + /// against arithmetic worked out from `lstm.cpp` rather than against another port of it. + #[test] + fn a_single_layer_matches_the_upstream_equations_worked_by_hand() { + let m = model(1, 1, single_layer_weights()); + let input = [0.2f32, -0.6, 0.9]; + let got = run(&m, &input); + + let (mut h, mut c) = (H0, C0); + let mut want = Vec::new(); + for &x in &input { + let (h_next, c_next) = hand_step(W1, B1, h, c, x, [0, 1, 2, 3]); + h = h_next; + c = c_next; + want.push(HEAD_B + HEAD_W * h); + } + + assert_eq!(got.len(), want.len()); + for (t, (&g, &w)) in got.iter().zip(&want).enumerate() { + assert!( + (g - w).abs() < 1e-6, + "sample {t}: got {g}, hand-computed {w}" + ); + } + } + + /// The fixture above is only evidence if it can tell the plausible misreadings apart, so this + /// asserts that it does: each variant below is what the same weights would produce under one + /// specific wrong reading of the format, and each must differ materially from the right one. + #[test] + fn the_hand_computed_fixture_discriminates_the_plausible_misreadings() { + let x = 0.2f32; + let (h_correct, _) = hand_step(W1, B1, H0, C0, x, [0, 1, 2, 3]); + + // Gate order f,i,g,o (i and f transposed) -- the classic one, since PyTorch's own + // documentation lists the gates in that order in places. + let (h_if_swapped, _) = hand_step(W1, B1, H0, C0, x, [1, 0, 2, 3]); + // Gate order i,f,o,g (the candidate and output gates transposed). + let (h_go_swapped, _) = hand_step(W1, B1, H0, C0, x, [0, 1, 3, 2]); + // `h0`/`c0` read in the opposite order. + let (h_h0c0_swapped, _) = hand_step(W1, B1, C0, H0, x, [0, 1, 2, 3]); + // `h0`/`c0` treated as zero init rather than as learned weights. + let (h_zero_init, _) = hand_step(W1, B1, 0.0, 0.0, x, [0, 1, 2, 3]); + + for (name, other) in [ + ("i/f swapped", h_if_swapped), + ("g/o swapped", h_go_swapped), + ("h0/c0 swapped", h_h0c0_swapped), + ("zero-initialised state", h_zero_init), + ] { + assert!( + (h_correct - other).abs() > 1e-3, + "the fixture cannot tell the correct reading from `{name}` ({h_correct} vs \ + {other}) -- pick different weights" + ); + } + } + + /// Layer `i > 0` takes the previous layer's hidden state from the **same** sample + /// (`lstm.cpp:153-155`). A one-sample delay there is invisible in a steady state but not at + /// the start of the signal, which is what this compares. + #[test] + fn two_layers_chain_within_one_sample() { + // Layer 1 has `cell_input_size == hidden_size == 1` here, so its weight block has the + // same shape as layer 0's; the width-discrimination case is the test below. + const W2: [f32; 8] = [-0.4, 0.2, 0.6, -0.3, 0.45, 0.15, -0.2, 0.5]; + const B2: [f32; 4] = [-0.05, 0.15, -0.25, 0.35]; + const H0_2: f32 = -0.2; + const C0_2: f32 = 0.4; + + let mut weights = Vec::new(); + weights.extend_from_slice(&W1); + weights.extend_from_slice(&B1); + weights.push(H0); + weights.push(C0); + weights.extend_from_slice(&W2); + weights.extend_from_slice(&B2); + weights.push(H0_2); + weights.push(C0_2); + weights.push(HEAD_W); + weights.push(HEAD_B); + + let m = model(2, 1, weights); + let input = [0.2f32, -0.6, 0.9]; + let got = run(&m, &input); + + let (mut h1, mut c1) = (H0, C0); + let (mut h2, mut c2) = (H0_2, C0_2); + let mut want = Vec::new(); + let mut want_delayed = Vec::new(); + let mut h1_prev = H0; + for &x in &input { + let (h1n, c1n) = hand_step(W1, B1, h1, c1, x, [0, 1, 2, 3]); + // Correct: layer 1 consumes `h1n`, this sample's layer-0 output. + let (h2n, c2n) = hand_step(W2, B2, h2, c2, h1n, [0, 1, 2, 3]); + // The misreading: layer 1 consumes the *previous* sample's layer-0 output. + let (h2_delayed, _) = hand_step(W2, B2, h2, c2, h1_prev, [0, 1, 2, 3]); + h1_prev = h1n; + h1 = h1n; + c1 = c1n; + h2 = h2n; + c2 = c2n; + want.push(HEAD_B + HEAD_W * h2n); + want_delayed.push(HEAD_B + HEAD_W * h2_delayed); + } + + for (t, (&g, &w)) in got.iter().zip(&want).enumerate() { + assert!( + (g - w).abs() < 1e-6, + "sample {t}: got {g}, hand-computed {w}" + ); + } + assert!( + (want[0] - want_delayed[0]).abs() > 1e-3, + "this fixture cannot tell same-sample chaining from a one-sample delay" + ); + } + + /// `lstm.cpp:79-80`: layer `i > 0` is constructed with `cell_input_size == hidden_size`, not + /// `input_size`. With `hidden_size > input_size` the two readings consume different numbers + /// of weights, so upstream's "the weight vector is exactly exhausted" assertion + /// (`lstm.cpp:100`) is what separates them — and nothing external does: the only real + /// `NeuralAmpModelerCore` render this project holds for LSTM + /// (`namir-nam/tests/golden/lstm_tiny*`) is a **one-layer** model. + #[test] + fn a_later_layer_takes_its_input_width_from_hidden_size() { + const HIDDEN: usize = 2; + const INPUT: usize = 1; + let per_layer = + |cell_input: usize| 4 * HIDDEN * (cell_input + HIDDEN) + 4 * HIDDEN + 2 * HIDDEN; + let correct = per_layer(INPUT) + per_layer(HIDDEN) + HIDDEN + 1; + let misread = per_layer(INPUT) + per_layer(INPUT) + HIDDEN + 1; + assert_ne!( + correct, misread, + "this shape cannot tell the two readings apart -- widen `hidden_size`" + ); + + // Weight values are irrelevant to the count; a simple deterministic ramp keeps the model + // non-degenerate. + let weights: Vec = (0..correct).map(|i| ((i % 7) as f32 - 3.0) * 0.1).collect(); + let m = model(2, HIDDEN, weights); + let out = run(&m, &[0.2, -0.6, 0.9]); + assert_eq!(out.len(), 3); + assert!(out.iter().all(|v| v.is_finite())); + } +} diff --git a/crates/namir-fixtures/src/nam/mod.rs b/crates/namir-fixtures/src/nam/mod.rs index 8f404f8..fdfe73d 100644 --- a/crates/namir-fixtures/src/nam/mod.rs +++ b/crates/namir-fixtures/src/nam/mod.rs @@ -147,9 +147,21 @@ pub struct LstmConfig { } /// A thin public re-export of `lstm_infer::run`, exposed *specifically* as a cross-crate numeric -/// parity oracle for `namir-nam`'s independently-written LSTM implementation — the LSTM -/// counterpart of [`reference_infer`]; see that function's doc comment, which applies here -/// unchanged except for architecture. +/// parity oracle for `namir-nam`'s own LSTM implementation — the LSTM counterpart of +/// [`reference_infer`]; see that function's doc comment, which applies here unchanged except for +/// architecture and for the paragraph below. +/// +/// **What "parity oracle" is worth here, stated precisely** (this doc comment used to call +/// `namir-nam`'s LSTM implementation "independently-written" with respect to this one, which was +/// false as written): `lstm_infer.rs` was originally written from `namir-nam`'s *reading* of the +/// `.nam` LSTM format, not from the format's own source, so agreement between the two could not +/// rule out a misreading they shared. M14 re-derived it from `NeuralAmpModelerCore`'s +/// `NAM/lstm.h`/`NAM/lstm.cpp` at the pinned commit, fact by fact with citations, and pinned each +/// fact to hand-computed arithmetic in that module's own tests. Two things are still true and +/// worth keeping in view: the re-derivation was not blind (its author had seen the earlier port), +/// and the only *external* LSTM evidence this project holds — `namir-nam`'s +/// `tests/golden_reference.rs` `lstm_tiny` render — is a one-layer model, so the multi-layer +/// facts rest on `lstm_infer.rs`'s analytic tests rather than on a real reference render. pub fn reference_infer_lstm(model: &LstmModel, input: &[f32]) -> Vec { lstm_infer::run(model, input) } diff --git a/crates/namir-ir/examples/generate_ir_fuzz_corpus.rs b/crates/namir-ir/examples/generate_ir_fuzz_corpus.rs index 661cdc6..20a172d 100644 --- a/crates/namir-ir/examples/generate_ir_fuzz_corpus.rs +++ b/crates/namir-ir/examples/generate_ir_fuzz_corpus.rs @@ -13,10 +13,11 @@ //! One valid WAV (a short mono 16-bit-PCM delta impulse, encoded via //! `namir_fixtures::ir::to_mono_wav_bytes` -- the same generator `namir-ir`'s own correctness //! tests already trust) plus its [`namir_fixtures::mutate::seeded_corpus`] variants. That mutator -//! is JSON-aware for two of its four kinds (`DropField`/`CorruptNumber`) and falls back to a byte -//! flip on non-JSON input (its own doc comment) -- WAV bytes always take that fallback path for -//! those two, which is expected and still yields four distinct near-valid variants overall -//! (`ByteFlip`/`Truncate` operate on raw bytes regardless of format). +//! is JSON-aware for four of its six kinds (`DropField`/`CorruptNumber`/`NullField`/ +//! `RetypeField`) and falls back to a byte flip on non-JSON input (its own doc comment) -- WAV +//! bytes always take that fallback path for those four, which is expected and still yields six +//! distinct near-valid variants overall (`ByteFlip`/`Truncate` operate on raw bytes regardless of +//! format). //! //! Usage: `cargo run -p namir-ir --example generate_ir_fuzz_corpus` diff --git a/crates/namir-ir/fuzz/corpus/probe_wav/mutated_4.bin b/crates/namir-ir/fuzz/corpus/probe_wav/mutated_4.bin new file mode 100644 index 0000000000000000000000000000000000000000..c3d9711efb5f9127a08fcdc36692471d22502d86 GIT binary patch literal 172 zcmWIYbaPw6z`zjh80MOmTcRKUWHSO`!)_p>oRNWvL4YA8u_Unp$o*drBya);oHEps F007W33KakV literal 0 HcmV?d00001 diff --git a/crates/namir-ir/fuzz/corpus/probe_wav/mutated_5.bin b/crates/namir-ir/fuzz/corpus/probe_wav/mutated_5.bin new file mode 100644 index 0000000000000000000000000000000000000000..6715a44620e94732c1f5bb6c0d727fc9ef3a00c4 GIT binary patch literal 172 zcmWIYbaPw6z`zjh80MOmTcRKUWHSO`!)_p>oRNWvL4YA8u_Unp$o*drB&Y`(NHq!o D&@u{D literal 0 HcmV?d00001 diff --git a/crates/namir-nam/fuzz/corpus/load_nam/mutated_4.bin b/crates/namir-nam/fuzz/corpus/load_nam/mutated_4.bin new file mode 100644 index 0000000..c153ec6 --- /dev/null +++ b/crates/namir-nam/fuzz/corpus/load_nam/mutated_4.bin @@ -0,0 +1 @@ +{"architecture":"WaveNet","config":{"head":null,"head_scale":0.2059406,"layers":[{"activation":"Tanh","channels":null,"condition_size":1,"dilations":[1,2,4,8],"gated":false,"head_bias":false,"head_size":2,"input_size":1,"kernel_size":3},{"activation":"Tanh","channels":2,"condition_size":1,"dilations":[1,2,4,8],"gated":false,"head_bias":true,"head_size":1,"input_size":4,"kernel_size":3}]},"metadata":{"description":"Seeded, constrained-init WaveNet (D-19.1): not trained, tonal realism is irrelevant, only architecture and weights matter for a parity test.","gear_type":"amp","modeled_by":"namir-fixtures","name":"namir-fixtures generated nano WaveNet","tone_type":"clean"},"sample_rate":48000,"version":"0.5.5","weights":[-0.71856165,0.08382201,-0.4292693,0.3453338,0.24466312,-0.22172657,0.23127967,-0.09227884,-0.23842245,0.021841377,0.013995588,-0.12920724,-0.21516508,-0.2117163,-0.14682244,-0.075649425,0.051401705,-0.20443505,0.11126265,-0.15108068,0.23435843,0.047484457,0.1888577,0.27464074,0.16230619,-0.087433934,0.053222895,0.2326529,-0.19944285,-0.061713845,0.23521149,0.022384048,0.0620102,-0.18064089,0.16294819,-0.27373427,0.15406075,-0.056387305,0.25956237,0.05320123,-0.2752514,-0.19491723,0.14565104,-0.2867677,0.24225092,-0.26653177,-0.087074116,-0.17522404,0.12673157,-0.21603826,0.16586274,-0.0960149,0.0,0.0,0.0,0.0,0.25733113,-0.37609053,0.2351048,0.86294484,0.28815258,0.24759448,-0.4469658,-0.07556951,0.48035896,0.061979055,-0.44960737,0.42668724,-0.07883918,0.3925513,0.007968426,-0.35345376,-0.29528725,-0.47212005,0.39546132,-0.38601053,0.0,0.0,0.0,0.0,0.19694552,0.21963096,0.11190981,0.18081495,-0.085480526,-0.2597614,0.23409504,0.15899849,0.15408897,0.23735702,0.1311877,0.26563108,-0.03404668,-0.10072972,-0.25213718,0.002862513,0.051464498,-0.28641862,0.17928928,0.12375337,0.1869233,0.07289165,0.10798338,-0.2629154,0.12572756,-0.025771081,0.072568595,0.0103191435,0.026002884,0.08700886,-0.19020206,-0.11209123,-0.20954809,0.030539125,-0.12483469,0.001192689,0.014344335,-0.094255164,0.050610095,-0.13494536,0.05659294,-0.15851611,-0.09013368,-0.1641344,0.0806061,-0.17917258,0.28249836,0.06295133,0.0,0.0,0.0,0.0,-0.6222315,0.54739714,-0.41582227,-0.64969206,0.05295098,-0.1300869,0.16559696,0.27014995,-0.1422069,0.47342896,-0.05021572,-0.30224705,-0.32624924,0.069434166,0.04027295,0.024927616,0.12897837,-0.24547589,-0.48451376,-0.40702105,0.0,0.0,0.0,0.0,0.24334484,0.28706998,0.04569924,0.16275802,-0.20812842,-0.25779203,-0.19278714,-0.020062566,0.11231676,0.0048990548,0.21685743,0.13975152,-0.026774585,-0.0044523776,0.040429264,-0.2736815,-0.14387567,-0.12866132,-0.042904794,0.26788515,0.14741373,-0.026516676,0.035200685,-0.004151821,0.13483319,0.24538916,-0.20906913,0.20961684,0.043789744,-0.27242392,-0.25236294,-0.2672995,0.270064,-0.07811345,-0.11882657,0.005959809,0.18507153,-0.018899083,0.01886329,0.13675863,-0.2477578,0.19570604,0.10745245,0.2625544,-0.1346801,-0.28066665,0.203231,-0.25643176,0.0,0.0,0.0,0.0,0.7240617,-0.19972825,0.74778724,0.9062257,0.4409287,-0.38609457,-0.47810972,-0.22969246,0.38479435,-0.4055872,0.40338135,-0.17777121,-0.080718994,0.102237344,0.4362489,-0.091579676,-0.055714846,0.31604683,-0.025115132,0.10239029,0.0,0.0,0.0,0.0,-0.257069,0.04805219,0.053842127,0.27576834,0.07195321,-0.17766446,0.1138632,0.019616991,-0.2764447,0.15838078,0.06980014,-0.14483449,0.25973988,0.108435154,0.004925579,0.27511084,0.23644233,-0.0010420084,0.18072265,-0.034948915,-0.14203185,-0.091774344,-0.1532623,-0.16419819,-0.037718594,-0.054826334,-0.0092175305,0.20211267,-0.05623512,-0.09194338,0.24017972,-0.16473505,0.20439973,0.27903807,-0.10589583,0.28837347,0.22950315,-0.27331623,0.098597586,0.23226124,0.09624532,-0.26527378,0.25750917,-0.07662833,-0.009801865,0.07391751,0.25368845,-0.20094118,0.0,0.0,0.0,0.0,-0.32285953,0.28341985,0.4510846,0.077054024,-0.3804201,-0.1078881,-0.2891786,-0.36423016,0.3005389,-0.17965746,-0.46416044,0.08236468,-0.25859952,-0.07682896,-0.07795322,-0.40543926,-0.49901652,0.08749735,-0.054519653,-0.0055669546,0.0,0.0,0.0,0.0,0.3555057,-0.18164933,-0.35015607,-0.17920375,0.29058325,-0.41819537,-0.116699934,-0.40520918,-0.47128808,-0.47643328,-0.0026125908,-0.15840995,0.13209498,0.31543827,-0.41790557,0.006455064,-0.09737855,-0.36903924,-0.39866683,-0.27771318,0.0041806996,0.011366278,0.0435884,-0.18323393,0.12609896,0.30624434,-0.3066103,-0.40414235,0.0,0.0,-0.6729207,-0.98278093,-0.63720554,-0.62297714,-0.16788149,0.026917338,0.0,0.0,0.26354226,0.30093512,-0.35376024,0.06593871,-0.029768914,-0.25844592,-0.25694573,-0.15032181,0.38652954,-0.06677997,0.043539524,0.11403814,0.0,0.0,-0.7427013,-0.36946464,0.3238837,-0.36125126,0.48890644,0.1419934,0.0,0.0,-0.20966673,-0.30758452,0.113411695,0.06911424,-0.048631847,0.27715322,-0.25782466,0.09594694,-0.39352572,0.31721064,0.24846163,0.17320958,0.0,0.0,0.817467,-0.9070325,0.59551054,0.25873494,0.5030673,-0.7057643,0.0,0.0,0.33602247,0.25620183,0.38437596,0.304453,-0.31618902,-0.40222964,-0.0114762485,-0.40294242,0.39686713,0.2045081,-0.24678445,-0.046315968,0.0,0.0,-0.8193433,-0.82332945,-0.12963879,-0.5879094,0.42664963,-0.56485844,0.0,0.0,-0.2046184,-0.61091435,0.0,0.2059406]} \ No newline at end of file diff --git a/crates/namir-nam/fuzz/corpus/load_nam/mutated_5.bin b/crates/namir-nam/fuzz/corpus/load_nam/mutated_5.bin new file mode 100644 index 0000000..4f85ed5 --- /dev/null +++ b/crates/namir-nam/fuzz/corpus/load_nam/mutated_5.bin @@ -0,0 +1 @@ +{"architecture":"WaveNet","config":{"head":null,"head_scale":0.2059406,"layers":[{"activation":"Tanh","channels":4,"condition_size":1,"dilations":0,"gated":false,"head_bias":false,"head_size":2,"input_size":1,"kernel_size":3},{"activation":"Tanh","channels":2,"condition_size":1,"dilations":[1,2,4,8],"gated":false,"head_bias":true,"head_size":1,"input_size":4,"kernel_size":3}]},"metadata":{"description":"Seeded, constrained-init WaveNet (D-19.1): not trained, tonal realism is irrelevant, only architecture and weights matter for a parity test.","gear_type":"amp","modeled_by":"namir-fixtures","name":"namir-fixtures generated nano WaveNet","tone_type":"clean"},"sample_rate":48000,"version":"0.5.5","weights":[-0.71856165,0.08382201,-0.4292693,0.3453338,0.24466312,-0.22172657,0.23127967,-0.09227884,-0.23842245,0.021841377,0.013995588,-0.12920724,-0.21516508,-0.2117163,-0.14682244,-0.075649425,0.051401705,-0.20443505,0.11126265,-0.15108068,0.23435843,0.047484457,0.1888577,0.27464074,0.16230619,-0.087433934,0.053222895,0.2326529,-0.19944285,-0.061713845,0.23521149,0.022384048,0.0620102,-0.18064089,0.16294819,-0.27373427,0.15406075,-0.056387305,0.25956237,0.05320123,-0.2752514,-0.19491723,0.14565104,-0.2867677,0.24225092,-0.26653177,-0.087074116,-0.17522404,0.12673157,-0.21603826,0.16586274,-0.0960149,0.0,0.0,0.0,0.0,0.25733113,-0.37609053,0.2351048,0.86294484,0.28815258,0.24759448,-0.4469658,-0.07556951,0.48035896,0.061979055,-0.44960737,0.42668724,-0.07883918,0.3925513,0.007968426,-0.35345376,-0.29528725,-0.47212005,0.39546132,-0.38601053,0.0,0.0,0.0,0.0,0.19694552,0.21963096,0.11190981,0.18081495,-0.085480526,-0.2597614,0.23409504,0.15899849,0.15408897,0.23735702,0.1311877,0.26563108,-0.03404668,-0.10072972,-0.25213718,0.002862513,0.051464498,-0.28641862,0.17928928,0.12375337,0.1869233,0.07289165,0.10798338,-0.2629154,0.12572756,-0.025771081,0.072568595,0.0103191435,0.026002884,0.08700886,-0.19020206,-0.11209123,-0.20954809,0.030539125,-0.12483469,0.001192689,0.014344335,-0.094255164,0.050610095,-0.13494536,0.05659294,-0.15851611,-0.09013368,-0.1641344,0.0806061,-0.17917258,0.28249836,0.06295133,0.0,0.0,0.0,0.0,-0.6222315,0.54739714,-0.41582227,-0.64969206,0.05295098,-0.1300869,0.16559696,0.27014995,-0.1422069,0.47342896,-0.05021572,-0.30224705,-0.32624924,0.069434166,0.04027295,0.024927616,0.12897837,-0.24547589,-0.48451376,-0.40702105,0.0,0.0,0.0,0.0,0.24334484,0.28706998,0.04569924,0.16275802,-0.20812842,-0.25779203,-0.19278714,-0.020062566,0.11231676,0.0048990548,0.21685743,0.13975152,-0.026774585,-0.0044523776,0.040429264,-0.2736815,-0.14387567,-0.12866132,-0.042904794,0.26788515,0.14741373,-0.026516676,0.035200685,-0.004151821,0.13483319,0.24538916,-0.20906913,0.20961684,0.043789744,-0.27242392,-0.25236294,-0.2672995,0.270064,-0.07811345,-0.11882657,0.005959809,0.18507153,-0.018899083,0.01886329,0.13675863,-0.2477578,0.19570604,0.10745245,0.2625544,-0.1346801,-0.28066665,0.203231,-0.25643176,0.0,0.0,0.0,0.0,0.7240617,-0.19972825,0.74778724,0.9062257,0.4409287,-0.38609457,-0.47810972,-0.22969246,0.38479435,-0.4055872,0.40338135,-0.17777121,-0.080718994,0.102237344,0.4362489,-0.091579676,-0.055714846,0.31604683,-0.025115132,0.10239029,0.0,0.0,0.0,0.0,-0.257069,0.04805219,0.053842127,0.27576834,0.07195321,-0.17766446,0.1138632,0.019616991,-0.2764447,0.15838078,0.06980014,-0.14483449,0.25973988,0.108435154,0.004925579,0.27511084,0.23644233,-0.0010420084,0.18072265,-0.034948915,-0.14203185,-0.091774344,-0.1532623,-0.16419819,-0.037718594,-0.054826334,-0.0092175305,0.20211267,-0.05623512,-0.09194338,0.24017972,-0.16473505,0.20439973,0.27903807,-0.10589583,0.28837347,0.22950315,-0.27331623,0.098597586,0.23226124,0.09624532,-0.26527378,0.25750917,-0.07662833,-0.009801865,0.07391751,0.25368845,-0.20094118,0.0,0.0,0.0,0.0,-0.32285953,0.28341985,0.4510846,0.077054024,-0.3804201,-0.1078881,-0.2891786,-0.36423016,0.3005389,-0.17965746,-0.46416044,0.08236468,-0.25859952,-0.07682896,-0.07795322,-0.40543926,-0.49901652,0.08749735,-0.054519653,-0.0055669546,0.0,0.0,0.0,0.0,0.3555057,-0.18164933,-0.35015607,-0.17920375,0.29058325,-0.41819537,-0.116699934,-0.40520918,-0.47128808,-0.47643328,-0.0026125908,-0.15840995,0.13209498,0.31543827,-0.41790557,0.006455064,-0.09737855,-0.36903924,-0.39866683,-0.27771318,0.0041806996,0.011366278,0.0435884,-0.18323393,0.12609896,0.30624434,-0.3066103,-0.40414235,0.0,0.0,-0.6729207,-0.98278093,-0.63720554,-0.62297714,-0.16788149,0.026917338,0.0,0.0,0.26354226,0.30093512,-0.35376024,0.06593871,-0.029768914,-0.25844592,-0.25694573,-0.15032181,0.38652954,-0.06677997,0.043539524,0.11403814,0.0,0.0,-0.7427013,-0.36946464,0.3238837,-0.36125126,0.48890644,0.1419934,0.0,0.0,-0.20966673,-0.30758452,0.113411695,0.06911424,-0.048631847,0.27715322,-0.25782466,0.09594694,-0.39352572,0.31721064,0.24846163,0.17320958,0.0,0.0,0.817467,-0.9070325,0.59551054,0.25873494,0.5030673,-0.7057643,0.0,0.0,0.33602247,0.25620183,0.38437596,0.304453,-0.31618902,-0.40222964,-0.0114762485,-0.40294242,0.39686713,0.2045081,-0.24678445,-0.046315968,0.0,0.0,-0.8193433,-0.82332945,-0.12963879,-0.5879094,0.42664963,-0.56485844,0.0,0.0,-0.2046184,-0.61091435,0.0,0.2059406]} \ No newline at end of file From d5e88346d318b0f38a018b407988d2dac4a54c16 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 13:52:17 +0000 Subject: [PATCH 07/44] Regenerate the test plan for wave 1's two tag demotions FR-GATE-020 and NFR-SEC-010 become PARTIAL, each carrying its uncovered: text. Output of `xtask traceability --write`; not hand-edited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- docs/03-test-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/03-test-plan.md b/docs/03-test-plan.md index 2ac5c7e..5c549d3 100644 --- a/docs/03-test-plan.md +++ b/docs/03-test-plan.md @@ -36,7 +36,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | FR-ERR-060 | S | **PARTIAL** — `workspace`: FR-ERR-060 — the method's "no network-capable dependency is linked" remains a by-name deny list its own comment calls deliberately non-exhaustive: a network-capable crate not on it enters Cargo.lock with this gate green, and the compensating control is that xtask attribution fails until a human adds the new crate to THIRD-PARTY-NOTICES.md, which is review rather than a build-time classification; closes M8 | | FR-ERR-070 | S | `workspace` | | FR-GATE-010 | U | `namir-engine` | -| FR-GATE-020 | U | `namir-dsp` | +| FR-GATE-020 | U | **PARTIAL** — `namir-dsp`: FR-GATE-020 — the method ("exactly one close event") is asserted over FR-GATE-010's hold range from 5 ms up. At 0 and 1 ms the same decaying low-E note produces 62 and 61 close events with the shipped 3 dB gap: the 1 ms detector ripples about 9 dB peak-to-peak on an 82 Hz carrier and the gap is narrower than the ripple. That is a gate defect rather than a test gap — 12 dB of hysteresis, or a detector whose release is slow relative to the lowest program frequency, produces exactly one at every hold — so the two settings are left unasserted rather than pinned to today's numbers; closes M8 | | FR-GATE-030 | U | `namir-dsp` | | FR-IN-010 | U | `namir-engine` | | FR-IN-020 | U | **PARTIAL** — `namir-dsp`: FR-IN-020 — the "M for the display" half of the Verify line has no artifact: there is no docs/manual-tests/fr-in-020-*.md, and namir_ui::MeterReading carries only peak_db and rms_db, so the peak-hold value TrimStage publishes reaches no UI field for any script to observe; closes M8 | @@ -131,7 +131,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | NFR-RT-020 | S | `namir-engine` | | NFR-RT-030 | B | **PARTIAL** — `namir-engine`: NFR-RT-030 — the "on any supported platform" clause. Both the assembled arms and the per-stage arms added at M14 assert the 10% budget wherever this binary is run, but one run measures one platform and D-2.4 certifies only the 02-architecture.md §2 reference machine (Windows 11, x86-64); nothing runs this on macOS or Linux, where DenormalGuard's own per-architecture degradation (D-7.4) is what would show up; closes M8 | | NFR-RT-040 | B | **PARTIAL** — `namir-engine`: NFR-RT-040 — all three variables the requirement names are varied and asserted here: nine content and parameter conditions (spread 1.9%) and every run's own two halves (worst drift 10.1%), against the contamination-immune estimator. What is only partly spanned is the statistic the Verify line actually names: raw p99.9 is computed and printed for all nine arms but compared across only the 3 of 9 that D-2.4 left quotable on the machine this has run on, the other six being contaminated. And that machine is not 02-architecture.md section 2's reference machine — the ratios this binary asserts are machine-independent in a way NFR-PERF-010's absolute budget is not, but no run on the reference machine has been performed. Both are closed by one quiet run there, not by more code; closes M8 | -| NFR-SEC-010 | S | `namir-ir`, `namir-nam`, `namir-state` | +| NFR-SEC-010 | S | **PARTIAL** — `namir-ir`, `namir-nam`, `namir-state`: NFR-SEC-010 — within the `.nam` kind this target reaches `load` and the `new_state`/`process_block` path it feeds, and nothing else: `probe_metadata`, the weights-free read `namir-library`'s scanner runs over every file it indexes, is the `.nam` analogue of the separately-fuzzed `probe_wav` and is reached by no target in this crate; closes M8 | | NFR-SEC-020 | U | `namir-ir`, `namir-library`, `namir-state`, `namir-worker` | | NFR-SEC-030 | S | `workspace` | From 81792a9dadfe3ca6453625c0f969520d1f9d0085 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:05:10 +0000 Subject: [PATCH 08/44] Three failure modes documented as impossible (#126, #109, #116) BiquadCoeffs::design panicked below a 4 Hz sample rate ("min > max ... min = 1.0, max = 0.4995"). Its floor is now lowered to meet the ceiling rather than the ceiling raised to meet the floor: the issue's suggested fix would return a design at the sample rate, i.e. above Nyquist, which the constant's own doc calls meaningless. #126 is filed as low severity on the grounds that design runs on a worker. It does not. apply_param_direct -- documented wait-free, called from the CLAP audio processor's host-automation path -- reaches it through EqStage::retarget and IrStage's cut targets, so the panic was reachable on the audio thread and a Result was never viable. That doc claim is corrected where it was made. A panicked scan left the scanning flag set, so the library never scanned again for the rest of the session. A ScanFlag guard releases it on unwind; the explicit early drop preserves the documented ordering, where the flag must read false before on_complete so a caller restarting from its own callback is not refused. A job dropped without running now releases it too. NaN into ParamValues::set stored NaN and wrote JSON null, which then would not read back. It now takes the default, the same rule from_document_section already applies to a non-finite number arriving from a file. Infinity keeps saturating to the bound rather than resetting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-dsp/src/biquad.rs | 52 +++++++++++++++--- crates/namir-state/src/params.rs | 86 ++++++++++++++++++++++++++++-- crates/namir-worker/src/library.rs | 85 ++++++++++++++++++++++++++--- 3 files changed, 204 insertions(+), 19 deletions(-) diff --git a/crates/namir-dsp/src/biquad.rs b/crates/namir-dsp/src/biquad.rs index fefa151..d5a61d9 100644 --- a/crates/namir-dsp/src/biquad.rs +++ b/crates/namir-dsp/src/biquad.rs @@ -35,9 +35,14 @@ pub struct BiquadCoeffs { } /// Frequencies at or above Nyquist, or non-positive, have no meaningful digital-filter design; -/// clamped rather than made fallible so `design` is infallible at the type level (P1: this runs -/// on a worker computing coefficients for the RT thread to consume, but the type must not force -/// a `Result` onto a caller for an out-of-range UI value). +/// clamped rather than made fallible so `design` is infallible at the type level (P1: this also +/// runs *on the audio thread* — `AudioEngine::apply_param_direct` reaches `EqStage::retarget` +/// and `IrStage::retarget_low_cut`, both of which redesign coefficients inline — so the type +/// must not force a `Result` onto a caller for an out-of-range UI value, and there would be no +/// sound way to report one from there anyway). +/// +/// The floor gives way to the ceiling when the two invert, which they do below a 3 Hz sample +/// rate: see `design`'s own comment (issue #126). const MIN_FREQ_HZ: f64 = 1.0; const NYQUIST_HEADROOM: f64 = 0.999; @@ -58,9 +63,10 @@ impl BiquadCoeffs { } /// Designs a biquad per the RBJ Audio EQ Cookbook formulas. `freq_hz` is clamped to - /// `(MIN_FREQ_HZ, nyquist * NYQUIST_HEADROOM)` and `q` to `>= MIN_Q` before computing, so a + /// `(MIN_FREQ_HZ, nyquist * NYQUIST_HEADROOM)` — with the floor lowered to the ceiling at a + /// sample rate too low to contain both, below — and `q` to `>= MIN_Q` before computing, so a /// caller can never produce an unstable or degenerate design (house rule: clamp, don't fail, - /// for anything that could eventually run on the audio thread). + /// for anything that could eventually run on the audio thread, which this does). /// /// Shelf filters use a fixed shelf slope `S = 1`, per this crate's brief. pub fn design( @@ -71,7 +77,15 @@ impl BiquadCoeffs { sample_rate: SampleRate, ) -> Self { let nyquist = sample_rate.hz_f64() / 2.0; - let freq_hz = freq_hz.clamp(MIN_FREQ_HZ, nyquist * NYQUIST_HEADROOM); + // `SampleRate::new` rejects only zero, so a host reporting 1 or 2 Hz is constructible, + // and at those rates the sub-Nyquist ceiling falls below `MIN_FREQ_HZ` -- bounds + // `f64::clamp` panics on ("min > max, or either was NaN"), which is what issue #126 + // caught contradicting this function's documented infallibility. The ceiling is the + // bound that has to hold, since a design at or above Nyquist is the meaningless one, so + // the floor gives way to it rather than the reverse. Both stay finite and positive for + // every constructible rate (`nyquist >= 0.5`), so the clamp cannot panic. + let max_freq_hz = nyquist * NYQUIST_HEADROOM; + let freq_hz = freq_hz.clamp(MIN_FREQ_HZ.min(max_freq_hz), max_freq_hz); let q = q.max(MIN_Q); let w0 = 2.0 * std::f64::consts::PI * freq_hz / sample_rate.hz_f64(); @@ -508,6 +522,32 @@ mod tests { } } + /// The degenerate end of the sample-rate axis, which the sweep above does not reach: + /// `SampleRate::new` rejects only zero, so a host reporting 1 or 2 Hz is constructible, and + /// at those rates Nyquist itself sits below `MIN_FREQ_HZ` — the two clamp bounds invert. + /// `design` documents itself infallible, so every kind must still return a finite design + /// rather than panicking inside `f64::clamp` (issue #126). 3 Hz is the first rate whose + /// bounds are the right way round, included as the neighbouring in-contract case. + #[test] + fn design_is_infallible_at_a_sample_rate_below_twice_the_minimum_frequency() { + let kinds = [ + FilterKind::LowPass, + FilterKind::HighPass, + FilterKind::LowShelf, + FilterKind::HighShelf, + FilterKind::Peaking, + ]; + for hz in [1u32, 2, 3] { + for &kind in &kinds { + let c = BiquadCoeffs::design(kind, 1000.0, 0.707, 6.0, sr(hz)); + assert!( + [c.b0, c.b1, c.b2, c.a1, c.a2].iter().all(|v| v.is_finite()), + "non-finite design at {hz} Hz for {kind:?}: {c:?}" + ); + } + } + } + // --- FR-PARAM-040's second sentence: "Frequency-affecting parameters shall be smoothed or // their coefficients interpolated to the same audible standard." // diff --git a/crates/namir-state/src/params.rs b/crates/namir-state/src/params.rs index 6ba8dc2..2d93b94 100644 --- a/crates/namir-state/src/params.rs +++ b/crates/namir-state/src/params.rs @@ -80,8 +80,10 @@ impl ParamValues { /// `0..values.len() - 1` (`Stepped`) rather than rejecting an out-of-range value outright — /// the same tolerance [`Self::from_document_section`] applies to a value read from a file, /// applied here so a programmatic caller (a UI control, a test) gets identical behaviour - /// rather than a second set of rules. Fails only when `key` names no `REGISTRY` entry at - /// all, since there is then no descriptor to clamp against. + /// rather than a second set of rules. A NaN is likewise not rejected but normalised, to the + /// descriptor's default — the read path's own rule for a non-finite value (issue #116; it + /// used to be stored, and then serialised as JSON `null`). Fails only when `key` names no + /// `REGISTRY` entry at all, since there is then no descriptor to clamp against. pub fn set(&mut self, key: &str, value: f32) -> Result<(), UnknownParameter> { let index = Self::index_of(key).ok_or_else(|| UnknownParameter(key.to_string()))?; self.0[index] = clamp_to_descriptor(®ISTRY[index], value); @@ -178,7 +180,20 @@ fn default_of(descriptor: &ParamDescriptor) -> f32 { } } +/// `value` brought into the range `descriptor` declares, and — since issue #116 — made finite. +/// +/// **The NaN arm is not defensive padding.** `f32::clamp` panics on NaN *bounds* but returns NaN +/// for a NaN *input*, and `f32::round` is NaN-preserving too, so neither arm below filters one +/// out: a NaN handed to [`ParamValues::set`] used to be stored, and [`number_value`] then wrote +/// it as `Value::Null`, which reads back as a `state.param.invalid` warning and a silent reset. +/// The rule applied here is the one [`ParamValues::from_document_section`] already applies to a +/// non-finite number arriving from a file ("there is no nearby value to clamp a non-number to" — +/// reset to the documented default), so the setter and the read path stay one set of rules rather +/// than two. ±Infinity needs no arm of its own: it is ordered, so `clamp` maps it onto a bound. fn clamp_to_descriptor(descriptor: &ParamDescriptor, value: f32) -> f32 { + if value.is_nan() { + return default_of(descriptor); + } match descriptor.kind { ParamKind::Continuous { min, max, .. } => value.clamp(min, max), ParamKind::Stepped { values, .. } => { @@ -201,9 +216,13 @@ fn clamp_to_descriptor(descriptor: &ParamDescriptor, value: f32) -> f32 { fn number_value(value: f32) -> Value { Number::from_f64(f64::from(value)) .map(Value::Number) - .unwrap_or(Value::Null) // value is always finite (clamp_to_descriptor never produces - // NaN/Infinity from a finite descriptor range), so this arm is unreachable in practice; Null - // rather than a panic keeps that unreachability a documented assumption, not a crash site. + .unwrap_or(Value::Null) // Unreachable: every value stored in a `ParamValues` has been + // through `clamp_to_descriptor`, which is total over `f32` -- NaN to the default, ±Infinity + // to a bound, everything else into a finite declared range. That was an *assumption* until + // issue #116, where the public setter passed a NaN straight through and this arm wrote the + // `null` that made the document unreadable; it is now established by that function rather + // than only asserted here. Null rather than a panic keeps a future violation a warning on + // read instead of a crash on save. } #[cfg(test)] @@ -314,6 +333,63 @@ mod tests { assert!(Number::from_f64(f64::INFINITY).is_none()); } + /// **Issue #116.** `f32::clamp` returns NaN for a NaN input (it panics only on NaN *bounds*), + /// so before this the public setter stored one, `number_value` turned it into `Value::Null`, + /// and the document that had just been saved read back as a `state.param.invalid` warning + /// plus a silent reset — a value the caller set, lost across a save/load with no error at the + /// point it went wrong. The setter now applies the same rule the file-read path applies to a + /// non-finite number (reset to the descriptor's default), so the invariant `number_value` + /// documents is actually established rather than merely assumed. + /// + /// Both kinds are covered: `Stepped` reaches the identical trap through `value.round()`, + /// which is also NaN-preserving. + #[test] + fn set_replaces_a_nan_with_the_default_rather_than_storing_it() { + let mut values = ParamValues::defaults(); + // trim.gain_db is Continuous, eq.enabled is Stepped. + for key in ["trim.gain_db", "eq.enabled"] { + let default = values.get(key).expect("a registry key"); + values.set(key, 6.0).ok(); + values.set(key, f32::NAN).unwrap(); + let stored = values.get(key).expect("a registry key"); + assert!(stored.is_finite(), "{key} stored {stored}"); + assert_eq!(stored, default, "{key}"); + } + } + + /// The consequence the issue was actually reported against: a NaN reaching `set` used to + /// serialise as JSON `null`, which `from_document_section` then rejected as invalid. Whatever + /// the setter stores, the section it produces must be numbers only and must survive the + /// round trip without a warning. + #[test] + fn a_document_written_after_a_nan_set_reads_back_without_a_warning() { + let mut values = ParamValues::defaults(); + values.set("trim.gain_db", f32::NAN).unwrap(); + + let section = values.to_document_section(); + assert!( + section.values().all(Value::is_number), + "a non-number reached the document: {section:?}" + ); + + let (restored, warnings) = ParamValues::from_document_section(§ion); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(restored, values); + } + + /// ±Infinity needs no separate filter: `f32::clamp` maps it onto the descriptor's own bound, + /// which is finite. Pinned so the NaN rule above is not later "simplified" into one that + /// sends infinities to the default too, which would silently change a saturating set into a + /// reset. + #[test] + fn set_saturates_an_infinite_value_to_the_descriptor_bound() { + let mut values = ParamValues::defaults(); + values.set("trim.gain_db", f32::INFINITY).unwrap(); + assert_eq!(values.get("trim.gain_db"), Some(24.0)); + values.set("trim.gain_db", f32::NEG_INFINITY).unwrap(); + assert_eq!(values.get("trim.gain_db"), Some(-24.0)); + } + #[test] fn number_value_round_trips_f32_precision_through_f64() { // The property number_value's own doc comment claims: converting a genuine f32 through diff --git a/crates/namir-worker/src/library.rs b/crates/namir-worker/src/library.rs index 4a6ee86..8cc1763 100644 --- a/crates/namir-worker/src/library.rs +++ b/crates/namir-worker/src/library.rs @@ -376,11 +376,12 @@ impl LibraryService { /// /// **Isolated per D-16.3**, inherited rather than reimplemented: the scan closure runs /// through [`ThreadPool::spawn`], so a panic inside it is caught at the job boundary exactly - /// as any other job this crate submits. `scanning` is still cleared in that case (the pool's - /// `catch_unwind` runs the closure's remainder up to the panic point only, so this method - /// relies on `ThreadPool`'s isolation rather than its own — a panic mid-scan leaves this - /// service's `scanning` flag stuck `true` and no further scan startable, which is D-16.3's - /// documented containment boundary, not a gap this method papers over). + /// as any other job this crate submits. `scanning` is cleared in that case too, by + /// [`ScanFlag`]'s `Drop` rather than by the `store` at the end of the job body — the pool's + /// `catch_unwind` runs the closure only up to the panic point, so a flag cleared by a + /// statement past that point would stay `true` and refuse every later scan for the life of + /// the process (issue #109). Containment is D-16.3's boundary; losing the library's + /// rescannability to it was not, and this method no longer does. pub fn start_scan( &self, pool: &ThreadPool, @@ -397,7 +398,9 @@ impl LibraryService { let roots = self.roots.clone(); let shared = Arc::clone(&self.shared); - let scanning = Arc::clone(&self.scanning); + // Moved into the job and held for its whole duration, so that an unwind from anywhere + // inside still clears the flag; released explicitly before `on_complete` below. + let scanning = ScanFlag(Arc::clone(&self.scanning)); pool.spawn(move || { // **Inside the job, and the `prior` snapshot is taken after it** (M14). The load is @@ -453,8 +456,9 @@ impl LibraryService { shared.publish(new_index, save_error.is_none()); // Cleared before on_complete, not after: a caller that starts a new scan from inside - // its own on_complete callback must see is_scanning() == false by then. - scanning.store(false, Ordering::Release); + // its own on_complete callback must see is_scanning() == false by then. Dropping the + // guard early is what does it; the guard itself is only the unwinding path's backstop. + drop(scanning); on_complete(ScanOutcome { complete, @@ -469,6 +473,21 @@ impl LibraryService { } } +/// Owns the "a scan is running" bit for the duration of one scan job and clears it on drop. +/// +/// A plain `store(false)` at the end of the job body is not equivalent: the pool contains a +/// panicking job at its own boundary (D-16.3), so a statement past the panic point never runs and +/// the flag would stay `true` — [`LibraryService::start_scan`] would then refuse every later scan +/// for the life of the process, silently, since the only trace of the panic is a log record +/// (issue #109). `Drop` runs during the unwind, so containment no longer costs the flag. +struct ScanFlag(Arc); + +impl Drop for ScanFlag { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + fn lock(m: &Mutex) -> MutexGuard<'_, T> { // P8, mirroring pool.rs's and cache.rs's identical recovery: a panic elsewhere must not // permanently disable this service's index access. @@ -773,6 +792,56 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// **Issue #109.** A panic inside the scan job -- injected here through the caller's own + /// `on_progress`, which `start_scan` calls from the pool thread and which is the one part of + /// the job body a test can make fail without a fault-injection seam -- must not leave + /// `scanning` stuck `true`. D-16.3 contains the panic at the job boundary; containment must + /// not also cost this service every later scan for the life of the process, which is what a + /// clear-at-the-end `store(false)` past the panic point did. + /// + /// The second job is the synchronisation: the pool has one thread and runs its queue in + /// order, so its arrival proves the panicked job has already unwound (and therefore that the + /// flag's guard has already dropped) without polling the flag this test is asserting on. + #[test] + fn a_panicked_scan_releases_the_scanning_flag_and_a_later_scan_still_starts() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + + let root = temp_dir("panicked-scan"); + write_nam(&root, "a.nam"); + let (service, _) = LibraryService::open(root.join("index.json"), vec![root.clone()]); + + let pool = ThreadPool::with_threads(1); + service + .start_scan( + &pool, + |_| panic!("a scan progress callback failing on purpose"), + |_| {}, + ) + .expect("the first scan should start"); + + let (drained_tx, drained_rx) = mpsc::channel(); + pool.spawn(move || drained_tx.send(()).unwrap()); + drained_rx + .recv_timeout(SCAN_BUDGET) + .expect("the pool must keep serving after a scan job panics"); + std::panic::set_hook(previous); + + assert!( + !service.is_scanning(), + "a panicked scan must not leave the scanning flag set" + ); + + let (tx, rx) = mpsc::channel(); + service + .start_scan(&pool, |_| {}, move |outcome| tx.send(outcome).unwrap()) + .expect("a scan must still be startable after an earlier one panicked"); + recv(&rx); + assert!(!service.is_scanning()); + + let _ = std::fs::remove_dir_all(&root); + } + /// D-12.2's cancellable job, exercised against `namir-fixtures`'s 10,000-file shared corpus — /// large enough that cancelling immediately after `start_scan` returns reliably lands before /// a full scan (which reads and hashes every file) could finish, unlike a two-file fixture From 9828eda0612d6d8d73575a1e5769265eaef8da1d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:06:31 +0000 Subject: [PATCH 09/44] Refuse a float WAV with non-finite taps at load (#52, #53) The panic #52 names was already fixed at M14 (ec46de9): the convolver's c2r site falls back to silence. But nothing rejected a non-finite tap at load, and that was live on a path the issue does not name -- the resampled one, which is the common case for a downloaded 44.1 kHz IR at a 48 kHz engine: panicked at rubato-0.16.2/src/synchro.rs:182:14: called `Result::unwrap()` on an `Err` value: Imaginary part of first value was non-zero. That is rubato's own unwrap, before this crate sees a tap, so no guard of ours could reach it. On the rate-matched path the file instead loaded "successfully" and then produced non-finite output forever, from a load that reported success. Rejected rather than sanitised: zeroing a NaN is defensible, clamping an Inf invents a tap, and the two cannot be told apart at the point of repair. A silently repaired IR whose response is wrong is its own defect. Contrast was_truncated, which repairs and reports, because truncation is a policy applied to valid data -- a NaN tap is not data. No RT-path change: realfft's r2c fails only on buffer lengths, never on values. #53: build_schedule's assert was over-strict. With max_partition <= block_size the loop already yields a valid uniform schedule, so max_partition is floored at block_size and a 16384-frame offline render loads instead of panicking. Checked against the direct-convolution reference at -100 dB, not merely for absence of a panic. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-ir/src/convolver.rs | 154 ++++++++++++++++++++++++++++- crates/namir-ir/src/error_codes.rs | 24 +++++ crates/namir-ir/src/wav.rs | 125 ++++++++++++++++++++--- 3 files changed, 287 insertions(+), 16 deletions(-) diff --git a/crates/namir-ir/src/convolver.rs b/crates/namir-ir/src/convolver.rs index eb71e31..212b0e9 100644 --- a/crates/namir-ir/src/convolver.rs +++ b/crates/namir-ir/src/convolver.rs @@ -222,7 +222,8 @@ pub struct StageSpec { /// /// `growth_factor == 1` degenerates to uniform partitioned convolution (every FFT partition is /// `block_size`), useful as a schedule to compare against. `max_partition == block_size` also -/// degenerates to uniform, regardless of `growth_factor`. +/// degenerates to uniform, regardless of `growth_factor` — and so does a `max_partition` *below* +/// `block_size`, which is floored to it (see the note at the assert below, and issue #53). /// /// **Causality**, ported from the spike's derivation: a size-`P` FFT partition at IR offset /// `off` can only be computed once `P` samples of input feeding it have arrived, and its output @@ -250,7 +251,20 @@ pub fn build_schedule( growth_factor: usize, max_partition: usize, ) -> Vec { - assert!(block_size > 0 && growth_factor >= 1 && max_partition >= block_size); + assert!(block_size > 0 && growth_factor >= 1); + // **`max_partition` is floored at `block_size` rather than asserted above it** (issue #53). + // D-9.6's `max_partition` is a ceiling on how large a partition may *grow*, and this schedule's + // partitions start at `block_size`; a `max_partition` below that asks for a ceiling under the + // floor. The loop below already answers that coherently — `size < max_partition` is false from + // the first iteration, so `size` never grows and every partition stays `block_size`, a valid + // uniform schedule — and the assert refused it anyway. What that cost was not hypothetical: + // `PreparedIr::from_wav_bytes` passes the host's block size straight through, so every host + // presenting more than `DEFAULT_MAX_PARTITION` (8192) frames at once — an ordinary offline + // render or bounce at 16384 — panicked on IR load, against a precondition `from_wav_bytes` + // documented nowhere. Flooring makes the degenerate case explicit instead of fatal; a caller + // asking for a genuinely smaller ceiling than the block size is asking for the uniform + // schedule and now gets it. + let max_partition = max_partition.max(block_size); let head = block_size.min(ir_len); let per_level = growth_factor.max(1); @@ -703,6 +717,16 @@ impl PreparedIr { /// FR-NAM-060 — see `resample_mono`'s doc comment), truncates at D-9.7's 10-second-at-engine- /// rate ceiling, and builds the D-9.4 schedule with R-8's staggering baked in, using this /// crate's [`DEFAULT_GROWTH_FACTOR`] / [`DEFAULT_MAX_PARTITION`]. + /// + /// `block_size` is the host's, and there is no upper bound on it: a `block_size` above + /// [`DEFAULT_MAX_PARTITION`] — an offline render or bounce presenting 16384 frames at once — + /// yields a uniform schedule rather than an error, and used to panic (issue #53; see + /// [`build_schedule`]'s note). The only precondition is `block_size > 0`. + /// + /// Every failure is a catalogued [`IrLoadError`], never a panic. That includes the file's + /// *values*, not only its shape: a 32-bit float WAV carrying a NaN or infinite sample is + /// refused here (`ir.load.non_finite_sample`), because there is no later point at which such + /// a tap can be made safe — see `wav.rs`'s check and issue #52. pub fn from_wav_bytes( bytes: &[u8], engine_rate: SampleRate, @@ -1005,6 +1029,68 @@ mod tests { assert!(covered.iter().all(|&c| c), "some tap never covered"); } + /// Issue #53: `build_schedule` asserted `max_partition >= block_size`, so a host block larger + /// than `DEFAULT_MAX_PARTITION` — 16384 frames, an ordinary offline render — aborted the + /// process on IR load. The degenerate schedule it refused is a perfectly good one: `size` + /// never grows past `block_size`, so every partition is `block_size` and the result is + /// uniform partitioned convolution. Asserted here as the three properties any schedule must + /// have (uniform size, causal, and covering every tap past the head exactly once), not as a + /// literal partition list. + #[test] + fn schedule_is_uniform_and_causal_when_block_size_exceeds_max_partition() { + let ir_len = 100_003; // deliberately not a multiple of the block size + let block_size = 16_384; + let stages = build_schedule( + ir_len, + block_size, + DEFAULT_GROWTH_FACTOR, + DEFAULT_MAX_PARTITION, + ); + assert!(!stages.is_empty()); + + let mut covered = vec![false; ir_len]; + for c in covered.iter_mut().take(block_size.min(ir_len)) { + *c = true; + } + for s in &stages { + assert_eq!( + s.size, block_size, + "every partition should be the block size" + ); + assert!(s.offset >= s.size, "offset {} < size {}", s.offset, s.size); + assert_eq!( + s.stagger, 0, + "a one-phase size level has nothing to stagger" + ); + for (i, c) in covered + .iter_mut() + .enumerate() + .skip(s.offset) + .take(s.actual_len) + { + assert!(!*c, "tap {i} covered twice"); + *c = true; + } + } + assert!(covered.iter().all(|&c| c), "some tap never covered"); + } + + /// The same flooring seen from the other side: an explicitly *smaller* `max_partition` than + /// the block size is a request for the uniform schedule, and gives the same answer as asking + /// for it the two documented ways (`max_partition == block_size`, or `growth_factor == 1`). + #[test] + fn a_max_partition_below_the_block_size_gives_the_uniform_schedule() { + let floored = build_schedule(10_000, 512, 2, 64); + let equal = build_schedule(10_000, 512, 2, 512); + assert_eq!(floored.len(), equal.len()); + for (a, b) in floored.iter().zip(equal.iter()) { + assert_eq!( + (a.offset, a.size, a.actual_len, a.stagger), + (b.offset, b.size, b.actual_len, b.stagger) + ); + } + } + #[test] fn stagger_is_zero_for_a_size_level_with_only_one_partition() { // growth_factor = 1: uniform, every level has exactly one partition. @@ -1431,6 +1517,70 @@ mod tests { ); } + /// Issue #52, the end-to-end half (`wav.rs`'s tests own the decode half). A float WAV with a + /// NaN or infinite tap is an ordinary artefact of a bad export, and before the load-time + /// rejection it reached the convolver two different ways with two different bad endings: + /// + /// - **rate-matched**: the file loaded, `PreparedIr` built an all-NaN `h` spectrum for the + /// poisoned partition and an all-NaN head, and every output sample was non-finite from the + /// first block on — for the life of the load, since the poison is in the *IR*, not in the + /// signal. `namir-engine`'s FR-CHAIN-080 scan would then silence the chain and raise a + /// fault on every block, permanently, from a file that reported a successful load. + /// - **resampled** (any file whose rate differs from the engine's, FR-IR-030): worse and + /// sooner — `rubato`'s own inverse transform rejects the NaN spectrum and `unwrap`s it + /// inside the dependency, so the load itself panicked, on the worker thread, before this + /// crate saw a single tap. + /// + /// Both are closed by the same check, and it is at load time on purpose: the audio thread + /// cannot fix a poisoned IR, only pay per sample to rediscover it (D-16.3, NFR-RT-010). + #[test] + fn a_float_wav_with_a_non_finite_tap_is_refused_before_it_reaches_the_convolver() { + let engine_rate = SampleRate::new(48_000).unwrap(); + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut h = decaying_noise(1_024, 9, 128.0); + h[10] = bad; + // Both file rates: 48 kHz takes the rate-matched branch, 44.1 kHz the resampling one. + for file_rate in [48_000u32, 44_100] { + let bytes = write_mono_wav(file_rate, &h); + let Err(err) = PreparedIr::from_wav_bytes(&bytes, engine_rate, 64) else { + panic!("a non-finite tap must be refused at load, at either rate"); + }; + assert_eq!(err.code.id, "ir.load.non_finite_sample"); + } + } + } + + /// Issue #53, end to end: `PreparedIr::from_wav_bytes` passes the host's block size straight + /// into `build_schedule`, so a host presenting more frames than `DEFAULT_MAX_PARTITION` + /// panicked on IR load. It must load — and convolve correctly, against D-9.5's permanent + /// direct-convolution reference, since a uniform schedule is a different code path through + /// the same machinery and "does not panic" is the weaker half of what is wanted here. + #[test] + fn loads_and_convolves_correctly_at_a_block_size_above_max_partition() { + let block_size = 16_384; + let h = decaying_noise(40_000, 7, 4_096.0); + let bytes = write_mono_wav(48_000, &h); + let engine_rate = SampleRate::new(48_000).unwrap(); + let prepared = PreparedIr::from_wav_bytes(&bytes, engine_rate, block_size) + .expect("a block size above DEFAULT_MAX_PARTITION must load, not panic"); + + let mut state = prepared.new_state(); + let x = white_noise(3 * block_size, 21); + let mut y = vec![0f32; x.len()]; + for chunk_start in (0..x.len()).step_by(block_size) { + let end = (chunk_start + block_size).min(x.len()); + let mut out_slice = &mut y[chunk_start..end]; + prepared.process_block( + &mut state, + &x[chunk_start..end], + std::slice::from_mut(&mut out_slice), + ); + } + let direct = direct_convolve(&h, &x); + let err = rms_error_db(&direct, &y).unwrap(); + assert!(err < -100.0, "error too high: {err} dB"); + } + #[test] fn stereo_wav_loads_as_two_independent_channels() { let left = delayed_delta(400, 10); diff --git a/crates/namir-ir/src/error_codes.rs b/crates/namir-ir/src/error_codes.rs index a77eab9..dcd5bb3 100644 --- a/crates/namir-ir/src/error_codes.rs +++ b/crates/namir-ir/src/error_codes.rs @@ -51,6 +51,29 @@ pub const EMPTY_IR: ErrorCode = ErrorCode::new( the library.", ); +/// A 32-bit float WAV carries a sample that is not a finite number — a NaN or an infinity. +/// +/// Unlike every other entry here this one is about a *value*, not a shape: the file parses, its +/// header is in FR-IR-010's matrix, and only the sample data is unusable. It exists because a +/// non-finite tap has no safe downstream behaviour. It poisons the whole FFT partition it lands in +/// (one NaN makes every bin of that partition's `h` spectrum NaN), so the convolver's output is +/// non-finite for the life of the load, not for one block; and on the resampled path (any file +/// whose rate differs from the engine's, FR-IR-030) `rubato`'s own inverse transform rejects the +/// resulting spectrum and **panics inside the dependency**, on the worker thread, before this +/// crate ever sees the taps. There is no point downstream of `wav::decode` at which either +/// outcome can be turned back into a working IR, so the file is refused here. +/// +/// Only the float branch can produce this: an integer sample is `i32 as f32 / 2f32.powi(bits-1)`, +/// finite for every `i32` at every supported depth. +pub const NON_FINITE_SAMPLE: ErrorCode = ErrorCode::new( + "ir.load.non_finite_sample", + Severity::Error, + "This impulse response file contains a sample that is not a finite number ({detail}).", + "The file's audio data is damaged -- a NaN or infinite sample usually means a failed export. \ + Export the impulse response again from your audio editor, or choose a different one in the \ + library.", +); + /// Carries a `namir_core::ErrorCode` (D-16.1) plus a `detail` string naming the specific reason. /// This crate only ever sees bytes, not a file path, so `detail` carries whatever numbers/names /// are relevant to the failure; a caller that knows the file path prepends it when presenting @@ -79,6 +102,7 @@ const ALL: &[ErrorCode] = &[ UNSUPPORTED_FORMAT, INVALID_SAMPLE_RATE, EMPTY_IR, + NON_FINITE_SAMPLE, ]; #[cfg(test)] diff --git a/crates/namir-ir/src/wav.rs b/crates/namir-ir/src/wav.rs index 8e98e2c..db37ec8 100644 --- a/crates/namir-ir/src/wav.rs +++ b/crates/namir-ir/src/wav.rs @@ -4,10 +4,12 @@ //! [`IrLoadError`](crate::error_codes::IrLoadError), never a panic. //! //! Supports exactly FR-IR-010's matrix: mono or stereo, 16-bit int / 24-bit int / 32-bit int / -//! 32-bit float, `8_000..=192_000` Hz. Every sample is converted to `f32` in (approximately) -//! `[-1.0, 1.0]` — see [`decode`]'s doc comment for the exact conversion and the empirical tests -//! that prove it, per this crate's build instructions: hound's exact integer sample range per bit -//! depth is not assumed from memory, it is read from hound 3.5.1's own source +//! 32-bit float, `8_000..=192_000` Hz — and, in the float case only, requires every sample to be +//! a finite number (`error_codes::NON_FINITE_SAMPLE`; see that entry for why a NaN or infinite tap +//! is refused here rather than handled downstream). Every sample is converted to `f32` in +//! (approximately) `[-1.0, 1.0]` — see [`decode`]'s doc comment for the exact conversion and the +//! empirical tests that prove it, per this crate's build instructions: hound's exact integer +//! sample range per bit depth is not assumed from memory, it is read from hound 3.5.1's own source //! (`hound::Sample::read` impls for `i32`/`f32` in its `lib.rs`) and then proven by round-tripping //! known values through `hound::WavWriter` in this module's tests. //! @@ -55,10 +57,11 @@ pub(crate) struct DecodedWav { } /// The header validation `decode` and `probe` both need, factored out so the two never drift: -/// a header that `probe_wav` accepts must be one `decode` would go on to accept too (modulo -/// `EMPTY_IR`, which needs the declared-frame check `probe_wav` also performs — see both -/// callers). Returns the parsed `hound::WavReader` so `decode` can go on to read samples from it -/// without re-parsing the header a second time. +/// a header that `probe_wav` accepts must be one `decode` would go on to accept too (modulo the +/// two judgments that are not about the header at all: `EMPTY_IR`, which needs the declared-frame +/// check `probe_wav` also performs, and `NON_FINITE_SAMPLE`, which needs the sample data only +/// `decode` reads — see both callers). Returns the parsed `hound::WavReader` so `decode` can go on +/// to read samples from it without re-parsing the header a second time. fn open_and_validate_header(bytes: &[u8]) -> Result>, IrLoadError> { let reader = hound::WavReader::new(Cursor::new(bytes)).map_err(|e| IrLoadError { code: error_codes::MALFORMED_WAV, @@ -159,6 +162,22 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { code: error_codes::MALFORMED_WAV, detail: e.to_string(), })?; + // The one *value* check in this module, and the reason it is here rather than + // anywhere downstream: see `error_codes::NON_FINITE_SAMPLE`. A non-finite tap + // either panics `rubato` on the resampling path or poisons an FFT partition's + // whole `h` spectrum for the life of the load, and neither is recoverable once the + // taps exist. Load time is where refusing costs one `is_finite` per sample on a + // worker thread; the audio thread is where it would cost one per sample per block, + // forever, to salvage nothing. Integer files skip the check because they cannot + // fail it (`i32 as f32 / 2^(bits-1)` is finite for every `i32`). + if !s.is_finite() { + return Err(IrLoadError { + code: error_codes::NON_FINITE_SAMPLE, + detail: format!( + "sample {i} of {total_samples} is {s}, not a finite number" + ), + }); + } channel_data[i % channels].push(s); } } @@ -218,12 +237,19 @@ pub struct WavInfo { /// /// Applies the same FR-IR-010 format/channel/rate validation `decode` does, and a file it rejects /// fails with the identical catalogued [`IrLoadError`] `decode` would give the same bytes — with -/// one deliberate exception: a zero-frame file probes successfully (it is a legitimate, if -/// useless, library entry to display) but `decode` still refuses to load it -/// (`error_codes::EMPTY_IR`), because "usable as a convolution kernel" is a stronger, load-time -/// judgment this shallower header check does not make. So "probes successfully" means "a library -/// entry worth indexing", not "guaranteed loadable" — a caller that wants the stronger guarantee -/// still has to call `decode`/`PreparedIr::from_wav_bytes`. +/// two deliberate exceptions, both of them "usable as a convolution kernel" judgments this +/// shallower header check does not make: +/// +/// - a zero-frame file probes successfully (it is a legitimate, if useless, library entry to +/// display) but `decode` refuses to load it (`error_codes::EMPTY_IR`); +/// - a float file carrying a NaN or infinite sample probes successfully — the header says nothing +/// about it and `probe_wav` reads no sample data — but `decode` refuses to load it +/// (`error_codes::NON_FINITE_SAMPLE`). +/// +/// So "probes successfully" means "a library entry worth indexing", not "guaranteed loadable" — a +/// caller that wants the stronger guarantee still has to call +/// `decode`/`PreparedIr::from_wav_bytes`. `probe_wav`'s own accepted set is unchanged by the +/// second exception: it never read samples and still does not. pub fn probe_wav(bytes: &[u8]) -> Result { let reader = open_and_validate_header(bytes)?; let spec = reader.spec(); @@ -486,6 +512,77 @@ mod tests { assert_eq!(err.code.id, error_codes::EMPTY_IR.id); } + /// Issue #52: a 32-bit float WAV carrying a NaN tap. Before this check `decode` pushed the + /// sample raw, and the file loaded "successfully"; what happened next depended only on whether + /// the file's rate matched the engine's — `rubato` panicked inside the dependency on the + /// resampling path, and the convolver produced non-finite output forever on the matched-rate + /// path. See `error_codes::NON_FINITE_SAMPLE`, and `convolver.rs`'s + /// `a_float_wav_with_a_non_finite_tap_is_refused_before_it_reaches_the_convolver` for the + /// end-to-end half. + #[test] + fn rejects_a_float_wav_containing_a_nan_sample() { + let bytes = write_float_wav(48_000, 1, &[0.5, f32::NAN, 0.25]); + let err = decode(&bytes).unwrap_err(); + assert_eq!(err.code.id, error_codes::NON_FINITE_SAMPLE.id); + assert!( + err.detail.contains("sample 1"), + "detail should name the offending sample index: {}", + err.detail + ); + } + + #[test] + fn rejects_a_float_wav_containing_an_infinite_sample() { + for value in [f32::INFINITY, f32::NEG_INFINITY] { + let bytes = write_float_wav(48_000, 1, &[0.5, 0.25, value]); + let err = decode(&bytes).unwrap_err(); + assert_eq!(err.code.id, error_codes::NON_FINITE_SAMPLE.id); + } + } + + /// The rejection is per *sample*, not per channel: a stereo file whose only bad sample is in + /// the right channel is refused just as a mono one is (the right channel's taps are convolved + /// independently, FR-CHAIN-060, so a poisoned one is exactly as unusable). + #[test] + fn rejects_a_float_wav_whose_only_non_finite_sample_is_in_the_second_channel() { + // Interleaved L,R: the NaN is the right channel's second frame. + let bytes = write_float_wav(44_100, 2, &[0.5, 0.5, 0.25, f32::NAN]); + let err = decode(&bytes).unwrap_err(); + assert_eq!(err.code.id, error_codes::NON_FINITE_SAMPLE.id); + } + + /// The check is confined to the float branch because the integer branch cannot fail it: + /// `i32 as f32 / 2f32.powi(bits - 1)` is finite for every `i32` at every supported depth, + /// extremes included. Asserted rather than reasoned about, since it is what licenses the + /// integer path to skip the test. + #[test] + fn integer_files_cannot_produce_a_non_finite_sample() { + for bits in [16u16, 24, 32] { + let full = 1i64 << (bits - 1); + let values = [(full - 1) as i32, (-full) as i32, 0]; + let bytes = write_int_wav(48_000, 1, bits, &values); + let decoded = decode(&bytes).unwrap(); + assert!( + decoded.channel_data[0].iter().all(|s| s.is_finite()), + "{bits}-bit integer decode produced a non-finite sample" + ); + } + } + + /// `probe_wav` is unchanged by the finiteness check — it reads no sample data, so it still + /// accepts a file `decode` now refuses. Same shape as the `EMPTY_IR` divergence above and + /// documented alongside it: "probes successfully" means "worth indexing", not "loadable". + #[test] + fn probe_wav_accepts_a_non_finite_file_decode_would_reject() { + let bytes = write_float_wav(48_000, 1, &[0.5, f32::NAN]); + let info = probe_wav(&bytes).unwrap(); + assert_eq!(info.sample_format, SampleFormat::Float); + assert_eq!( + decode(&bytes).unwrap_err().code.id, + error_codes::NON_FINITE_SAMPLE.id + ); + } + // trace: FR-IR-010 #[test] fn accepts_boundary_sample_rates() { From a7b97aaefa4cc70d9b2b0ad5ac3c99062af9527b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:17:27 +0000 Subject: [PATCH 10/44] Reject three hostile .nam shapes at load, not on the audio thread (#46, #47, #49) All three loaded cleanly and then misbehaved inside process_block, which runs on the audio thread, so each is refused in from_file instead: wavenet.rs:1711: range end index 32 out of range for slice of length 8 (#46) wavenet.rs:558: range end index 16 out of range for slice of length 8 (#47) #46 is a real reference-supported multi-output model, not a damaged file -- NeuralAmpModelerCore derives NumOutputChannels() from the last layer array's head_size -- so it is refused as unsupported, the same mono-only scope limit in_channels != 1 already carries. The check reads the resolved head width, so A1's head_size and A2's head.out_channels are both covered, and a wide non-final head still loads, which every real two-array export has. #47 takes a different code from the one the issue proposed: inconsistent, not unsupported. The reference takes the layer's input width from this field while sizing the signal from in_channels, and never checks they agree; since in_channels is already pinned to 1, a first-array input_size of 2 is a file contradicting its own declared width. A genuine two-input model declares in_channels: 2 and is still rejected earlier, by name. #49: non-finite weights are rejected via a shared helper. Only deserialized JSON text can carry one -- serde_json writes non-finite as null, and rustc refuses the literal -- which is why no generated fixture ever had this shape. Extended past the issue to A2 activation parameters, which are weights by another name and reach f32 the same way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-nam/src/error_codes.rs | 36 ++- crates/namir-nam/src/lstm.rs | 7 +- crates/namir-nam/src/model.rs | 124 +++++++++ crates/namir-nam/src/shared.rs | 46 +++- crates/namir-nam/src/wavenet.rs | 390 +++++++++++++++++++++++++++- 5 files changed, 593 insertions(+), 10 deletions(-) diff --git a/crates/namir-nam/src/error_codes.rs b/crates/namir-nam/src/error_codes.rs index 74eba73..3b8b420 100644 --- a/crates/namir-nam/src/error_codes.rs +++ b/crates/namir-nam/src/error_codes.rs @@ -139,7 +139,18 @@ pub const UNSUPPORTED_LSTM_CHANNELS: ErrorCode = ErrorCode::new( /// FiLM conditioning at any of the eight `*_film` sites, an active `head1x1`, an inactive /// `layer1x1`, gating (`gating_mode` other than `"none"` or the legacy `gated: true`), a `groups_*` /// value other than 1, or a `slimmable` container. `detail` names the offending key — that naming -/// is FR-NAM-140's own requirement text, not a courtesy. **Distinct from `MALFORMED_JSON` by +/// is FR-NAM-140's own requirement text, not a courtesy. +/// +/// Also the model's *output* width, added with issue #46: the last layer array's head width (A1's +/// `head_size`, A2's `head.out_channels`) must be 1. That is the same scope limit +/// `config.in_channels != 1` above already carries at the input end — Namir is a mono-in, +/// mono-out amp simulator — and it belongs to this code for the same reason `in_channels` does: +/// the reference implementation derives its output channel count from exactly that field +/// (`wave_net_output_channels`), so a wider value is a real, supported multi-output model this +/// build does not implement, not a damaged file. Left unchecked it was one of the two files that +/// loaded cleanly and then panicked *on the audio thread* inside +/// `wavenet::PreparedWaveNet::process_block`; the other, `layers[0].input_size != 1`, is +/// `INCONSISTENT_CONFIGURATION` below rather than this code, and that entry says why. **Distinct from `MALFORMED_JSON` by /// construction**: reaching this code means `serde` already accepted the document as a `NamFile`, /// so "not valid JSON" was never a true statement about it. This is FR-NAM-140's *configuration* /// clause; `UNSUPPORTED_ARCHITECTURE` above remains its *architecture* clause. @@ -151,6 +162,21 @@ pub const UNSUPPORTED_CONFIGURATION: ErrorCode = ErrorCode::new( from the trainer with default settings avoids all of them.", ); +/// A weight, the `head_scale`, or an activation parameter is not a finite number (infinite or +/// NaN). `serde_json` accepts `1e40` — in `f64` range, out of `f32` range — and hands back +/// `f32::INFINITY` with no error at all, so nothing before this check distinguishes such a file +/// from a good one. Rejecting it at load is not cosmetic: a non-finite weight propagates through +/// inference to a non-finite output on the **audio thread**, where FR-CHAIN-080/090's non-finite +/// guard then mutes the block — permanent silence plus a fault counter, and no message naming the +/// cause, which is exactly what FR-NAM-040 requires the load to have produced instead. +pub const NON_FINITE_VALUE: ErrorCode = ErrorCode::new( + "nam.load.non_finite_value", + Severity::Error, + "This model contains a weight or parameter that is not a finite number.", + "The file is damaged, or was exported by a trainer run that diverged. Re-export or download \ + the model again; a model with an infinite or NaN weight can only ever produce silence.", +); + /// The file is well-formed and every feature it uses is one this build supports, but its declared /// configuration contradicts itself: both or neither of `kernel_size`/`kernel_sizes` present; a /// `kernel_sizes` or per-layer `activation` array whose length disagrees with `dilations`; both or @@ -159,6 +185,13 @@ pub const UNSUPPORTED_CONFIGURATION: ErrorCode = ErrorCode::new( /// file that is simply self-contradictory, not one that names a real, unimplemented feature. Not /// required by FR-NAM-140's own text — added for message truthfulness, recorded here rather than /// left implicit so a reviewer doesn't have to rediscover the reasoning. +/// +/// Issue #47 adds one more member: `layers[0].input_size != 1`. The first layer array's +/// `input_size` is the width feeding its rechannel, and the signal fed to it is the model's own +/// input, whose width is `config.in_channels` — already pinned to 1 by +/// `UNSUPPORTED_CONFIGURATION` above. So this is a file disagreeing with itself about how wide +/// its own input is, not a multi-input model Namir is declining to play: a real one declares +/// `in_channels` as well, and is rejected above, by name, as the unsupported feature it is. pub const INCONSISTENT_CONFIGURATION: ErrorCode = ErrorCode::new( "nam.load.inconsistent_configuration", Severity::Error, @@ -206,6 +239,7 @@ const ALL: &[ErrorCode] = &[ UNSUPPORTED_LSTM_CHANNELS, UNSUPPORTED_CONFIGURATION, INCONSISTENT_CONFIGURATION, + NON_FINITE_VALUE, ]; #[cfg(test)] diff --git a/crates/namir-nam/src/lstm.rs b/crates/namir-nam/src/lstm.rs index acab46d..cb26272 100644 --- a/crates/namir-nam/src/lstm.rs +++ b/crates/namir-nam/src/lstm.rs @@ -69,7 +69,7 @@ use namir_core::SampleRate; use crate::error_codes::{self, NamLoadError}; use crate::file::{LstmConfigJson, LstmFile, NamMetadata}; -use crate::shared::{WeightReader, check_max, check_min1}; +use crate::shared::{WeightReader, check_finite, check_max, check_min1}; /// FRS §2's definitions: model sample rate is "typically 48 kHz" — the fallback when a `.nam` /// file omits `sample_rate` entirely, same default `wavenet.rs` uses. @@ -335,6 +335,11 @@ impl PreparedLstm { check_min1(in_channels, "config.in_channels")?; check_min1(out_channels, "config.out_channels")?; check_max(nam.weights.len(), MAX_LSTM_TOTAL_WEIGHTS, "weights.len()")?; + // Issue #49: after the ceiling above (so this walk is bounded), before any of these + // floats can become part of a `PreparedLstm` the audio thread will run. LSTM has no + // `head_scale` counterpart to WaveNet's — every float this architecture reads is in + // `weights` — so this one call is the whole check here. See `shared::check_finite`. + check_finite(&nam.weights, "weights")?; if input_size != 1 || in_channels != 1 || out_channels != 1 { return Err(NamLoadError { diff --git a/crates/namir-nam/src/model.rs b/crates/namir-nam/src/model.rs index 275c758..daea4ee 100644 --- a/crates/namir-nam/src/model.rs +++ b/crates/namir-nam/src/model.rs @@ -361,6 +361,16 @@ mod tests { /// question): the error id differs from `MALFORMED_JSON`'s **and** `detail` names the offending /// key — asserting only the first would leave "names the unsupported feature" untested and this /// tag would be a `trace-partial`, not a plain one. + /// + /// Issue #46 added one more member to the set this quantifies over — a model declaring more + /// than one output channel, i.e. a last layer array whose head is wider than 1 — and it is + /// *not* in the table below, because unlike every case here it changes a dimension and so + /// needs its own weight count. It is covered, with both the same assertions, by + /// [`documents_that_used_to_load_and_then_misbehave_on_the_audio_thread_are_rejected`], which + /// carries the same tag: the two tests jointly span the set, neither alone does (the same + /// split, for the same reason, that FR-NAM-030's pair of golden-reference tests uses). Issue + /// #47's `layers[0].input_size` is not a member at all — a self-contradictory file is + /// `INCONSISTENT_CONFIGURATION`, which this requirement's text does not cover. // trace: FR-NAM-140 #[test] fn unsupported_features_are_named_and_distinct_from_malformed() { @@ -504,6 +514,120 @@ mod tests { assert!(err.detail.contains("RNN")); } + /// Issues #46, #47 and #49: three `.nam` documents a user can really hold, each of which + /// **loaded successfully** and then misbehaved on the audio thread, reached here through the + /// same bytes-to-model path a real file takes (`load`), not through a hand-built `NamFile`. + /// + /// What each did before the load-time checks that now reject them: + /// + /// | document | observed | + /// |---|---| + /// | `head_size: 4` on the only (therefore last) layer array | `process_block` panicked: `range end index 32 out of range for slice of length 8` | + /// | `input_size: 2` on the first layer array | `process_block` panicked: `range end index 16 out of range for slice of length 8` | + /// | a `1e40` weight (in `f64` range, out of `f32` range, so `f32::INFINITY` after serde) | loaded, and `process` returned a non-finite block | + /// + /// A panic inside `process_block` is a panic on the host's audio thread — in `namir-clap`, + /// the user's whole DAW session. None of the three is defensible there (the RT path may not + /// allocate, and has no way to report anything), so all three are rejected here, at load, off + /// the audio thread, where an error costs nothing. The weight counts below are exact for each + /// mutated shape, so each document is genuinely *loadable-looking* — a weight-count mismatch + /// would prove nothing about these checks. + /// + /// Tagged FR-NAM-140 for its first case only — a model with more than one output channel is + /// an unsupported feature; see [`unsupported_features_are_named_and_distinct_from_malformed`]'s + /// own doc comment for how the two tests split that requirement's set between them. The other + /// two cases are a self-contradictory file and a corrupted one, neither of which FR-NAM-140 + /// covers. + // trace: FR-NAM-140 + #[test] + fn documents_that_used_to_load_and_then_misbehave_on_the_audio_thread_are_rejected() { + // `1e40` is written as JSON text (an `f64`, in range there), not as an `f32`: it cannot + // be a Rust `f32` literal at all (rustc's `overflowing_literals` lint refuses `1e40f32` + // outright), and it cannot even be *re-serialized* from an `f32::INFINITY` value, since + // `serde_json` writes every non-finite float as `null`. Only the text form reproduces + // what a real file carries -- which is exactly why this slipped through: the value is + // perfectly ordinary JSON, and becomes infinite only on the way into an `f32`. + assert!( + serde_json::from_str::("1e40").unwrap().is_infinite(), + "serde_json deserializes the JSON number 1e40 into f32::INFINITY" + ); + let mut infinite_weights = vec![serde_json::json!(0.0); 8]; + infinite_weights[6] = serde_json::json!(1e40); + infinite_weights[7] = serde_json::json!(0.5); + + // (case, layer-array overrides, weights, expected code, substring `detail` must name) + let cases: Vec<(&str, serde_json::Value, serde_json::Value, &str, &str)> = vec![ + ( + "issue #46: last layer array's head_size > 1", + serde_json::json!({ "head_size": 4 }), + // exact for this shape: 6 + head_size(4) * channels(1) + serde_json::json!(vec![0.0f32; 10]), + error_codes::UNSUPPORTED_CONFIGURATION.id, + "head", + ), + ( + "issue #47: first layer array's input_size > 1", + serde_json::json!({ "input_size": 2 }), + // exact for this shape: rechannel is channels(1) * input_size(2) + serde_json::json!(vec![0.0f32; 8]), + // Inconsistent, not unsupported: this document declares a one-channel input (by + // omitting `in_channels`) and a first layer array expecting two. See + // `wavenet::PreparedWaveNet::from_file`'s comment at the check. + error_codes::INCONSISTENT_CONFIGURATION.id, + "input_size", + ), + ( + "issue #49: a non-finite weight", + serde_json::json!({}), + serde_json::Value::Array(infinite_weights), + error_codes::NON_FINITE_VALUE.id, + "weights[6]", + ), + ]; + + for (case, layer_overrides, weights, expected_code, expect_substring) in cases { + let layer = merge_object(minimal_layer_array_json(), layer_overrides); + let bytes = serde_json::json!({ + "architecture": "WaveNet", + "config": { "layers": [layer], "head_scale": 0.5, "head": null }, + "weights": weights, + "sample_rate": 48000 + }) + .to_string() + .into_bytes(); + + let err = expect_err(load(&bytes)); + assert_eq!(err.code.id, expected_code, "{case}: wrong catalogue code"); + assert!( + err.detail.contains(expect_substring), + "{case}: detail {:?} does not name {expect_substring:?}", + err.detail + ); + } + + // The positive control: the same document, unmutated, still loads and still processes — + // so the three rejections above are about the mutations, not about this shape. + let good = load(&minimal_wavenet_json()).expect("the unmutated document still loads"); + let mut state = good.new_state(4); + assert_eq!(good.process(&mut state, &[0.1, 0.2, 0.3, 0.4]).len(), 4); + } + + /// Issue #49's LSTM half: `lstm::PreparedLstm::from_file` has the same check, reached through + /// the same `load` path. LSTM has no `head_scale`, so `weights` is the whole of it. + #[test] + fn a_non_finite_lstm_weight_is_rejected_at_load() { + let mut value: serde_json::Value = serde_json::from_slice(&minimal_lstm_json()).unwrap(); + value["weights"][3] = serde_json::json!(1e40); + let bytes = serde_json::to_vec(&value).unwrap(); + let err = expect_err(load(&bytes)); + assert_eq!(err.code.id, error_codes::NON_FINITE_VALUE.id); + assert!( + err.detail.contains("weights[3]"), + "detail: {:?}", + err.detail + ); + } + #[test] fn wavenet_and_lstm_models_both_process_through_the_same_api() { let wavenet = load(&minimal_wavenet_json()).unwrap(); diff --git a/crates/namir-nam/src/shared.rs b/crates/namir-nam/src/shared.rs index c0448e9..d33ad87 100644 --- a/crates/namir-nam/src/shared.rs +++ b/crates/namir-nam/src/shared.rs @@ -1,10 +1,12 @@ //! Small helpers shared by every architecture module (`wavenet.rs`, `lstm.rs`): a flat-array -//! weight reader and the two NFR-SEC-020 ceiling-check primitives. Factored out here rather than -//! left as `wavenet.rs`-private (which is how they started — WaveNet was implemented first) -//! because `lstm.rs` needs exactly the same two things: "read `n` floats off the front of the -//! model's flat weight array, or fail cleanly" and "reject a declared dimension before it's used -//! in any arithmetic." Neither helper knows anything about either architecture's layout, so -//! sharing them is not an abstraction stretch — it would have been duplication otherwise. +//! weight reader, the two NFR-SEC-020 ceiling-check primitives, and (added with issue #49) the +//! two finiteness checks. Factored out here rather than left as `wavenet.rs`-private (which is +//! how they started — WaveNet was implemented first) because `lstm.rs` needs exactly the same +//! things: "read `n` floats off the front of the model's flat weight array, or fail cleanly", +//! "reject a declared dimension before it's used in any arithmetic", and "reject a weight that +//! isn't a finite number before it can reach the audio thread." No helper here knows anything +//! about either architecture's layout, so sharing them is not an abstraction stretch — it would +//! have been duplication otherwise. use crate::error_codes::{self, NamLoadError}; @@ -58,6 +60,38 @@ pub(crate) fn check_max(value: usize, max: usize, name: &str) -> Result<(), NamL Ok(()) } +/// Rejects any non-finite float (infinity or NaN) in `values`, naming the first offender's index. +/// `name` identifies the array to the user (`"weights"`, an activation parameter, ...). +/// +/// Why this is a *load-time* check and not an audio-thread one: `serde_json` deserializes `1e40` +/// — in `f64` range, out of `f32` range — to `f32::INFINITY` without error, so a real file can +/// carry one. A single non-finite weight propagates through inference to a non-finite output +/// block, which FR-CHAIN-080/090's guard downstream then mutes; the user gets permanent silence +/// and a fault counter instead of FR-NAM-040's message naming the reason. The audio thread cannot +/// return an error, and this one is free to detect here — the weights are already in hand, and +/// their length is already bounded by the caller's own `MAX_*_TOTAL_WEIGHTS` ceiling. +pub(crate) fn check_finite(values: &[f32], name: &str) -> Result<(), NamLoadError> { + if let Some((i, v)) = values.iter().enumerate().find(|(_, v)| !v.is_finite()) { + return Err(NamLoadError { + code: error_codes::NON_FINITE_VALUE, + detail: format!("{name}[{i}] is {v}, which is not a finite number"), + }); + } + Ok(()) +} + +/// The scalar counterpart of [`check_finite`], for a single named float (`config.head_scale`, an +/// activation's `negative_slope`, ...). +pub(crate) fn check_finite_scalar(value: f32, name: &str) -> Result<(), NamLoadError> { + if !value.is_finite() { + return Err(NamLoadError { + code: error_codes::NON_FINITE_VALUE, + detail: format!("{name} is {value}, which is not a finite number"), + }); + } + Ok(()) +} + /// Rejects `value == 0`: every dimension this crate reads is used as an array length or a matrix /// dimension at least once, and zero is never a sensible size for those. pub(crate) fn check_min1(value: usize, name: &str) -> Result<(), NamLoadError> { diff --git a/crates/namir-nam/src/wavenet.rs b/crates/namir-nam/src/wavenet.rs index c0c31f7..2517586 100644 --- a/crates/namir-nam/src/wavenet.rs +++ b/crates/namir-nam/src/wavenet.rs @@ -58,7 +58,7 @@ use wide::f32x8; use crate::error_codes::{self, NamLoadError}; use crate::file::{self, LayerArrayConfig, NamFile, NamMetadata}; -use crate::shared::{WeightReader, check_max, check_min1}; +use crate::shared::{WeightReader, check_finite, check_finite_scalar, check_max, check_min1}; /// A flat, row-major multi-channel signal buffer: `data[channel * n + t]`. Ported verbatim from /// the spike: one allocation per tensor rather than one per channel keeps `WaveNetState`'s scratch @@ -355,6 +355,50 @@ impl TryFrom<&str> for Activation { } } +/// Issue #49's activation-parameter half: an activation's own float parameters are weights by +/// another name — they multiply the signal on the audio thread exactly as a `weights[]` entry +/// does, and reach `Activation` through the same `serde_json` float parsing that turns an +/// out-of-`f32`-range `1e40` into `f32::INFINITY` without error. Checked on the *resolved* +/// activation rather than on `ActivationParams`' raw `Option`s, so every variant's defaults and +/// the bare-name form are covered by construction and no future variant can be added without +/// passing through here. +fn check_activation_parameters_finite( + activation: &Activation, + array_index: usize, + layer_index: usize, +) -> Result<(), NamLoadError> { + let at = format!("layer array {array_index} layer {layer_index}: activation"); + match activation { + Activation::Tanh + | Activation::ReLU + | Activation::Sigmoid + | Activation::Identity + | Activation::SiLU + | Activation::Hardswish + | Activation::Softsign => Ok(()), + Activation::LeakyReLU { negative_slope } => { + check_finite_scalar(*negative_slope, &format!("{at} negative_slope")) + } + Activation::LeakyHardtanh { + min_val, + max_val, + min_slope, + max_slope, + } => { + check_finite_scalar(*min_val, &format!("{at} min_val"))?; + check_finite_scalar(*max_val, &format!("{at} max_val"))?; + check_finite_scalar(*min_slope, &format!("{at} min_slope"))?; + check_finite_scalar(*max_slope, &format!("{at} max_slope")) + } + Activation::PReLU(PReluSlopes::Scalar(slope)) => { + check_finite_scalar(*slope, &format!("{at} negative_slope")) + } + Activation::PReLU(PReluSlopes::PerChannel(slopes)) => { + check_finite(slopes, &format!("{at} negative_slopes")) + } + } +} + /// Resolves one `.nam` layer's `activation` entry (bare name, or an object naming `type` plus /// parameters — [`file::ActivationEntry`]) to this file's `Activation`. `bottleneck` is the /// layer's internal width, needed only to validate a per-channel `PReLU`'s `negative_slopes` @@ -365,6 +409,20 @@ fn resolve_activation_entry( bottleneck: usize, array_index: usize, layer_index: usize, +) -> Result { + let activation = resolve_activation_kind(entry, bottleneck, array_index, layer_index)?; + check_activation_parameters_finite(&activation, array_index, layer_index)?; + Ok(activation) +} + +/// [`resolve_activation_entry`]'s name-and-parameter mapping, split out so that function is +/// exactly "resolve, then validate" — see [`check_activation_parameters_finite`] for the +/// validation half. +fn resolve_activation_kind( + entry: &file::ActivationEntry, + bottleneck: usize, + array_index: usize, + layer_index: usize, ) -> Result { match entry { file::ActivationEntry::Name(name) => Activation::try_from(name.as_str()), @@ -1356,7 +1414,8 @@ impl PreparedWaveNet { /// 5. `sample_rate` is nonzero if present (`INVALID_SAMPLE_RATE`), else defaults to 48 kHz. /// 6. `config.layers` is non-empty (`EMPTY_LAYER_ARRAYS`). /// 7. `config.layers.len()` and `weights.len()` are within their NFR-SEC-020 ceilings - /// (`DIMENSION_LIMIT_EXCEEDED`). + /// (`DIMENSION_LIMIT_EXCEEDED`), and every float in `weights` is finite + /// (`NON_FINITE_VALUE`, issue #49 — checked after the ceiling so the walk is bounded). /// 8. Every layer array is resolved via `resolve_layer_array` (M10, FR-NAM-140/D-9.12): any /// permanently out-of-scope feature the array uses is rejected by name /// (`UNSUPPORTED_CONFIGURATION`), a self-contradictory shape (both-or-neither of an A1/A2 @@ -1369,6 +1428,16 @@ impl PreparedWaveNet { /// (`DIMENSION_LIMIT_EXCEEDED` / `UNSUPPORTED_CONDITION_SIZE`), including the per-layer and /// per-head NFR-SEC-020 product checks `validate_layer_array_dims` performs. /// + /// 8b. The model's own boundary widths: `layers[0].input_size == 1` + /// (`INCONSISTENT_CONFIGURATION` — it disagrees with `config.in_channels`, pinned to 1 by + /// step 4; issue #47) and the *last* array's head width, A1's `head_size` or A2's + /// `head.out_channels` (`UNSUPPORTED_CONFIGURATION` — that field *is* the model's output + /// channel count, and Namir plays mono; issue #46). Neither was constrained by anything + /// before — step 10's chaining check only relates *adjacent* arrays, so the stack's two + /// ends were free — and each one let a loadable file panic inside `process_block`, on the + /// audio thread. See the checks' own comment for both index expressions and for why the + /// two codes differ. + /// /// Step 8 all happens *before* step 9 reads a single weight or performs a single /// dimension-derived multiplication or allocation. This ordering is load-bearing, not /// decorative: once every dimension that ever appears in a product (`channels * input_size`, @@ -1400,6 +1469,8 @@ impl PreparedWaveNet { /// 11. The trailing `head_scale` float is resolved exactly as the spike's confirmed reading /// of `WaveNet::set_weights_`: if one float remains after step 9, it is authoritative; if /// none remain, `config.head_scale` is used; anything else is `WEIGHT_COUNT_MISMATCH`. + /// Whichever of the two is resolved must itself be finite (`NON_FINITE_VALUE`) — step 7 + /// covers the trailing-float form, but nothing else ever looks at `config.head_scale`. pub fn from_file(nam: &NamFile) -> Result { if nam.architecture != "WaveNet" { return Err(NamLoadError { @@ -1454,6 +1525,11 @@ impl PreparedWaveNet { "config.layers.len()", )?; check_max(nam.weights.len(), MAX_TOTAL_WEIGHTS, "weights.len()")?; + // Issue #49: after the ceiling above (so this walk is bounded), before any of these + // floats can become part of a `PreparedWaveNet` the audio thread will run. See + // `shared::check_finite` for why an infinite weight is a load-time rejection and not + // something the RT path can be asked to cope with. + check_finite(&nam.weights, "weights")?; let mut resolved_arrays = Vec::with_capacity(nam.config.layers.len()); for (i, cfg) in nam.config.layers.iter().enumerate() { @@ -1462,6 +1538,61 @@ impl PreparedWaveNet { resolved_arrays.push(resolved); } + // The model's own two boundary widths, as opposed to the widths *between* adjacent arrays + // (step 10, below). Step 10 relates array `i` to array `i + 1`, so the two ends of the + // stack — what feeds array 0, and what array `n-1` emits — were constrained by nothing at + // all, and each let a file load and then misbehave inside `process_block`, on the audio + // thread. Neither is defensible there (no allocation, no way to report anything), so both + // are load-time rejections. They get *different* codes because they are different + // failures, which the reference implementation's own reading of these two fields settles + // (`NAM/wavenet/model.cpp`, read directly for this fix, as `lstm.rs`'s module doc comment + // records doing for LSTM): + // + // * `layers[0].input_size` is the width feeding the first array's rechannel + // (`nam::wavenet::detail::LayerArray`'s `_rechannel(params.input_size, + // params.channels, false)`), while the signal fed to it is the model's own input, + // which is `config.in_channels` wide + // (`WaveNet::_set_condition_array`, sized by `config.value("in_channels", 1)`). The + // reference never checks the two agree; here `in_channels` has already been pinned to 1 + // above, so `input_size != 1` is a file contradicting its own declared input width — + // `INCONSISTENT_CONFIGURATION`, not "unsupported": a genuine multi-input model declares + // `in_channels` too, and is rejected above, by name, as the unsupported feature it is. + // Namir feeds `Conv1x1::apply_into` the width-1 condition signal regardless, and that + // function indexes `input[ic * n..(ic + 1) * n]` for `ic in 0..in_ch`, so this file read + // straight off the end of it (issue #47: `range end index 16 out of range for slice of + // length 8`). + // * The last array's head width *is* the model's output channel count — the reference + // derives `NumOutputChannels()` from exactly this field (`wave_net_output_channels`: + // `layer_array_params.back().head_size`, absent a post-stack head, which Namir rejects + // already) and then writes that many output buffers. So a value above 1 is a real, + // reference-supported multi-output model, and rejecting it is a scope limit, not a + // repair: `UNSUPPORTED_CONFIGURATION`, the same code and the same mono-only reason as + // `config.in_channels != 1` above. Namir's chain carries one signal and has nowhere to + // put a second; `process_block` instead wrote `out[..head_size * n]` into a buffer every + // caller sizes to `input.len()` (issue #46: `range end index 32 out of range for slice + // of length 8`). + let first_input_size = nam.config.layers[0].input_size; + if first_input_size != 1 { + return Err(NamLoadError { + code: error_codes::INCONSISTENT_CONFIGURATION, + detail: format!( + "layer array 0: input_size ({first_input_size}) does not match the model's \ + input width of 1 channel (config.in_channels, absent or 1)" + ), + }); + } + let last_index = resolved_arrays.len() - 1; + let last_head_width = resolved_arrays[last_index].head_out_channels; + if last_head_width != 1 { + return Err(NamLoadError { + code: error_codes::UNSUPPORTED_CONFIGURATION, + detail: format!( + "layer array {last_index} (the last) declares a head width of \ + {last_head_width} (head_size, or head.out_channels), so this model has \ + {last_head_width} output channels; Namir supports 1" + ), + }); + } let mut r = WeightReader::new(&nam.weights); let mut arrays = Vec::with_capacity(nam.config.layers.len()); for (cfg, resolved) in nam.config.layers.iter().zip(&resolved_arrays) { @@ -1583,6 +1714,14 @@ impl PreparedWaveNet { }); }; + // Issue #49, the other half: the trailing-float form is already covered by the + // `check_finite` on `weights` above, but `config.head_scale` — the fallback when no + // trailing float is present — has never been through any check at all. Only the value + // actually resolved is checked, so a file carrying an unused, non-finite + // `config.head_scale` alongside a good trailing float is not rejected for a field its + // own reference implementation would have overwritten. + check_finite_scalar(head_scale, "head_scale")?; + Ok(Self { arrays, head_scale, @@ -2149,6 +2288,253 @@ mod tests { }); } + // ----------------------------------------------------------------------------------------- + // Issues #46/#47/#49: the three ways a loadable file used to reach `process_block` and then + // misbehave there — twice by panicking (on the audio thread, which in `namir-clap` is the + // host's), once by emitting a non-finite block. All three are load-time rejections now; the + // audio thread is not where any of them could have been handled. + // ----------------------------------------------------------------------------------------- + + /// Builds a one-layer-array file around `cfg`, with the exact A1 weight count `cfg` implies + /// plus the trailing `head_scale` float — the same shape `minimal_valid_file` produces, for a + /// caller that has already mutated the layer array. + fn file_around(cfg: LayerArrayConfig) -> NamFile { + let n = weight_count_for(&cfg); + let mut weights = vec![0.01f32; n]; + weights.push(0.5); // trailing head_scale + NamFile { + version: None, + architecture: "WaveNet".to_string(), + config: WaveNetConfig { + layers: vec![cfg], + head_scale: 0.5, + head: None, + in_channels: None, + condition_dsp: None, + }, + weights, + sample_rate: Some(48_000), + metadata: NamMetadata::default(), + } + } + + /// Issue #46. The last array's head output *is* the model's output, and `process_block` writes + /// `out[..head_size * n]` into a buffer sized `input.len()`. `MAX_HEAD_SIZE` bounded this + /// field and the chaining check below constrained `head_size[i]` only against + /// `bottleneck[i+1]`, so the *last* array's was free: this file loaded, and then + /// `process_block` panicked with `range end index 32 out of range for slice of length 8`. + #[test] + fn rejects_a_final_layer_array_head_size_other_than_one() { + let mut cfg = minimal_layer_array(); + cfg.head_size = Some(4); + let err = expect_err(PreparedWaveNet::from_file(&file_around(cfg))); + assert_eq!(err.code.id, error_codes::UNSUPPORTED_CONFIGURATION.id); + assert!(err.detail.contains("head"), "detail: {:?}", err.detail); + } + + /// Issue #46's A2 spelling: the same width, declared as the nested head's `out_channels` + /// rather than A1's legacy `head_size`. The check is on the *resolved* width precisely so one + /// check covers both spellings — a check written against `cfg.head_size` would have left this + /// file loading and panicking exactly as before. + #[test] + fn rejects_a_final_a2_head_out_channels_other_than_one() { + let mut cfg = a2_minimal_layer_array(); + cfg.head = Some(file::LayerArrayHeadConfig { + out_channels: 3, + kernel_size: 3, + head_dilation: Some(2), + bias: true, + }); + let n = a2_weight_count_for(&cfg); + let mut weights = vec![0.01f32; n]; + weights.push(0.5); + let file = NamFile { + version: None, + architecture: "WaveNet".to_string(), + config: WaveNetConfig { + layers: vec![cfg], + head_scale: 0.5, + head: None, + in_channels: None, + condition_dsp: None, + }, + weights, + sample_rate: Some(48_000), + metadata: NamMetadata::default(), + }; + let err = expect_err(PreparedWaveNet::from_file(&file)); + assert_eq!(err.code.id, error_codes::UNSUPPORTED_CONFIGURATION.id); + assert!(err.detail.contains("head"), "detail: {:?}", err.detail); + } + + /// The other side of issue #46's check, and the reason it is scoped to the *last* array: a + /// non-final array's head width is the next array's head-accumulator width, so anything above + /// 1 is ordinary there (every real two-array export has one). Rejecting `head_size != 1` + /// outright would have refused every model this crate is for. + #[test] + fn a_non_final_layer_array_head_size_above_one_still_loads() { + let mut cfg0 = minimal_layer_array(); // channels 2, head_size 1 + cfg0.head_size = Some(2); // == cfg1.bottleneck (its `channels`, A1 having no bottleneck) + let mut cfg1 = minimal_layer_array(); + cfg1.input_size = cfg0.channels; // the trunk signal chains here + cfg1.channels = 2; + + let mut weights = vec![0.01f32; weight_count_for(&cfg0) + weight_count_for(&cfg1)]; + weights.push(0.5); + let file = NamFile { + version: None, + architecture: "WaveNet".to_string(), + config: WaveNetConfig { + layers: vec![cfg0, cfg1], + head_scale: 0.5, + head: None, + in_channels: None, + condition_dsp: None, + }, + weights, + sample_rate: Some(48_000), + metadata: NamMetadata::default(), + }; + let prepared = + PreparedWaveNet::from_file(&file).expect("a two-array model must still load"); + let mut state = prepared.new_state(8); + let out = prepared.process(&mut state, &[0.1f32; 8]); + assert_eq!(out.len(), 8, "the model's own output is still mono"); + } + + /// Issue #47. `process_block` feeds the first array's rechannel the width-1 condition signal, + /// while `Conv1x1::apply_into` indexes `input[ic * n..(ic + 1) * n]` for `ic in 0..in_ch`, + /// where `in_ch` is this field: the file loaded, and then `process_block` panicked with + /// `range end index 16 out of range for slice of length 8`. + /// + /// `INCONSISTENT_CONFIGURATION`, not `UNSUPPORTED_CONFIGURATION`: this file declares (by + /// omitting `in_channels`) a one-channel input and then a first layer array expecting two — + /// see `from_file`'s own comment at the check, and the next test for the file that *is* an + /// unsupported multi-input model rather than a self-contradictory one. + #[test] + fn rejects_a_first_layer_array_input_size_other_than_one() { + let mut cfg = minimal_layer_array(); + cfg.input_size = 2; + let err = expect_err(PreparedWaveNet::from_file(&file_around(cfg))); + assert_eq!(err.code.id, error_codes::INCONSISTENT_CONFIGURATION.id); + assert!( + err.detail.contains("input_size"), + "detail: {:?}", + err.detail + ); + } + + /// The distinction the previous test's code choice rests on: a *consistent* multi-input model + /// — `in_channels` and `layers[0].input_size` both 2 — is a real shape this build does not + /// implement, and is rejected as that, by name, before the consistency check is ever reached. + /// The two files get different codes and different messages because they are different + /// problems. + #[test] + fn a_consistent_multi_input_model_is_unsupported_rather_than_inconsistent() { + let mut cfg = minimal_layer_array(); + cfg.input_size = 2; + let mut file = file_around(cfg); + file.config.in_channels = Some(2); + let err = expect_err(PreparedWaveNet::from_file(&file)); + assert_eq!(err.code.id, error_codes::UNSUPPORTED_CONFIGURATION.id); + assert!( + err.detail.contains("in_channels"), + "detail: {:?}", + err.detail + ); + } + + /// Issue #49. `serde_json` deserializes `1e40` — in `f64` range, out of `f32` range — into + /// `f32::INFINITY` with no error, and nothing checked. The model loaded and `process` returned + /// `[NaN, 0.005060297, 0.005065496, 0.005070695]` for this fixture (the issue's own fixture + /// reported `[inf, inf, inf, inf]`; which of the two a given file produces depends only on + /// which weight is infinite). Downstream, FR-CHAIN-080/090 mutes such a block, so the user's + /// symptom was silence and a fault counter with nothing naming the cause. + #[test] + fn rejects_a_non_finite_weight() { + let mut file = minimal_valid_file(); + // A Rust `f32` literal cannot express this (rustc's `overflowing_literals` lint refuses + // `1e40f32` outright), so the value is produced the same way a real file's is: by + // deserializing JSON text. + file.weights[6] = serde_json::from_str::("1e40").unwrap(); + let err = expect_err(PreparedWaveNet::from_file(&file)); + assert_eq!(err.code.id, error_codes::NON_FINITE_VALUE.id); + assert!( + err.detail.contains("weights[6]"), + "detail: {:?}", + err.detail + ); + } + + /// NaN as well as infinity — `is_finite()` covers both, and a file can carry a NaN weight the + /// same way (JSON has no NaN literal, but `1e40 - 1e40` style arithmetic in an exporter + /// produces one, and a corrupted file can carry any bit pattern). + #[test] + fn rejects_a_nan_weight() { + let mut file = minimal_valid_file(); + file.weights[3] = f32::NAN; + let err = expect_err(PreparedWaveNet::from_file(&file)); + assert_eq!(err.code.id, error_codes::NON_FINITE_VALUE.id); + } + + /// Issue #49's `head_scale` half, in the one form the `weights` check above does not already + /// cover: `config.head_scale` is used only when no trailing float remains, so this file has + /// its trailing float removed. + #[test] + fn rejects_a_non_finite_config_head_scale_when_it_is_the_one_in_use() { + let mut file = minimal_valid_file(); + file.weights.pop(); // no trailing float => config.head_scale is authoritative + file.config.head_scale = serde_json::from_str::("-1e40").unwrap(); + let err = expect_err(PreparedWaveNet::from_file(&file)); + assert_eq!(err.code.id, error_codes::NON_FINITE_VALUE.id); + assert!( + err.detail.contains("head_scale"), + "detail: {:?}", + err.detail + ); + } + + /// The deliberate limit of the previous test: only the head_scale actually *resolved* is + /// checked. With a trailing float present, `config.head_scale` is dead data — the reference + /// implementation overwrites it unread — so a file is not rejected over a field neither + /// implementation ever uses. + #[test] + fn an_unused_non_finite_config_head_scale_is_not_a_rejection() { + let mut file = minimal_valid_file(); // keeps its trailing head_scale float + file.config.head_scale = serde_json::from_str::("1e40").unwrap(); + let prepared = PreparedWaveNet::from_file(&file) + .expect("the trailing float is authoritative; config.head_scale is unread"); + let mut state = prepared.new_state(4); + let out = prepared.process(&mut state, &[0.1, 0.2, 0.3, 0.4]); + assert!(out.iter().all(|v| v.is_finite()), "output: {out:?}"); + } + + /// Issue #49 extended to the one other float family a `.nam` file can carry: an activation's + /// own parameters. They multiply the signal on the audio thread exactly as a weight does, and + /// arrive through the same `serde_json` float parsing, so an infinite `negative_slope` would + /// have produced the same non-finite output a bad weight does. + #[test] + fn rejects_a_non_finite_activation_parameter() { + let mut file = a2_minimal_valid_file(); + file.config.layers[0].activation = + file::ActivationSpec::One(file::ActivationEntry::Params(file::ActivationParams { + kind: "LeakyReLU".to_string(), + negative_slope: Some(serde_json::from_str::("1e40").unwrap()), + negative_slopes: None, + min_val: None, + max_val: None, + min_slope: None, + max_slope: None, + })); + let err = expect_err(PreparedWaveNet::from_file(&file)); + assert_eq!(err.code.id, error_codes::NON_FINITE_VALUE.id); + assert!( + err.detail.contains("negative_slope"), + "detail: {:?}", + err.detail + ); + } + // ----------------------------------------------------------------------------------------- // M10 (A2, Steps A1-A4): core-A2 feature coverage. Small-scale hand-built fixtures, not the // full 23-layer "A2 standard"/"A2 nano" shapes `a2_fast.h` describes — nothing about that From 0ea98a9b463c381e0b06d1889b48cc6bc913c4dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:38:57 +0000 Subject: [PATCH 11/44] namir-platform: asm soundness, thread priority, and the fallback target (#74-#83) #74 is the real one: the ldmxcsr block declared preserves_flags, which the Rust Reference defines to include MXCSR's six sticky exception flags on x86 -- and ldmxcsr loads the whole register, clearing any flag raised since the guard was constructed. A false promise to LLVM regardless of EFLAGS. Option dropped, SAFETY rewritten. stmxcsr keeps it (it only reads), and the aarch64 msr fpcr blocks are correct as they stand: that rule covers NZCV and FPSR, which it does not write. #77, same file, same class: MXCSR and FPCR are per-thread, so a guard sent across threads restores one thread's mode onto another and leaves the first engaged for life. PhantomData<*const ()> makes it a compile error, as MutexGuard does. Both production call sites and both benches drop on the constructing thread, so nothing breaks. #75: SCHED_FIFO at max is 99 on Linux, where watchdog/N and migration/N live and threaded IRQ handlers sit at 50 -- outranking everything able to preempt a runaway audio thread. Now min+10, expressed relative to min because the Linux and Darwin ranges (1..=99, 15..=47) do not overlap in meaning. Read back on Linux as SCHED_FIFO priority 11. #81 is recorded, not implemented: Darwin promotes audio threads with THREAD_TIME_CONSTRAINT_POLICY, a deadline contract no POSIX priority expresses, so this module's success there is materially weaker than on Windows and Linux. macOS is secondary and no CI machine here can exercise a Mach binding. #76 is half done. Its proposed fix -- log the outcome from stream.rs -- would fail the build: stream.rs is on rt-logging's audio-thread list, and both callers elevate from inside the audio callback, where a format! also trips the allocation harness. diagnostic() returns a catalogued code with no allocation instead; the shells must carry the Copy outcome off-thread. Those caller changes are still owed. #80: OsError widens to i64, so 0x8007000E reaches a bundle as itself rather than -2147024882. #79: the bad-level warning bypasses severity admission, except at Off, whose contract is that no file is created. #83 reproduced for real by building for wasm32 -- three unused_variables, a -D warnings failure -- and fixed, plus the same defect in both test modules' imports. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-platform/src/clap_paths.rs | 18 +- crates/namir-platform/src/denormal.rs | 140 ++++++++++++- crates/namir-platform/src/error_codes.rs | 63 +++++- crates/namir-platform/src/logging.rs | 54 +++-- crates/namir-platform/src/paths.rs | 14 +- crates/namir-platform/src/thread_priority.rs | 203 ++++++++++++++++++- crates/namir-platform/tests/logging.rs | 34 ++++ 7 files changed, 492 insertions(+), 34 deletions(-) diff --git a/crates/namir-platform/src/clap_paths.rs b/crates/namir-platform/src/clap_paths.rs index 096787e..ebb3553 100644 --- a/crates/namir-platform/src/clap_paths.rs +++ b/crates/namir-platform/src/clap_paths.rs @@ -99,14 +99,30 @@ fn clap_install_dir_from( ClapInstallScope::SystemWide => Some(PathBuf::from("/usr/lib/clap")), }; + // On a target that is neither Windows nor `unix`, D-13.3's table has no row and every one of + // this function's inputs goes unread -- which `unused_variables` reports and CI's `-D + // warnings` turns into a build failure, on precisely the "must not be precluded" target this + // fallback exists for (this module's doc comment; NFR-PORT-030). Consuming both by reference + // is the narrowest fix: it leaves the parameters' names, types and documentation intact for + // the other three arms, and unlike `#[allow(unused_variables)]` on the function it cannot + // hide a genuinely unread parameter in an arm that *should* read one. Android and iOS are + // both `unix`, so no currently-plausible target reaches this line. #[cfg(not(any(target_os = "windows", unix)))] - let result: Option = None; + let result: Option = { + let _ = (&scope, &getenv); + None + }; result } #[cfg(test)] mod tests { + // Every test below lives in a per-OS submodule, so on a target with no row in the table above + // this import has no user and `-D warnings` would fail the test build -- the same defect + // `clap_install_dir_from`'s own fallback arm carries, one target away. Scoped to the + // import and to that target, so it cannot hide an unused import anywhere real. + #[cfg_attr(not(any(target_os = "windows", unix)), allow(unused_imports))] use super::*; #[cfg(target_os = "windows")] diff --git a/crates/namir-platform/src/denormal.rs b/crates/namir-platform/src/denormal.rs index 0957032..c01ae1c 100644 --- a/crates/namir-platform/src/denormal.rs +++ b/crates/namir-platform/src/denormal.rs @@ -9,6 +9,8 @@ #![allow(unsafe_code)] +use core::marker::PhantomData; + /// MXCSR bit 15 (FTZ, flush-to-zero: a subnormal *result* is replaced by zero) and bit 6 (DAZ, /// denormals-are-zero: a subnormal *input* is treated as zero before the operation runs). Both /// are needed together: FTZ alone still takes the slow microcode path for subnormal inputs @@ -30,9 +32,24 @@ const FZ_MASK: u64 = 1 << 24; /// On an architecture this crate doesn't have a denormal-control implementation for, the guard /// still constructs and drops cleanly; it just doesn't change anything (NFR-PORT-030 must not be /// precluded by this guard failing to compile on, say, a 32-bit ARM target). +/// +/// **Bind it.** `DenormalGuard::new();` written as a bare statement engages and restores the mode +/// inside the one expression — a complete no-op whose only symptom is NFR-RT-030 quietly failing +/// again, with no test able to see it. Hence the `#[must_use]`. +/// +/// **Deliberately neither `Send` nor `Sync`**, enforced by the `PhantomData<*const ()>` field. +/// MXCSR (x86_64) and FPCR (AArch64) are *per-thread* CPU state: the OS saves and restores them +/// across a context switch, so a guard constructed on thread A and dropped on thread B would +/// write A's captured mode onto B — clobbering B's mode and leaving A's engaged for the rest of +/// that thread's life. This is the same hazard `std::sync::MutexGuard` is `!Send` to prevent, and +/// the compiler is the only thing that can catch it, since the misuse is silent at runtime. #[cfg(target_arch = "x86_64")] +#[must_use = "the guard restores the FPU mode on drop; binding it is the point"] pub struct DenormalGuard { previous_mxcsr: u32, + /// Makes the guard `!Send`/`!Sync` — see this struct's doc comment. Zero-sized: the guard is + /// still exactly one `u32` at runtime. + _not_send: PhantomData<*const ()>, } /// Puts the FPU into flush-to-zero / denormals-are-zero mode for as long as it's alive, restoring @@ -43,9 +60,24 @@ pub struct DenormalGuard { /// On an architecture this crate doesn't have a denormal-control implementation for, the guard /// still constructs and drops cleanly; it just doesn't change anything (NFR-PORT-030 must not be /// precluded by this guard failing to compile on, say, a 32-bit ARM target). +/// +/// **Bind it.** `DenormalGuard::new();` written as a bare statement engages and restores the mode +/// inside the one expression — a complete no-op whose only symptom is NFR-RT-030 quietly failing +/// again, with no test able to see it. Hence the `#[must_use]`. +/// +/// **Deliberately neither `Send` nor `Sync`**, enforced by the `PhantomData<*const ()>` field. +/// MXCSR (x86_64) and FPCR (AArch64) are *per-thread* CPU state: the OS saves and restores them +/// across a context switch, so a guard constructed on thread A and dropped on thread B would +/// write A's captured mode onto B — clobbering B's mode and leaving A's engaged for the rest of +/// that thread's life. This is the same hazard `std::sync::MutexGuard` is `!Send` to prevent, and +/// the compiler is the only thing that can catch it, since the misuse is silent at runtime. #[cfg(target_arch = "aarch64")] +#[must_use = "the guard restores the FPU mode on drop; binding it is the point"] pub struct DenormalGuard { previous_fpcr: u64, + /// Makes the guard `!Send`/`!Sync` — see this struct's doc comment. Zero-sized: the guard is + /// still exactly one `u64` at runtime. + _not_send: PhantomData<*const ()>, } /// Puts the FPU into flush-to-zero / denormals-are-zero mode for as long as it's alive, restoring @@ -56,8 +88,24 @@ pub struct DenormalGuard { /// On an architecture this crate doesn't have a denormal-control implementation for, the guard /// still constructs and drops cleanly; it just doesn't change anything (NFR-PORT-030 must not be /// precluded by this guard failing to compile on, say, a 32-bit ARM target). +/// +/// **Bind it.** `DenormalGuard::new();` written as a bare statement engages and restores the mode +/// inside the one expression — a complete no-op whose only symptom is NFR-RT-030 quietly failing +/// again, with no test able to see it. Hence the `#[must_use]`. +/// +/// **Deliberately neither `Send` nor `Sync`**, enforced by the `PhantomData<*const ()>` field. +/// MXCSR (x86_64) and FPCR (AArch64) are *per-thread* CPU state: the OS saves and restores them +/// across a context switch, so a guard constructed on thread A and dropped on thread B would +/// write A's captured mode onto B — clobbering B's mode and leaving A's engaged for the rest of +/// that thread's life. This is the same hazard `std::sync::MutexGuard` is `!Send` to prevent, and +/// the compiler is the only thing that can catch it, since the misuse is silent at runtime. #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] -pub struct DenormalGuard; +#[must_use = "the guard restores the FPU mode on drop; binding it is the point"] +pub struct DenormalGuard { + /// Makes the guard `!Send`/`!Sync` on this target too, so a caller that compiles for one + /// architecture compiles for all of them — see this struct's doc comment. + _not_send: PhantomData<*const ()>, +} /// `core::arch::x86_64::_mm_getcsr`/`_mm_setcsr` exist but are deprecated as of this workspace's /// toolchain: the intrinsic form carries no memory clobber, so the compiler is free to reorder @@ -73,6 +121,9 @@ fn read_mxcsr() -> u32 { // whole duration, not observed through any other reference while this call runs. No memory // besides that one `u32` is touched, and the register being read (MXCSR) is FPU control // state, not addressable memory, so this cannot violate Rust's memory-safety guarantees. + // `preserves_flags` is accurate here and only here in this module's x86_64 half: `stmxcsr` + // reads MXCSR and modifies nothing the option covers — unlike `ldmxcsr`, which is why + // `write_mxcsr` below does not claim it. unsafe { core::arch::asm!( "stmxcsr [{0}]", @@ -83,18 +134,34 @@ fn read_mxcsr() -> u32 { value } +/// The write half of the pair above. +/// +/// **Deliberately without `preserves_flags`, and that is the whole difference from +/// [`read_mxcsr`].** The Rust Reference's list of state `options(preserves_flags)` promises the +/// compiler is untouched includes, on x86, "the floating-point exception flags in `MXCSR` (`PE`, +/// `UE`, `OE`, `ZE`, `DE`, `IE`)". `ldmxcsr` loads the *whole* 32-bit register — control bits and +/// those six sticky status bits alike — and the value being loaded here was captured by a +/// `stmxcsr` at some earlier point, so any exception flag raised by floating-point work in +/// between is cleared by the load. That is precisely the state the option promises is preserved, +/// so declaring it here would be a false promise to LLVM (a miscompile hazard, not a lint), even +/// though `ldmxcsr` leaves `EFLAGS` itself alone. `stmxcsr` only *reads* `MXCSR`, so it keeps the +/// option. The AArch64 blocks in this module keep it for the analogous reason: the AArch64 rule +/// covers `NZCV` and `FPSR`, and `msr fpcr` writes neither. #[cfg(target_arch = "x86_64")] fn write_mxcsr(value: u32) { // SAFETY: `ldmxcsr` loads the 4-byte MXCSR value from the address given by `{0}`, which is // `&value as *const u32` — a valid, aligned, readable place for the instruction's duration. // The instruction only ever reads memory here (never writes it), matching the `readonly` // option below, and the register it loads (MXCSR) is FPU control state, not memory, so this - // cannot violate Rust's memory-safety guarantees. + // cannot violate Rust's memory-safety guarantees. `preserves_flags` is *not* claimed: loading + // MXCSR overwrites the sticky FP exception flags that option covers — see this function's own + // doc comment. `nostack` and `readonly` are both accurate: the instruction touches no stack + // slot and writes no memory. unsafe { core::arch::asm!( "ldmxcsr [{0}]", in(reg) &value as *const u32, - options(nostack, preserves_flags, readonly), + options(nostack, readonly), ); } } @@ -105,7 +172,10 @@ impl DenormalGuard { pub fn new() -> Self { let previous_mxcsr = read_mxcsr(); write_mxcsr(previous_mxcsr | FTZ_DAZ_MASK); - Self { previous_mxcsr } + Self { + previous_mxcsr, + _not_send: PhantomData, + } } } @@ -136,7 +206,10 @@ impl DenormalGuard { options(nomem, nostack, preserves_flags), ); } - Self { previous_fpcr } + Self { + previous_fpcr, + _not_send: PhantomData, + } } } @@ -145,7 +218,9 @@ impl DenormalGuard { /// No-op on an architecture this crate has no denormal-control implementation for; see this /// struct's doc comment for why that must not preclude building here at all. pub fn new() -> Self { - Self + Self { + _not_send: PhantomData, + } } } @@ -192,6 +267,59 @@ mod tests { drop(guard); } + /// The guard captures *per-thread* CPU state (MXCSR/FPCR), so sending one across a thread + /// boundary would restore thread A's mode onto thread B. `PhantomData<*const ()>` is what + /// makes that a compile error; this asserts the property is actually present, since deleting + /// the marker field would otherwise change nothing any other test can observe. + /// + /// `Probe::::IS_SEND` resolves to the inherent `impl` when `T: Send` holds and + /// falls back to the blanket trait const when it does not — the standard stable way to ask + /// the compiler an auto-trait question inside a running test rather than a `compile_fail` + /// doctest that could pass for an unrelated reason. `CONTROL` proves the probe can say + /// `true`, so a `false` below is the marker doing its job and not a broken probe. + #[test] + fn guard_is_neither_send_nor_sync() { + struct Probe(PhantomData); + trait NotSend { + const IS_SEND: bool = false; + } + impl NotSend for Probe {} + impl Probe { + const IS_SEND: bool = true; + } + // A second wrapper, because one type cannot carry two inherent consts under different + // bounds without them colliding on a type that satisfies both. + struct SyncProbe(PhantomData); + trait NotSync { + const IS_SYNC: bool = false; + } + impl NotSync for SyncProbe {} + impl SyncProbe { + const IS_SYNC: bool = true; + } + + struct Control(#[allow(dead_code)] u32); + + // `const {}` because the answers are compile-time facts and clippy rightly refuses a + // runtime `assert!` on one. A regression here is therefore a compile error in this test + // target rather than a red test -- louder, and in the same spirit as the rest of this + // crate's static guarantees. + const { + assert!( + >::IS_SEND && >::IS_SYNC, + "the probe itself is broken: a plain u32 wrapper must read as Send + Sync" + ); + assert!( + !>::IS_SEND, + "DenormalGuard must not be Send: it restores per-thread FPU state on drop" + ); + assert!( + !>::IS_SYNC, + "DenormalGuard must not be Sync: it guards per-thread FPU state" + ); + } + } + #[cfg(target_arch = "x86_64")] mod x86_64_tests { use super::*; diff --git a/crates/namir-platform/src/error_codes.rs b/crates/namir-platform/src/error_codes.rs index 35ecf9f..71e34d4 100644 --- a/crates/namir-platform/src/error_codes.rs +++ b/crates/namir-platform/src/error_codes.rs @@ -4,11 +4,19 @@ //! //! This crate had no catalogue at all until M9b: `paths.rs`, `clap_paths.rs` and //! `denormal.rs` all report by returning `Option`/an outcome enum rather than by raising a -//! catalogued error, and `thread_priority.rs` does the same. The three entries below exist +//! catalogued error, and `thread_priority.rs` did the same. The first three entries below exist //! because D-16.5 makes **every** log record catalogue-backed — `` is a mandatory field //! of the record format — so the log writer's own lifecycle events (a session opening, a rotation //! happening, an unparseable `NAMIR_LOG`) need catalogue ids like everything else, rather than a //! second, id-less record shape. +//! +//! The last two entries are `thread_priority.rs`'s, added when +//! [`crate::ThreadPriorityOutcome`] stopped being a value its only caller discarded: the outcome +//! enum is still the return type (a denial is not an error to propagate with `?`), and these are +//! what [`crate::ThreadPriorityOutcome::diagnostic`] hands a caller to *record*. Note where that +//! record may be written: both of today's callers invoke the elevation from inside an audio +//! callback, and FR-ERR-030 forbids logging there, so the outcome has to be carried off the +//! audio thread before it becomes a record -- see that method's own doc comment. use namir_core::{ErrorCode, Severity}; @@ -38,6 +46,14 @@ pub const LOG_ROTATED: ErrorCode = ErrorCode::new( /// back exactly as if the variable were unset — never silently off — and this record names the /// rejected value so the user who mistyped it can see that they did. /// +/// **Admitted regardless of the resolved level, `off` excepted** (issue #79). [`Severity::Warning`] +/// is below what [`crate::logging::LogLevel::Error`] admits, so routing this through the ordinary +/// `record` path discarded it for exactly the user who had already chosen a quiet log *and* +/// mistyped the variable — leaving the record's own promise above unkept. [`crate::logging::Logger::new`] +/// therefore writes it directly. The single exception is [`crate::logging::LogLevel::Off`], whose +/// contract is that the file is never opened or created at all; forcing a record past that would +/// create a log the user switched off. +/// /// **Severity divergence, recorded rather than glossed.** D-16.5's parameter prose calls these /// three "`Severity::Info` consts" in one sentence and then calls this one's record a "`WARN /// platform.log.bad_level` record" two paragraphs later; the two statements cannot both hold, @@ -55,6 +71,43 @@ pub const LOG_BAD_LEVEL: ErrorCode = ErrorCode::new( at its normal level in the meantime, not switched off.", ); +/// The audio thread asked for a real-time scheduling priority and the OS refused for want of a +/// privilege, capability or resource limit (`EPERM`, or Win32 `ERROR_ACCESS_DENIED`) -- +/// [`crate::ThreadPriorityOutcome::PermissionDenied`]. +/// +/// The single most likely outcome on a Linux or macOS system with no pro-audio privilege +/// configuration done, and the one non-`Elevated` outcome with a remedy the user can act on, +/// which is why it is its own entry rather than sharing [`THREAD_PRIORITY_NOT_ELEVATED`]. Audio +/// still processes correctly at ordinary priority; what degrades is the xrun rate under load +/// (FR-IO-060), and a user reporting that deserves to find this line in the log rather than +/// guess. +pub const THREAD_PRIORITY_DENIED: ErrorCode = ErrorCode::new( + "platform.thread_priority.denied", + Severity::Warning, + "The audio thread could not be given real-time scheduling priority ({detail}).", + "Grant this user a real-time scheduling allowance -- on Linux, an `rtprio` limit (the \ + `audio` group and `/etc/security/limits.d` on most distributions, the same configuration \ + JACK and PipeWire ask for) or `CAP_SYS_NICE`. Audio still runs without it, with a higher \ + chance of dropouts under system load.", +); + +/// The audio thread did not get a real-time scheduling priority for a reason the user cannot act +/// on: the OS call failed for something other than a permission check +/// ([`crate::ThreadPriorityOutcome::OsError`]), or this target has no implementation +/// ([`crate::ThreadPriorityOutcome::Unsupported`]). +/// +/// Split from [`THREAD_PRIORITY_DENIED`] on remedy, not on severity: both are `Warning` and both +/// degrade the same way, but this one has nothing to tell the user to do, and a remedy that does +/// not apply is worse than none. The `{detail}` carries the raw OS code or the target name, which +/// is what FR-ERR-050's diagnostic bundle wants. +pub const THREAD_PRIORITY_NOT_ELEVATED: ErrorCode = ErrorCode::new( + "platform.thread_priority.not_elevated", + Severity::Warning, + "The audio thread is running at ordinary scheduling priority ({detail}).", + "Nothing to configure; this is not a permission problem. If you are hearing dropouts, raise \ + the audio buffer size. Include this line if you report the problem.", +); + #[cfg(test)] mod tests { use super::*; @@ -63,7 +116,13 @@ mod tests { /// statically. Same check every other crate's catalogue carries. #[test] fn catalogue_ids_are_unique() { - let all = [LOG_SESSION_STARTED, LOG_ROTATED, LOG_BAD_LEVEL]; + let all = [ + LOG_SESSION_STARTED, + LOG_ROTATED, + LOG_BAD_LEVEL, + THREAD_PRIORITY_DENIED, + THREAD_PRIORITY_NOT_ELEVATED, + ]; namir_core::assert_unique_ids(&all); for code in all { assert!( diff --git a/crates/namir-platform/src/logging.rs b/crates/namir-platform/src/logging.rs index 93c2237..d701899 100644 --- a/crates/namir-platform/src/logging.rs +++ b/crates/namir-platform/src/logging.rs @@ -14,14 +14,27 @@ //! //! **Why a mutex in a logger is acceptable here, and why this module lives in *this* crate.** //! D-5.1's table gives `namir-engine` `core, params, dsp, nam, ir` and nothing else, and `cargo -//! run -p xtask -- layering` checks that edge on every merge — so no code on the audio thread can -//! so much as *name* this module. The lint is what makes the lock safe; siting the writer in -//! `namir-platform` is therefore load-bearing rather than incidental. What the lint does not cover -//! is stated rather than assumed: `namir-app` and `namir-clap` depend on everything and own the -//! audio callbacks, so those two crates *could* call in from `cpal`'s callback or from -//! `process()`. Nothing mechanical stops them; the rule that no record is emitted from an audio -//! callback or a per-frame UI path is held by review plus `namir-worker`'s `assert_no_alloc` -//! stress harness, which fails on the allocation a record's formatting performs. +//! run -p xtask -- layering` checks that edge on every merge — so no *production* code on the +//! audio thread can so much as *name* this module. The lint is what makes the lock safe; siting +//! the writer in `namir-platform` is therefore load-bearing rather than incidental. Two things the +//! lint does not cover, stated rather than assumed: +//! +//! 1. **The edge check exempts dev-dependencies**, and `crates/namir-engine/Cargo.toml` carries +//! `namir-platform` as one (for D-7.4's `DenormalGuard` in `benches/denormal_guard.rs` and +//! `benches/rt_invariance.rs`). So `namir-engine`'s own benches and integration tests — +//! including the RT harnesses that drive `AudioEngine::process` — *can* name this module, and +//! the "cannot so much as name it" guarantee holds for the shipped build, not for the test +//! build. Nothing shipped is affected, and no test target does name it today; the carve-out is +//! recorded because a guarantee stated without its exception is the kind that gets relied on +//! where it does not hold. +//! 2. **`namir-app` and `namir-clap` depend on everything and own the audio callbacks**, so those +//! two crates *could* call in from `cpal`'s callback or from `process()`. `xtask rt-logging` +//! (M9b, FR-ERR-030's static half) is what now stops them, per-module: it fails the build if +//! any file on its `AUDIO_THREAD_MODULES` list names `logging`, `Logger`, `LogLevel` or +//! `record_verbose`. Beyond the names it reads, the rule that no record is emitted from an +//! audio callback or a per-frame UI path is held by review plus `namir-worker`'s +//! `assert_no_alloc` stress harness, which fails on the allocation a record's formatting +//! performs. //! //! **What this module deliberately does not have.** No `BufWriter`: a half-flushed buffer loses //! precisely the records written in the moments a crash makes interesting, so a record is exactly @@ -271,13 +284,24 @@ impl Logger { }; logger.record(LOG_SESSION_STARTED, &detail); if let Some(value) = choice.rejected { - logger.record( - LOG_BAD_LEVEL, - &format!( - "{LEVEL_ENV_VAR}={value} is not one of off/error/info/verbose; using {}", - choice.level - ), - ); + // Emitted through `write_locked`, deliberately bypassing `record`'s severity + // admission. [`LOG_BAD_LEVEL`] is a `Severity::Warning` (that const's doc comment + // argues why it may not be anything else), and `admits` does not pass a `Warning` at + // `LogLevel::Error` -- so a user running at `error` who mistyped `NAMIR_LOG` was told + // nothing at all by the record whose entire purpose is to tell them. The one check + // kept is `Off`, because that level's contract is that the file is never opened or + // created; a record forced past *that* would create a log the user switched off. + let bits = choice.level as u8; + if bits != LogLevel::Off as u8 { + logger.write_locked( + bits, + LOG_BAD_LEVEL, + &format!( + "{LEVEL_ENV_VAR}={value} is not one of off/error/info/verbose; using {}", + choice.level + ), + ); + } } logger } diff --git a/crates/namir-platform/src/paths.rs b/crates/namir-platform/src/paths.rs index de164a1..bec4b34 100644 --- a/crates/namir-platform/src/paths.rs +++ b/crates/namir-platform/src/paths.rs @@ -89,14 +89,26 @@ fn config_dir_from(getenv: impl Fn(&str) -> Option) -> Option .map(|xdg| PathBuf::from(xdg).join("namir")) .or_else(|| getenv("HOME").map(|home| PathBuf::from(home).join(".config/namir"))); + // Same fallback, same reason, same fix as `clap_paths.rs`'s -- see that arm's comment: on a + // target that is neither Windows nor `unix` this function's one parameter goes unread, and + // `unused_variables` under CI's `-D warnings` would fail the build on exactly the target the + // fallback exists to keep building (NFR-PORT-030). #[cfg(not(any(target_os = "windows", unix)))] - let result: Option = None; + let result: Option = { + let _ = &getenv; + None + }; result } #[cfg(test)] mod tests { + // Every test below lives in a per-OS submodule, so on a target with no row in the table above + // this import has no user and `-D warnings` would fail the test build -- the same defect + // `config_dir_from`'s own fallback arm carries, one target away. Scoped to the + // import and to that target, so it cannot hide an unused import anywhere real. + #[cfg_attr(not(any(target_os = "windows", unix)), allow(unused_imports))] use super::*; #[cfg(target_os = "windows")] diff --git a/crates/namir-platform/src/thread_priority.rs b/crates/namir-platform/src/thread_priority.rs index f4a7d52..f4eef57 100644 --- a/crates/namir-platform/src/thread_priority.rs +++ b/crates/namir-platform/src/thread_priority.rs @@ -14,6 +14,16 @@ //! next reader does not mistake "built, uncalled" for "built, working": nothing measures the //! effect of calling this yet, the same gap D-7.4's M3 audit found for `DenormalGuard`. //! +//! *Correction (2026-08-28, issue #76).* The paragraph above is history now, kept rather than +//! rewritten per this project's convention: M6 did land both callers, and +//! `crates/namir-app/src/stream.rs` and `crates/namir-clap/src/audio.rs` each call this exactly +//! once, from inside their own audio callback. What they did *not* do is look at the answer -- +//! both wrote `let _ = elevate_current_thread_priority();`, so a Linux user whose xruns come from +//! a missing `rtprio` limit got no diagnostic anywhere. [`ThreadPriorityOutcome`] is now +//! `#[must_use]` and [`ThreadPriorityOutcome::diagnostic`] maps a non-`Elevated` outcome to the +//! catalogue entry to record; the recording itself has to happen off the audio thread +//! (FR-ERR-030), which is that method's own doc comment. +//! //! **When and how a future caller should invoke this, stated explicitly so it isn't //! rediscovered:** once, from the thread being elevated -- OS thread-priority and //! scheduling-policy APIs act on a thread handle referring to *some* thread, and every API this @@ -42,15 +52,52 @@ //! codebase (D-7.1's worker-pool floor, D-8.1's return-ring backpressure): an unelevated thread //! still processes audio correctly, just with a higher chance of an OS-scheduling-induced xrun //! (FR-IO-060) under system load. Nothing in this module may panic or abort on a denied request. +//! +//! **What the Unix path deliberately does *not* ask for (issue #75).** It no longer requests the +//! policy's maximum priority. `sched_get_priority_max(SCHED_FIFO)` is 99 on Linux, which outranks +//! the very kernel threads that would otherwise notice and preempt a runaway audio thread, and a +//! spin or deadlock up there can take a machine down. The module now targets the policy minimum +//! plus a fixed offset -- 11 on Linux, 25 on macOS -- which is the band JACK and PipeWire settled +//! on for the same reason. The constant and its full argument are in `unix::RT_PRIORITY_ABOVE_MIN` +//! below; **it is not yet recorded in D-13.2**, whose text still reads "at that policy's maximum +//! priority", and doing so is the follow-up this change owes `docs/02-architecture.md`. +//! +//! **What the macOS path is not (issue #81).** `pthread_setschedparam` with `SCHED_FIFO` is *not* +//! how Darwin grants an audio thread real-time scheduling. CoreAudio-grade threads there are +//! promoted with `thread_policy_set(..., THREAD_TIME_CONSTRAINT_POLICY, ...)`, which states a +//! period, a computation budget and a constraint -- a deadline contract POSIX's priority number +//! has no way to express. What this module does on Darwin raises the thread within the timeshare +//! band and typically returns [`ThreadPriorityOutcome::Elevated`], so the outcome enum reports a +//! success that delivers materially less than the Windows and Linux paths do. That is recorded +//! rather than fixed here on purpose: macOS is a secondary platform and not a 1.0 target +//! (`AGENTS.md`, "Primary platform is Windows 11 x86-64"), a Mach `thread_policy_set` binding +//! would add a second unsafe surface with no machine in this project's CI able to exercise it, +//! and shipping the wrong mechanism quietly is worse than shipping a weaker one that says so. +//! When macOS becomes a supported target, this is the call to replace, and the outcome enum will +//! need a way to say "raised, but without a deadline guarantee". #![allow(unsafe_code)] +use namir_core::ErrorCode; + +use crate::error_codes::{THREAD_PRIORITY_DENIED, THREAD_PRIORITY_NOT_ELEVATED}; + /// Outcome of one call to [`elevate_current_thread_priority`]. Deliberately not a `Result` /// wrapping an error type with a `Display` impl or similar: per this module's own doc comment, a /// denial is an expected, common, non-exceptional outcome on Linux/macOS without prior privilege /// configuration, not an error condition to propagate with `?`. A caller matches on this and /// decides what to log; it is not expected to bail out. +/// +/// **`#[must_use]`, because "expected and non-fatal" is not the same as "ignorable".** The whole +/// value of distinguishing [`ThreadPriorityOutcome::PermissionDenied`] from +/// [`ThreadPriorityOutcome::Elevated`] is that a user reporting xruns can be told their process +/// never got the priority it asked for -- exactly D-13.3's "support request we can answer without +/// a round trip" reasoning. Discarding the value with `let _ =` leaves that user with no +/// diagnostic anywhere. [`ThreadPriorityOutcome::diagnostic`] is the one-call route from an +/// outcome to the catalogue entry a caller should record. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[must_use = "an unelevated audio thread is a diagnosable cause of xruns; record the outcome \ + (see ThreadPriorityOutcome::diagnostic) rather than discarding it"] pub enum ThreadPriorityOutcome { /// The calling thread's scheduling priority/class was raised successfully. Elevated, @@ -63,7 +110,15 @@ pub enum ThreadPriorityOutcome { /// OS error code (`GetLastError` on Windows, the `pthread_*` return value or `errno` on /// Unix) for diagnostics -- FR-ERR-050's diagnostic bundle is the intended consumer of this /// value, not a user-facing message formatted from it directly. - OsError(i32), + /// + /// **`i64`, not `i32`, and the width is load-bearing on exactly one platform.** Unix's two + /// sources are both `c_int` and fit an `i32` with room to spare. Windows's `GetLastError` + /// returns a `u32`, and the Win32 codes with bit 31 set (an `HRESULT`-shaped failure + /// propagated as a last-error value) are exactly the ones a two's-complement narrowing turns + /// into a negative number that appears in no header: `0x8007000E` would reach a reader of + /// FR-ERR-050's bundle as `-2147024882`. Widening to `i64` keeps every code on every platform + /// printable as the number the platform's own documentation uses. + OsError(i64), /// This target has no implementation in this module (anything besides Windows/Linux/macOS -- /// notably Android and iOS, which D-5.1 marks this crate as building for but which M6's /// product shells do not target for 1.0). Matches @@ -73,6 +128,42 @@ pub enum ThreadPriorityOutcome { Unsupported, } +impl ThreadPriorityOutcome { + /// The catalogue entry a caller should record for this outcome -- `None` for + /// [`ThreadPriorityOutcome::Elevated`], which has nothing to report. + /// + /// [`ThreadPriorityOutcome::PermissionDenied`] maps to + /// [`crate::error_codes::THREAD_PRIORITY_DENIED`], the one case with a remedy the user can + /// act on; [`ThreadPriorityOutcome::OsError`] and [`ThreadPriorityOutcome::Unsupported`] both + /// map to [`crate::error_codes::THREAD_PRIORITY_NOT_ELEVATED`], which says the same thing + /// without a remedy that does not apply. The two are split on remedy rather than on severity + /// -- see those consts' own doc comments. + /// + /// **Where the record may be written, which is not where this is called.** Returning an + /// [`ErrorCode`] and formatting nothing is deliberate: an `ErrorCode` is four `&'static str`s + /// and a [`namir_core::Severity`], so obtaining one allocates nothing and is safe to do from + /// an audio callback -- which is exactly where both of this crate's callers invoke the + /// elevation, since a thread can only raise *its own* priority and neither `cpal`'s data + /// callback nor CLAP's `process()` runs on a thread the shell can reach beforehand. Emitting + /// the record is a different matter: FR-ERR-030 forbids logging, allocation and + /// logging-formatting on the audio thread, and `xtask rt-logging` fails the build if + /// `crates/namir-app/src/stream.rs` or `crates/namir-clap/src/audio.rs` so much as names the + /// logger. A caller must therefore carry the outcome off the audio thread -- it is `Copy` and + /// eight bytes, so an atomic or the shell's existing notice channel is enough -- and record it + /// from the main/UI thread, the way `namir-clap`'s `audio.rs` already routes its + /// unusable-sample-rate condition through `shared.rs`'s `push_notice`. + #[must_use] + pub fn diagnostic(self) -> Option { + match self { + ThreadPriorityOutcome::Elevated => None, + ThreadPriorityOutcome::PermissionDenied => Some(THREAD_PRIORITY_DENIED), + ThreadPriorityOutcome::OsError(_) | ThreadPriorityOutcome::Unsupported => { + Some(THREAD_PRIORITY_NOT_ELEVATED) + } + } + } +} + /// Raises the *calling* thread's OS scheduling priority to a level suitable for an audio callback /// thread. See this module's doc comment for exactly when a future caller should invoke this /// (once, at stream/process start, from the thread being elevated) and why a @@ -85,9 +176,14 @@ pub enum ThreadPriorityOutcome { /// Windows implements the actual real-time protection at the *process* priority class level /// (`REALTIME_PRIORITY_CLASS`), not at this thread-priority level, so this call is expected to /// succeed in the overwhelming majority of cases. -/// - **Linux/macOS:** `pthread_setschedparam` with `SCHED_FIFO` at that policy's maximum priority. -/// Requires a privilege (`CAP_SYS_NICE`) or resource limit (`rtprio`) the user may not have -/// configured -- see this module's doc comment for why that is an expected, non-fatal outcome. +/// - **Linux/macOS:** `pthread_setschedparam` with `SCHED_FIFO` at the policy's *minimum* priority +/// plus a fixed offset of 10 -- 11 on Linux, 25 on macOS -- deliberately not at the maximum, +/// which on Linux is 99 and outranks the kernel's own watchdog and IRQ threads (see +/// `unix::RT_PRIORITY_ABOVE_MIN`). Requires a privilege (`CAP_SYS_NICE`) or resource limit +/// (`rtprio`) the user may not have configured -- see this module's doc comment for why that is +/// an expected, non-fatal outcome. On macOS this raises the thread inside the timeshare band +/// and is *not* the `thread_policy_set` deadline contract CoreAudio-grade threads use; that +/// limitation is stated in full in this module's doc comment. /// - **Everything else:** [`ThreadPriorityOutcome::Unsupported`], unconditionally. pub fn elevate_current_thread_priority() -> ThreadPriorityOutcome { #[cfg(target_os = "windows")] @@ -160,7 +256,9 @@ mod windows { if code == ERROR_ACCESS_DENIED { ThreadPriorityOutcome::PermissionDenied } else { - ThreadPriorityOutcome::OsError(code as i32) + // `i64::from`, never `as i32`: `GetLastError` is a `u32` and its bit-31 codes must not + // be sign-mangled on the way into the outcome -- see `OsError`'s own doc comment. + ThreadPriorityOutcome::OsError(i64::from(code)) } } } @@ -183,9 +281,32 @@ mod unix { // vetted, widely-used binding removes that risk entirely; hand-rolling it to save one // dependency would trade a real soundness risk for a cosmetic win. use libc::{ - SCHED_FIFO, pthread_self, pthread_setschedparam, sched_get_priority_max, sched_param, + SCHED_FIFO, pthread_self, pthread_setschedparam, sched_get_priority_max, + sched_get_priority_min, sched_param, }; + /// How far above `SCHED_FIFO`'s own minimum this module elevates. **Deliberately not the + /// policy maximum**, which is the whole point of this constant existing. + /// + /// `sched_get_priority_max(SCHED_FIFO)` is 99 on Linux, and 99 is the band the kernel keeps + /// for its own supervision: the per-CPU `watchdog/N` and `migration/N` threads sit there, and + /// threaded IRQ handlers (`irq/N-*`) sit at 50. A userspace audio thread pinned at 99 that + /// spins, deadlocks or simply overruns its budget therefore outranks everything able to + /// notice and preempt it, and on a single CPU (or a thread pinned to one) the machine is + /// unrecoverable short of the NMI watchdog or the reset button. This is the standard + /// pro-audio footgun, and the reason JACK's default `rtprio` is 10 and PipeWire's `rt.prio` + /// stays well below the maximum rather than at it. + /// + /// `min + 10` resolves to **11 on Linux** (min = 1) and **25 on macOS** (min = 15, max = 47): + /// above every `SCHED_OTHER` thread on the system and above `PREEMPT_RT`'s softirq/timer + /// threads at 1, which is all an audio callback actually needs to beat, and comfortably below + /// both the IRQ-thread band and the watchdog band, which keep their ability to preempt it. + /// The offset is expressed relative to the policy minimum rather than as a bare 11 because + /// the two supported Unix targets do not share a numeric range at all (1..=99 versus + /// 15..=47), so a literal that is moderate on one would be near-maximal or invalid on the + /// other. + const RT_PRIORITY_ABOVE_MIN: i32 = 10; + pub(super) fn elevate() -> ThreadPriorityOutcome { // SAFETY: `sched_get_priority_max` takes a plain `c_int` policy constant and performs no // memory access; it cannot be unsound regardless of the argument's value (an invalid @@ -195,6 +316,15 @@ mod unix { return os_error_outcome(); } + // SAFETY: identical to `sched_get_priority_max` above -- a plain `c_int` argument, no + // memory access, `-1` on an unsupported policy. + let min_priority = unsafe { sched_get_priority_min(SCHED_FIFO) }; + if min_priority == -1 { + return os_error_outcome(); + } + + let target_priority = target_priority(min_priority, max_priority); + // Zero-initialised rather than built as a struct literal: `libc::sched_param` carries a // private padding field on Darwin (see the module-level comment above) that this crate // cannot name in a literal. Zero-initialising the whole struct and then writing only the @@ -207,7 +337,7 @@ mod unix { // pattern is a valid value of every field, so `mem::zeroed` cannot produce an invalid // `sched_param`. let mut param: sched_param = unsafe { core::mem::zeroed() }; - param.sched_priority = max_priority; + param.sched_priority = target_priority; // SAFETY: `pthread_self()` takes no arguments and returns an opaque thread identifier for // the calling thread by value -- no memory access, cannot be unsound. @@ -224,10 +354,24 @@ mod unix { } else if rc == libc::EPERM { ThreadPriorityOutcome::PermissionDenied } else { - ThreadPriorityOutcome::OsError(rc) + ThreadPriorityOutcome::OsError(i64::from(rc)) } } + /// The priority [`elevate`] asks for, given the policy's own bounds. Split out as a pure + /// function of two integers so it can be tested at both platforms' real ranges from a + /// sandbox that is only one of them -- reading a thread's applied priority back would need + /// `pthread_getschedparam`, and this crate's tests may carry no `unsafe` (D-5.3). + /// + /// `.min(max)` rather than `clamp`: `clamp` panics when its two bounds are inverted, and + /// nothing in this module may panic (this module's doc comment, last paragraph). A libc + /// reporting max < min would be broken beyond what a guard here could repair; taking the + /// maximum in that case still yields a value the policy accepts. `saturating_add` for the + /// same reason -- an absurd `min` must not wrap into a negative priority. + pub(super) fn target_priority(min: i32, max: i32) -> i32 { + min.saturating_add(RT_PRIORITY_ABOVE_MIN).min(max) + } + /// `sched_get_priority_max` reports failure via `-1` and sets `errno` (unlike /// `pthread_setschedparam`, which is a `pthread_*` function returning its error code /// directly) -- `std::io::Error::last_os_error` already wraps the platform-correct way to @@ -237,11 +381,52 @@ mod unix { if code == libc::EPERM { ThreadPriorityOutcome::PermissionDenied } else { - ThreadPriorityOutcome::OsError(code) + ThreadPriorityOutcome::OsError(i64::from(code)) } } } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(test)] +mod unix_priority_tests { + use super::unix::target_priority; + + /// Issue #75: the elevation must not land on `SCHED_FIFO`'s maximum, which on Linux is 99 -- + /// the band `watchdog/N` and `migration/N` occupy. Asserted at both supported ranges from + /// whichever one this test happens to run on, which is what makes `target_priority` a pure + /// function of its bounds rather than a lookup. + #[test] + fn the_target_priority_is_moderate_at_both_platforms_real_ranges() { + // Linux: 1..=99. + assert_eq!(target_priority(1, 99), 11); + assert!( + target_priority(1, 99) < 99, + "an audio thread at SCHED_FIFO 99 outranks the kernel threads that would preempt it" + ); + // macOS: 15..=47. + assert_eq!(target_priority(15, 47), 25); + assert!(target_priority(15, 47) < 47); + } + + /// Nothing in this module may panic (its doc comment's last paragraph), including on bounds + /// no real libc reports. + #[test] + fn degenerate_bounds_neither_panic_nor_overflow() { + assert_eq!(target_priority(1, 5), 5, "a narrow range clamps to its max"); + assert_eq!(target_priority(0, 0), 0); + assert_eq!( + target_priority(i32::MAX, i32::MAX), + i32::MAX, + "the offset must saturate rather than wrap into a negative priority" + ); + assert_eq!( + target_priority(10, 1), + 1, + "inverted bounds must not panic the way `clamp` would" + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/namir-platform/tests/logging.rs b/crates/namir-platform/tests/logging.rs index aada119..39d00c3 100644 --- a/crates/namir-platform/tests/logging.rs +++ b/crates/namir-platform/tests/logging.rs @@ -584,6 +584,40 @@ fn clause_6_the_namir_log_parser() { ); assert_eq!(logger.level(), LogLevel::Info); + // ...and it reaches the log at `error` too, where the level check would otherwise drop it + // (issue #79). LOG_BAD_LEVEL is a WARN and `error` admits only ERROR and FAULT, so a user who + // had chosen a quiet log *and* mistyped NAMIR_LOG used to be told nothing at all -- the one + // combination where the record matters most. The session record, an INFO, is correctly absent + // here: it is the bad-level record specifically that bypasses admission. + let scratch = Scratch::new("bad-level-at-error"); + let logger = Logger::new( + Some(scratch.sink()), + resolve_level(Some(OsStr::new("shout")), Some(LogLevel::Error)), + ); + assert_eq!(logger.level(), LogLevel::Error); + assert_eq!( + codes_in(&scratch.sink()), + vec!["platform.log.bad_level".to_owned()], + "a mistyped NAMIR_LOG must be reported even at a level that does not admit WARN" + ); + assert!( + read_lines(&scratch.sink())[0].contains("NAMIR_LOG=shout"), + "the forced record must still name the rejected value" + ); + + // The one level that keeps its silence: `off` promises the file is never opened or created, + // and a forced record would create a log the user switched off. + let scratch = Scratch::new("bad-level-at-off"); + let logger = Logger::new( + Some(scratch.sink()), + resolve_level(Some(OsStr::new("shout")), Some(LogLevel::Off)), + ); + assert_eq!(logger.level(), LogLevel::Off); + assert!( + !scratch.logs_dir().exists(), + "`off` must still create nothing on disk, bad-level record or not" + ); + // The module-level entry points exist and are safe to call before `init` has run -- a record // submitted during static initialisation must be a no-op, not a panic and not a logger // installed at the wrong level behind the shell's back. From ab68cd2a33d9fb8ff0ab5181b5958bc9e26f3f4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:43:31 +0000 Subject: [PATCH 12/44] Fix the gate's envelope detector, closing FR-GATE-020 (#123, #124) The detector was a 1 ms symmetric one-pole, so it tracked the waveform rather than its envelope: a rectified 82 Hz carrier rippled 9.54 dB peak-to-peak, wider than the 3 dB default hysteresis, so the ripple itself crossed the gap. Measured before: 62 close events at hold 0 ms, 61 at 1 ms -- both in-range settings per FR-GATE-010's table. It also mis-calibrated the threshold, a -70 dBFS setting first opening at -69.09 dBFS at 82 Hz and -66.50 at 1 kHz. Now a sliding-window maximum of |x|: instantaneous attack, and a release that is the age of the window rather than a time constant. Ripple is 0.00 dB at every frequency measured, and the threshold reads within 0.07 dB of its setting across 82 Hz to 5 kHz. Not the slow-release peak follower first proposed: an exponential release cannot be both slow enough for a 6.07 ms half-period (needs tau >= 17.5 ms for 3 dB) and fast enough to concede silence inside 10 ms, which namir-engine's hold test requires. A windowed max is flat and then drops, so it does both. Fixed-size array of 16 sub-block maxima -- O(1) per sample, no allocation, no data-dependent loop, so the RT harness still passes. #124: Opening now falls back to Closing when close goes true, mirroring the existing resumption. One -36 dBFS sample reaches gain 0.155 rather than 1.0 at a 50 ms attack. The test's stimulus had to change, and this is the part worth reviewing: its old falsifiers depended on the detector defect to produce chatter, since a decaying low E is monotonic at a peak detector's output. Keeping them would have meant asserting a bug. It now runs the requirement's own two scenarios -- a low E hovering at the threshold, then decaying through it -- and asserts one close event at every hold in the FRS range, at three sample rates, with the gap as the variable under test. Tag promoted back to plain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-dsp/src/gain_ramp.rs | 61 +++- crates/namir-dsp/src/gate.rs | 478 ++++++++++++++++++++++++------ 2 files changed, 450 insertions(+), 89 deletions(-) diff --git a/crates/namir-dsp/src/gain_ramp.rs b/crates/namir-dsp/src/gain_ramp.rs index 4269aa0..95598c3 100644 --- a/crates/namir-dsp/src/gain_ramp.rs +++ b/crates/namir-dsp/src/gain_ramp.rs @@ -36,16 +36,34 @@ pub struct GainRamp { } impl GainRamp { - /// Starts at unity gain (1.0 linear, 0 dB). `time_constant_ms` is clamped to + /// Starts settled at unity gain (1.0 linear, 0 dB). `time_constant_ms` is clamped to /// `MIN_TIME_CONSTANT_MS`; see `RECOMMENDED_TIME_CONSTANT_MS`'s doc for the FR-PARAM-040 /// constraint that should drive the caller's choice of value. + /// + /// A caller whose parameter does not default to 0 dB wants [`GainRamp::new_at_db`] instead — + /// see its doc for why `new` followed by `set_target_db` is not the same thing. pub fn new(sample_rate: SampleRate, time_constant_ms: f32) -> Self { + Self::new_at_db(sample_rate, time_constant_ms, 0.0) + } + + /// Starts **settled at** `db` rather than ramping to it: both the current and the target gain + /// are `db`, so the very first sample out is already at the intended level. + /// + /// This exists because `new` followed by `set_target_db(db)` is *not* that (issue #127). It + /// leaves `current` at unity and `target` at `db`, so the first ~25 ms of audio after every + /// start, sample-rate change or re-prepare is a ramp from 0 dB down to the parameter's actual + /// default. That is silent today only because the three parameters smoothed this way + /// (`trim.gain_db`, `out.gain_db`, `ir.level_db`) all happen to default to 0.0 dB, where the + /// ramp has nowhere to travel; change any one of those defaults and the artefact appears with + /// nothing failing. Constructing at the value removes the trap rather than documenting it. + pub fn new_at_db(sample_rate: SampleRate, time_constant_ms: f32, db: f32) -> Self { let time_constant_ms = time_constant_ms.max(MIN_TIME_CONSTANT_MS); let tau_samples = (time_constant_ms as f64 / 1000.0) * sample_rate.hz_f64(); let coeff = (1.0 - (-1.0 / tau_samples).exp()) as f32; + let gain = db_to_linear(db); Self { - current: 1.0, - target: 1.0, + current: gain, + target: gain, coeff, } } @@ -183,6 +201,43 @@ mod tests { ); } + /// **Issue #127.** `new_at_db` starts settled, so nothing ramps; the `new` + `set_target_db` + /// pair every current call site uses does not, and the contrast is the point of the type. + /// + /// The non-unity default is deliberate: at the 0.0 dB the three shipped call sites happen to + /// use today, both constructions are identical and this test would pass without the fix. + #[test] + fn constructing_at_a_level_settles_there_instead_of_ramping_from_unity() { + let default_db = -24.0f32; + let expected = db_to_linear(default_db); + + let mut settled = GainRamp::new_at_db(sr(48_000), RECOMMENDED_TIME_CONSTANT_MS, default_db); + assert!( + (settled.current_db() - default_db).abs() < 1e-4, + "current_db={} before processing a single sample", + settled.current_db() + ); + let mut buf = [1.0f32; 512]; + settled.process(&mut buf); + for (i, s) in buf.iter().enumerate() { + assert!( + (s - expected).abs() <= expected * 1e-3, + "sample {i} came out at {s}, not the {expected} the ramp was constructed at" + ); + } + + // The falsifier: the same intent expressed as `new` + `set_target_db` audibly ramps. + let mut ramping = GainRamp::new(sr(48_000), RECOMMENDED_TIME_CONSTANT_MS); + ramping.set_target_db(default_db); + let mut buf = [1.0f32; 512]; + ramping.process(&mut buf); + assert!( + buf[0] > expected * 10.0, + "constructing at unity and retargeting should start near 0 dB, got {}", + buf[0] + ); + } + #[test] fn process_does_not_allocate() { let mut ramp = GainRamp::new(sr(48_000), RECOMMENDED_TIME_CONSTANT_MS); diff --git a/crates/namir-dsp/src/gate.rs b/crates/namir-dsp/src/gate.rs index d647743..331e557 100644 --- a/crates/namir-dsp/src/gate.rs +++ b/crates/namir-dsp/src/gate.rs @@ -53,14 +53,54 @@ pub enum GateStatus { Closing, } -/// A short, fixed envelope-detector time constant, independent of the attack/release *gain* -/// ramp times, so threshold/hysteresis comparisons are stable rather than jittering on raw -/// sample values. Not a user-facing control. -const DETECTOR_TIME_CONSTANT_MS: f64 = 1.0; - -fn one_pole_coeff(time_constant_ms: f64, sample_rate_hz: f64) -> f32 { - let tau_samples = (time_constant_ms / 1000.0) * sample_rate_hz; - (1.0 - (-1.0 / tau_samples).exp()) as f32 +/// The envelope detector's look-back window, in milliseconds. Not a user-facing control. +/// +/// # Why a windowed peak and not a one-pole (issue #123) +/// +/// The detector's job is to report *the level of the note*, so that FR-GATE-010's threshold means +/// what its dBFS unit says and FR-GATE-020's hysteresis gap is compared against something that +/// does not itself move by more than the gap. A symmetric one-pole on `|x|` — what this was until +/// M14 — does neither. At a 1 ms time constant its output on an 82.41 Hz carrier (a standard-tuned +/// guitar's low E) swings **9.5 dB peak to peak**, three times the shipped 3 dB hysteresis, so the +/// ripple alone re-crossed the band: a decaying low E produced **62** close events at `hold_ms = 0` +/// where FR-GATE-020 demands one. It also settled on mean-`|x|` rather than peak, which made the +/// threshold frequency-dependent — a −70 dBFS threshold first opened at −69.1 dBFS peak for 82.41 +/// Hz but −66.5 dBFS for 1 kHz. +/// +/// This is instead a sliding-window maximum of `|x|`: instantaneous attack (a transient is never +/// missed) and a release that is not a time constant at all but the age of the window. Two +/// properties follow, and they are the two the one-pole lacked. +/// +/// *Calibration.* The reading is the true peak amplitude of the carrier, so a threshold in dBFS is +/// peak-referenced at every frequency, as FR-GATE-010's unit implies. +/// +/// *Ripple.* For a sine whose rectified half-period is `T` and a retained window of `W`, the +/// window always contains the peak when `W >= T`, so the reading is exactly flat. Below that it +/// dips, to a worst case of `-20*log10(sin(pi*W/(2*T)))` dB — 0 dB down to 66.7 Hz on the figures +/// below, 1.7 dB at a four-string bass's 41.2 Hz low E, and still under the 3 dB default +/// hysteresis for anything above about **33 Hz**. That is the whole audible fundamental range of +/// both instruments this product names. +/// +/// The window is also how long the gate takes to notice true silence, which is why it is short +/// rather than arbitrarily long: 8 ms here, of which at least +/// `DETECTOR_WINDOW_MS * (DETECTOR_BLOCKS - 1) / DETECTOR_BLOCKS` = 7.5 ms is retained at any +/// instant (see `DETECTOR_BLOCKS`). Attack, hold and release remain the user's own controls and +/// are unaffected; this constant only sets when the *detector* concedes that the note has stopped. +const DETECTOR_WINDOW_MS: f64 = 8.0; + +/// The window is carried as this many rolling sub-block maxima rather than a per-sample ring +/// buffer: the maximum of a fixed, small array is O(1) per sample with no allocation and no +/// data-dependent loop, which a monotonic-deque sliding maximum would not be. The cost is that +/// the retained look-back is not exactly `DETECTOR_WINDOW_MS` but varies over +/// `[(K-1)/K, 1] * DETECTOR_WINDOW_MS` as the current sub-block fills; the ripple figures in +/// `DETECTOR_WINDOW_MS`'s doc are quoted against the retained minimum, the worst case. +const DETECTOR_BLOCKS: usize = 16; + +/// Samples per detector sub-block, floored at 1 so a pathologically low sample rate cannot +/// produce a zero-length block (which would advance the window every sample and retain nothing). +fn detector_block_len(sample_rate_hz: f64) -> u32 { + let window_samples = DETECTOR_WINDOW_MS / 1000.0 * sample_rate_hz; + (window_samples / DETECTOR_BLOCKS as f64).round().max(1.0) as u32 } /// Rounds `ms` of `sample_rate_hz` to whole samples, floored at 1 — for attack/release times, @@ -87,9 +127,18 @@ pub struct NoiseGate { status: GateStatus, /// Current linear gain applied to the signal; 1.0 = fully open, 0.0 = fully closed. gain: f32, - /// Fast peak-follower envelope, see `DETECTOR_TIME_CONSTANT_MS`. + /// The detector's reading: the largest `|x|` anywhere in the retained window, i.e. the peak + /// amplitude of the note. See `DETECTOR_WINDOW_MS` for why this is a windowed maximum and not + /// a one-pole follower. envelope: f32, - detector_coeff: f32, + /// The window itself, as `DETECTOR_BLOCKS` rolling sub-block maxima; `envelope` is their + /// maximum. `block_index` is the sub-block currently being filled. + window_blocks: [f32; DETECTOR_BLOCKS], + block_index: usize, + /// Samples left before `block_index` advances and the oldest sub-block is discarded. + block_remaining: u32, + /// Samples per sub-block, fixed at construction with the sample rate. + block_len: u32, /// Per-sample gain increment while `Opening` (`1 / attack_samples`). attack_step: f32, /// Per-sample gain decrement while `Closing` (`1 / release_samples`). @@ -103,14 +152,17 @@ pub struct NoiseGate { impl NoiseGate { /// Builds a closed gate at `GateParams::default()`, fixed to `sample_rate` for its lifetime. pub fn new(sample_rate: SampleRate) -> Self { - let detector_coeff = one_pole_coeff(DETECTOR_TIME_CONSTANT_MS, sample_rate.hz_f64()); + let block_len = detector_block_len(sample_rate.hz_f64()); let mut gate = Self { sample_rate, params: GateParams::default(), status: GateStatus::Closed, gain: 0.0, envelope: 0.0, - detector_coeff, + window_blocks: [0.0; DETECTOR_BLOCKS], + block_index: 0, + block_remaining: block_len, + block_len, attack_step: 1.0, release_step: 1.0, hold_samples: 0, @@ -132,10 +184,33 @@ impl NoiseGate { self.params = params; } + /// Advances the sliding-window peak detector by one sample and returns its reading. Runs on + /// the audio thread: fixed-size state, no allocation, and the only loop is the fold over + /// `DETECTOR_BLOCKS` (a compile-time constant) once every `block_len` samples. + fn detect(&mut self, input_abs: f32) -> f32 { + if self.block_remaining == 0 { + self.block_index = (self.block_index + 1) % DETECTOR_BLOCKS; + self.window_blocks[self.block_index] = 0.0; + self.block_remaining = self.block_len; + // The oldest sub-block has just been discarded, so the running maximum has to be + // rebuilt from what is left rather than merely relaxed. + self.envelope = self.window_blocks.iter().copied().fold(0.0f32, f32::max); + } + self.block_remaining -= 1; + + let block = &mut self.window_blocks[self.block_index]; + if input_abs > *block { + *block = input_abs; + } + if input_abs > self.envelope { + self.envelope = input_abs; + } + self.envelope + } + /// Advances the state machine by one sample and returns the gain to apply to it. fn step(&mut self, input_abs: f32) -> f32 { - self.envelope += self.detector_coeff * (input_abs - self.envelope); - let env_db = linear_to_db(self.envelope); + let env_db = linear_to_db(self.detect(input_abs)); let open = env_db >= self.params.threshold_db; let close = env_db < self.params.threshold_db - self.params.hysteresis_db; @@ -146,11 +221,20 @@ impl NoiseGate { } } GateStatus::Opening => { - self.gain += self.attack_step; - if self.gain >= 1.0 { - self.gain = 1.0; - self.status = GateStatus::Open; - self.hold_remaining = None; + if close { + // The signal fell back through the hysteresis band mid-attack: turn around + // from wherever the gain currently is, mirroring the `Closing -> Opening` + // resumption below. Without this the attack ramp ran to completion no matter + // what the detector did afterwards, so a single isolated sample opened the + // gate fully and held it open for attack + hold + release (issue #124). + self.status = GateStatus::Closing; + } else { + self.gain += self.attack_step; + if self.gain >= 1.0 { + self.gain = 1.0; + self.status = GateStatus::Open; + self.hold_remaining = None; + } } } GateStatus::Open => match self.hold_remaining { @@ -215,6 +299,9 @@ impl NoiseGate { self.status = GateStatus::Closed; self.gain = 0.0; self.envelope = 0.0; + self.window_blocks = [0.0; DETECTOR_BLOCKS]; + self.block_index = 0; + self.block_remaining = self.block_len; self.hold_remaining = None; } } @@ -263,45 +350,77 @@ mod tests { /// E2, the low-E fundamental of a standard-tuned guitar: the lowest note the instrument /// ahead of this gate actually produces, and the hardest case for the detector, whose - /// rectified ripple grows as the carrier frequency falls towards its own time constant. + /// window has to span the rectified half-period of the carrier to read its peak. const LOW_E_HZ: f64 = 82.41; /// FR-GATE-010's hold range (0..500 ms): both ends, the default, and the values between where - /// hold and hysteresis trade off. The test below filters this list rather than spanning it, - /// and the two settings the filter drops are the subject of its `// uncovered:` field. + /// hold and hysteresis trade off. Every entry is asserted; the range is spanned, not filtered. const FRS_HOLD_MS: [f32; 8] = [0.0, 1.0, 5.0, 10.0, 30.0, 100.0, 250.0, 500.0]; - /// Counts transitions into `Closing` — FR-GATE-020's "close event" — while a low-E note - /// decaying from -10 dBFS to about -97 dBFS over 5 s passes down through the gate's - /// threshold (-70 dBFS) and its hysteresis band. + /// The amplitude wobble applied to the hovering phase of [`hovering_then_decaying_low_e`], in + /// dB either side of the threshold, and its rate. 2 dB peak to peak is narrower than the 3 dB + /// default hysteresis and wider than a 0 or 1 dB gap, which is exactly what makes the gap the + /// variable under test rather than a passenger. + const WOBBLE_DB: f32 = 1.0; + const WOBBLE_HZ: f64 = 8.0; + + /// FR-GATE-020's own two scenarios, back to back, as one stimulus: a low-E note that first + /// **hovers at the threshold** (the requirement's sentence: "to prevent chatter on a signal + /// hovering at the threshold") and then **decays through it** (its `Verify:` method: "a signal + /// decaying through the threshold shall produce exactly one close event"). /// - /// The carrier is the whole point. A bare decaying envelope, which is what this test used - /// before, is monotonic at the detector's output, so it crosses the close threshold once - /// whatever the parameters are: it reports one close event with the hysteresis removed - /// entirely, and so cannot falsify the requirement it was annotated for (issue #125). A real - /// note is a carrier, the detector sees its rectified ripple, and the ripple is what - /// hysteresis exists to bridge. - fn close_events_on_a_decaying_low_e(hold_ms: f32, hysteresis_db: f32) -> u32 { - let sample_rate = 48_000u32; + /// Phase 1, 2 s: the carrier's peak amplitude sits at `threshold_db` with a ±`WOBBLE_DB` + /// modulation at `WOBBLE_HZ` — a note held at the edge of the gate's threshold, which is the + /// only place chatter can happen at all. + /// Phase 2, 3 s: exponential decay from the threshold to about 30 dB below it, well clear of + /// even the widest hysteresis gap swept below. + /// + /// **Both the carrier and the wobble are load-bearing.** A bare decaying envelope, which is + /// what this test used before issue #125, is monotonic at any detector's output, so it crosses + /// the close threshold once whatever the parameters are and cannot falsify the requirement. A + /// bare *carrier* under a bare decay is monotonic too now that the detector reads its peak + /// (issue #123) — it was only the old one-pole detector's own 9.5 dB of rectified ripple that + /// made that stimulus chatter, i.e. the test would have been measuring a detector defect, not + /// hysteresis. Real program material hovering at a gate threshold is not monotonic: it wobbles, + /// and the wobble is what hysteresis exists to bridge. + fn hovering_then_decaying_low_e( + threshold_db: f32, + samples: usize, + sample_rate: u32, + ) -> impl Iterator { + let hover_samples = sample_rate as usize * 2; + let decay_tau = sample_rate as f64 * 0.8; // ~32 dB over the 3 s decay phase. + let radians_per_sample = 2.0 * std::f64::consts::PI * LOW_E_HZ / sample_rate as f64; + let wobble_per_sample = 2.0 * std::f64::consts::PI * WOBBLE_HZ / sample_rate as f64; + (0..samples).map(move |n| { + let wobble_db = WOBBLE_DB * (wobble_per_sample * n as f64).sin() as f32; + let decay_db = if n < hover_samples { + 0.0 + } else { + -8.685_889 * ((n - hover_samples) as f64 / decay_tau) as f32 + }; + let amplitude = namir_core::db_to_linear(threshold_db + wobble_db + decay_db); + amplitude * (radians_per_sample * n as f64).sin() as f32 + }) + } + + /// Counts transitions into `Closing` — FR-GATE-020's "close event" — over + /// [`hovering_then_decaying_low_e`]. + fn close_events(sample_rate: u32, hold_ms: f32, hysteresis_db: f32) -> u32 { let mut gate = NoiseGate::new(sr(sample_rate)); - gate.set_params(GateParams { + let params = GateParams { hold_ms, hysteresis_db, ..GateParams::default() - }); - - // Starts comfortably above the open threshold and, over the 5 s window, decays to well - // below the close threshold (threshold - hysteresis) — so the note actually crosses the - // hysteresis band once, rather than asymptoting to a level still above it. - let start_linear = namir_core::db_to_linear(-10.0); - let tau = (sample_rate as f64) * 0.5; // slow decay relative to detector/attack times. - let radians_per_sample = 2.0 * std::f64::consts::PI * LOW_E_HZ / sample_rate as f64; + }; + gate.set_params(params); let mut closing_transitions = 0u32; let mut prev_status = gate.status(); - for n in 0..sample_rate as usize * 5 { - let envelope = start_linear * (-(n as f64) / tau).exp() as f32; - let mut sample = [envelope * (radians_per_sample * n as f64).sin() as f32]; + for x in + hovering_then_decaying_low_e(params.threshold_db, sample_rate as usize * 5, sample_rate) + { + let mut sample = [x]; gate.process(&mut sample); if gate.status() == GateStatus::Closing && prev_status != GateStatus::Closing { closing_transitions += 1; @@ -311,53 +430,75 @@ mod tests { closing_transitions } - // trace-partial: FR-GATE-020 - // uncovered: FR-GATE-020 — the method ("exactly one close event") is asserted over - // uncovered: FR-GATE-010's hold range from 5 ms up. At 0 and 1 ms the same decaying low-E - // uncovered: note produces 62 and 61 close events with the shipped 3 dB gap: the 1 ms - // uncovered: detector ripples about 9 dB peak-to-peak on an 82 Hz carrier and the gap is - // uncovered: narrower than the ripple. That is a gate defect rather than a test gap — 12 dB - // uncovered: of hysteresis, or a detector whose release is slow relative to the lowest - // uncovered: program frequency, produces exactly one at every hold — so the two settings are - // uncovered: left unasserted rather than pinned to today's numbers; closes M8 + /// The carrier's peak amplitude, in dBFS, at the sample where `gate` first leaves `Closed` + /// (the open level) and where it first enters `Closing` afterwards (the close level). + /// + /// Driven by a slow symmetric ramp — 25 dB up over 2 s, then 25 dB back down — so that the + /// level *is* the instantaneous amplitude to within the detector's own window (8 ms at + /// 12.5 dB/s is 0.1 dB) and neither figure is an artefact of how fast the ramp moved. + fn open_and_close_levels_dbfs(gate: &mut NoiseGate, freq_hz: f64) -> (f32, f32) { + let sample_rate = 48_000u32; + let leg = sample_rate as usize * 2; + let radians_per_sample = 2.0 * std::f64::consts::PI * freq_hz / sample_rate as f64; + let (mut open_at, mut close_at) = (None, None); + for n in 0..leg * 2 { + let db = if n < leg { + -85.0 + 25.0 * (n as f32 / leg as f32) + } else { + -60.0 - 25.0 * ((n - leg) as f32 / leg as f32) + }; + let mut sample = + [namir_core::db_to_linear(db) * (radians_per_sample * n as f64).sin() as f32]; + gate.process(&mut sample); + if open_at.is_none() && gate.status() != GateStatus::Closed { + open_at = Some(db); + } + if open_at.is_some() && close_at.is_none() && gate.status() == GateStatus::Closing { + close_at = Some(db); + } + } + ( + open_at.expect("the gate never opened"), + close_at.expect("the gate never closed"), + ) + } + + // trace: FR-GATE-020 #[test] - fn hysteresis_prevents_chatter_on_a_decaying_low_e_note() { - // (a) The requirement's own method, over the hold settings it holds for. 5 ms is the - // shortest one, and at 5 ms it is hysteresis and not hold that carries it — see (b). - for hold_ms in FRS_HOLD_MS.into_iter().filter(|&ms| ms >= 5.0) { - let events = close_events_on_a_decaying_low_e(hold_ms, 3.0); - assert_eq!( - events, 1, - "hold {hold_ms} ms: expected exactly one transition into Closing, got {events}" - ); + fn hysteresis_prevents_chatter_on_a_low_e_note_hovering_at_the_threshold() { + // (a) The requirement's own `Verify:` method — "exactly one close event" — over the whole + // of FR-GATE-010's hold range including its 0 ms end, at the shipped 3 dB gap. Run at + // three sample rates as well: the requirement does not quantify over them, but the + // detector's window is now the mechanism that carries it and `detector_block_len` + // rounds that window to whole samples per rate. + for sample_rate in [44_100u32, 48_000, 96_000] { + for hold_ms in FRS_HOLD_MS { + let events = close_events(sample_rate, hold_ms, 3.0); + assert_eq!( + events, 1, + "{sample_rate} Hz, hold {hold_ms} ms: expected exactly one transition into \ + Closing, got {events}" + ); + } } - // (b) The falsifier for (a)'s shortest hold: with the hysteresis gap removed and nothing - // else changed, the same stimulus chatters. Without this the assertion above would - // rest on hold — every value from 10 ms up reports one close event at 0 dB of - // hysteresis, because a hold longer than the ripple period absorbs the ripple by - // itself, which is the second half of what made the old test unfalsifiable. - let without_hysteresis = close_events_on_a_decaying_low_e(5.0, 0.0); + // (b) The falsifier. With the hysteresis gap removed and nothing else changed, the same + // stimulus chatters — so (a) at its 0 ms hold is resting on hysteresis and on nothing + // else. (At the long end of the hold range hold would carry it too; that is what (a)'s + // 0 and 1 ms entries are for.) + let without_hysteresis = close_events(48_000, 0.0, 0.0); assert!( without_hysteresis > 1, - "hold 5 ms with no hysteresis should chatter, got {without_hysteresis} close events \ + "hold 0 ms with no hysteresis should chatter, got {without_hysteresis} close events \ — the assertion above is then resting on hold, not on hysteresis" ); - // (c) At the bottom of FR-GATE-010's hold range hysteresis is the only mechanism left, so - // this is where the requirement's own sentence — "the level at which the gate closes - // shall be measurably below the level at which it opens" — is what is measured. - // Widening the gap must reduce the chatter monotonically, and a gap wider than the - // detector's ripple must remove it entirely. + // (c) Widening the gap must reduce the chatter monotonically, and any gap wider than the + // wobble the note is hovering with must remove it entirely. let sweep: Vec<(f32, u32)> = [0.0f32, 1.0, 3.0, 6.0, 12.0, 24.0] .into_iter() - .map(|db| (db, close_events_on_a_decaying_low_e(0.0, db))) + .map(|db| (db, close_events(48_000, 0.0, db))) .collect(); - assert!( - sweep[0].1 > 1, - "hold 0 ms with no hysteresis should chatter, got {} close events", - sweep[0].1 - ); for pair in sweep.windows(2) { let ((narrow_db, narrow), (wide_db, wide)) = (pair[0], pair[1]); assert!( @@ -366,14 +507,179 @@ mod tests { count from {narrow} to {wide}" ); } - let (widest_db, widest) = *sweep.last().unwrap(); - assert_eq!( - widest, 1, - "hold 0 ms at {widest_db} dB of hysteresis — wider than the detector's ripple on this \ - carrier — should close exactly once, got {widest}" + for &(db, events) in sweep.iter().filter(|(db, _)| *db >= 2.0 * WOBBLE_DB) { + assert_eq!( + events, + 1, + "hold 0 ms at {db} dB of hysteresis — wider than the note's {} dB of wobble — \ + should close exactly once, got {events}", + 2.0 * WOBBLE_DB + ); + } + + // (d) The requirement's normative sentence, measured directly rather than inferred from a + // chatter count: "the level at which the gate closes shall be measurably below the + // level at which it opens". The gap is `hysteresis_db` by construction, so this also + // pins that the parameter is what sets it. + for hysteresis_db in [3.0f32, 6.0, 12.0] { + let mut gate = NoiseGate::new(sr(48_000)); + gate.set_params(GateParams { + hold_ms: 0.0, + hysteresis_db, + ..GateParams::default() + }); + let (open_db, close_db) = open_and_close_levels_dbfs(&mut gate, LOW_E_HZ); + assert!( + close_db < open_db, + "close level {close_db:.2} dBFS is not below the open level {open_db:.2} dBFS" + ); + let measured_gap = open_db - close_db; + assert!( + (measured_gap - hysteresis_db).abs() < 0.5, + "hysteresis_db = {hysteresis_db}: opened at {open_db:.2} dBFS and closed at \ + {close_db:.2} dBFS, a gap of {measured_gap:.2} dB" + ); + } + } + + /// **Issue #123's second symptom.** FR-GATE-010 specifies Threshold in dBFS, a peak-referenced + /// unit, so the level at which a given threshold opens the gate must not depend on the + /// frequency of the note. The one-pole detector this replaced settled on mean-`|x|`, so a −70 + /// dBFS threshold first opened at −69.1 dBFS peak for a low E but −66.5 dBFS for 1 kHz — a + /// 2.6 dB spread across the instrument's range. + #[test] + fn the_threshold_is_peak_referenced_at_every_program_frequency() { + let mut levels = Vec::new(); + for freq_hz in [LOW_E_HZ, 110.0, 196.0, 440.0, 1000.0, 5000.0] { + let mut gate = NoiseGate::new(sr(48_000)); + let (open_db, _) = open_and_close_levels_dbfs(&mut gate, freq_hz); + assert!( + (open_db - GateParams::default().threshold_db).abs() < 0.5, + "{freq_hz} Hz: a -70 dBFS threshold first opened at {open_db:.2} dBFS peak" + ); + levels.push(open_db); + } + let spread = levels.iter().cloned().fold(f32::MIN, f32::max) + - levels.iter().cloned().fold(f32::MAX, f32::min); + assert!( + spread < 0.5, + "the open level varies by {spread:.2} dB across the instrument's range: {levels:?}" ); } + /// **Issue #123's first symptom, at the detector rather than through the state machine.** The + /// reading on a steady carrier must be flat: any ripple is a level the hysteresis gap has to + /// bridge before it can do the job FR-GATE-020 asks of it. The one-pole this replaced rippled + /// 9.5 dB peak to peak on a low E, three times the default gap. + /// + /// The bass frequencies are below what `DETECTOR_WINDOW_MS`'s retained window spans, so they + /// are asserted against that constant's own worst-case formula rather than at zero. + #[test] + fn the_detector_reads_a_steady_carrier_flat_across_the_instrument_range() { + let sample_rate = 48_000u32; + // The window's retained minimum — see `DETECTOR_BLOCKS`. + let retained_s = + DETECTOR_WINDOW_MS / 1000.0 * (DETECTOR_BLOCKS - 1) as f64 / DETECTOR_BLOCKS as f64; + + for freq_hz in [30.87f64, 41.2, LOW_E_HZ, 110.0, 440.0, 1000.0] { + let mut gate = NoiseGate::new(sr(sample_rate)); + let amplitude = namir_core::db_to_linear(-20.0); + let radians_per_sample = 2.0 * std::f64::consts::PI * freq_hz / sample_rate as f64; + let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY); + for n in 0..sample_rate as usize { + gate.step((amplitude * (radians_per_sample * n as f64).sin() as f32).abs()); + // Skip the first half second, while the window is still filling. + if n > sample_rate as usize / 2 { + let reading = linear_to_db(gate.envelope); + lo = lo.min(reading); + hi = hi.max(reading); + } + } + + let half_period_s = 0.5 / freq_hz; + let predicted = if retained_s >= half_period_s { + 0.0 + } else { + let ratio = std::f64::consts::PI * retained_s / (2.0 * half_period_s); + -20.0 * ratio.sin().log10() + }; + let ripple = (hi - lo) as f64; + assert!( + ripple <= predicted + 0.2, + "{freq_hz} Hz: detector ripples {ripple:.2} dB peak to peak, above the \ + {predicted:.2} dB DETECTOR_WINDOW_MS predicts" + ); + // Every frequency at or above a four-string bass's low E must stay inside the default + // hysteresis gap, or FR-GATE-020 cannot hold there. + if freq_hz >= 41.2 { + assert!( + ripple < GateParams::default().hysteresis_db as f64, + "{freq_hz} Hz: detector ripples {ripple:.2} dB, at or beyond the default \ + {} dB hysteresis gap", + GateParams::default().hysteresis_db + ); + } + } + } + + /// **Issue #124.** A single sample into otherwise silent input reads well above the threshold + /// at the detector — a peak detector's whole point is that it does — and `Opening` used to ramp + /// unconditionally to unity once entered, ignoring the detector for the rest of the attack. At + /// the 50 ms maximum attack the gate reached a gain of exactly **1.000** and passed signal for + /// **180 ms** (attack + hold + release) off one sample. It now turns the ramp around when the + /// detector's reading falls back through the hysteresis band, so the gain only ever gets as far + /// as the signal justified: `DETECTOR_WINDOW_MS / attack_ms`, measuring 0.155 at 50 ms. + /// + /// Attacks are swept from where that bound bites. Below about 8 ms the ramp finishes inside the + /// detector's own window, so the gate does open — correctly: a −36 dBFS sample is 34 dB above + /// the −70 dBFS threshold, a transient a 1 ms attack exists to catch, and how long it then + /// stays open is Hold and Release, which are the user's own controls and not this defect. + #[test] + fn an_isolated_sample_does_not_run_the_attack_ramp_to_unity() { + for attack_ms in [10.0f32, 25.0, 50.0] { + let mut gate = NoiseGate::new(sr(48_000)); + gate.set_params(GateParams { + attack_ms, + ..GateParams::default() + }); + + let mut probe = vec![0.0f32; 48_000]; + probe[10] = namir_core::db_to_linear(-36.0); + + let mut max_gain = 0.0f32; + let mut passing = 0usize; + for x in &probe { + let gain = gate.step(x.abs()); + max_gain = max_gain.max(gain); + if gain > 0.0 { + passing += 1; + } + } + + let bound = (DETECTOR_WINDOW_MS as f32 / attack_ms) * 1.05; + assert!( + max_gain <= bound, + "attack {attack_ms} ms: one isolated sample took the gate to a gain of \ + {max_gain:.4}, past the {bound:.4} its own detector window justifies" + ); + assert!( + max_gain < 1.0, + "attack {attack_ms} ms: one isolated sample opened the gate fully" + ); + assert_eq!( + gate.status(), + GateStatus::Closed, + "attack {attack_ms} ms: gate did not return to Closed" + ); + // 180 ms is what the unconditional ramp cost at 50 ms of attack. + let open_ms = passing as f64 / 48.0; + assert!( + open_ms < 90.0, + "attack {attack_ms} ms: the gate passed signal for {open_ms:.1} ms off one sample" + ); + } + } + /// Drives `input` through `gate` in `block`-sample calls and returns the gain that was /// actually applied to each sample, recovered as `output / input`. Exact, and the reason every /// caller below keeps its input non-zero everywhere: a gate driven with digital silence From e594a6d51002c01a26df0bd7059b216a8ddf9e57 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:44:25 +0000 Subject: [PATCH 13/44] Engine handover, bypass and block validation (#56-#63) #56: nothing re-tested self.retired, so a handover deferred by a full retire pen was never finalized -- the stage stayed bypassed for the rest of the session (mix_target stuck at 0.0). Finalization is retried at the top of each block. #57: split now raises retire capacity to the per-offer headroom rather than erroring, keeping its infallible signature that both shells call. The covering test really did prove nothing: with retire: 1 restored, its run is byte-identical to an engine that never loaded anything -- peak difference 0.0. It now asserts that difference, plus a strong_count that counts the engine's own reference rather than only the test's. #58: bypass compensation read a latency frozen at prepare. Fixed-capacity delay lines sized at prepare replace the per-channel VecDeques, and process reads the current latency per block -- no allocation, one modulo per block. Correction to the issue: under global bypass the Nam stage never runs, so the handover never completes and the reported latency does not move; the misalignment materialises on the load-then-bypass path, which the new probe drives with a 44.1 kHz model in a 48 kHz engine. #59: the delay ring is now fed on both paths, so engaging bypass emits the real signal instead of stale content. #60: process validates the block against the PrepareContext. A check, not the debug_assert suggested -- an assert is loud where the caller is a test and absent where a host runs, which is backwards for D-16.3 and leaves the release fallback untestable. Both profiles now behave alike. The panic came one stage earlier than the issue says: gate.rs, which runs first. #61: the ceiling clamp no longer runs when bypassed. The NaN scan still does, pinned by its own test. FR-CHAIN-030's null test is unchanged and its plain tag is now more defensible, the method no longer failing above 0 dBFS. #62: retire headroom is reserved across the drain rather than checked once. #63: deferred_blocks counts only when a command is actually waiting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-engine/src/chain.rs | 475 +++++++++++++++++++++--- crates/namir-engine/src/chain_probes.rs | 69 ++++ crates/namir-engine/src/engine.rs | 365 +++++++++++++++++- crates/namir-engine/src/stages/ir.rs | 115 +++++- crates/namir-engine/src/stages/nam.rs | 233 ++++++++++-- 5 files changed, 1139 insertions(+), 118 deletions(-) diff --git a/crates/namir-engine/src/chain.rs b/crates/namir-engine/src/chain.rs index 3132e99..ef70103 100644 --- a/crates/namir-engine/src/chain.rs +++ b/crates/namir-engine/src/chain.rs @@ -1,5 +1,3 @@ -use std::collections::VecDeque; - use namir_params::global::{GLOBAL_BYPASS, OUTPUT_CEILING_DB}; use crate::command::RetireSink; @@ -57,46 +55,122 @@ pub struct Chain { cross_cutting: Option, } +/// Ceiling on the bypass-compensation delay [`Chain::prepare_crosscutting`] pre-sizes each +/// channel's line to, expressed in milliseconds of the engine rate. +/// +/// **Why a ceiling at all (issue #58).** The chain's latency is not fixed at preparation: a NAM +/// model whose declared rate differs from the engine's engages `stages/nam.rs`'s `SlotResampler` +/// the moment it is installed, and FR-CLAP-040 names exactly that as a runtime latency change. +/// The compensation therefore has to track `Chain::latency_samples()` *per block*, and the only +/// way to do that without allocating on the audio thread (P1) is to allocate once, generously, +/// for a latency the chain will not exceed. +/// +/// 250 ms is that figure. The largest latency anything in the 1.0 chain can report is one +/// `SlotResampler`'s (a few hundred samples — 640 for a 44.1 kHz model in a 48 kHz engine, the +/// configuration `chain_probes.rs` measures), so this is roughly two orders of magnitude of +/// headroom; a chain whose latency exceeded a quarter of a second would be unusable as a live +/// amp simulator long before this line ran out. A latency above the ceiling is clamped rather +/// than allowed to allocate or panic (D-16.3) — see [`DelayLine::run`]. +const MAX_BYPASS_COMPENSATION_MS: f64 = 250.0; + +/// One channel's bypass-compensation delay: a fixed-capacity circular buffer, written on **every** +/// block (both paths — see [`CrossCuttingState::run_delay`]) and read back `delay` samples late +/// only while bypass is engaged. +/// +/// A circular `Vec` rather than the `VecDeque` this used to be, because the delay is now a +/// per-block input rather than a constant fixed at preparation: a `VecDeque` expresses "delay by +/// exactly its own length", so changing the delay would mean resizing it, which is an allocation +/// on the audio thread. Indexing a buffer whose length is the *maximum* delay expresses any delay +/// up to that maximum at no cost. +struct DelayLine { + /// Capacity is `max delay + 1`, so the read index can trail the write index by the maximum + /// delay without colliding with it. Never resized after construction. + buf: Vec, + /// Where the next sample will be written. + write: usize, +} + +impl DelayLine { + /// **Not RT-safe** (allocates once, at preparation). + fn new(capacity: usize) -> Self { + Self { + buf: vec![0.0; capacity.max(1)], + write: 0, + } + } + + /// Pushes every sample of `channel` into the line, in order, and — when `emit_delayed` — also + /// replaces each with the sample written `delay` positions earlier. + /// + /// **RT-safe:** no allocation, no branch whose bound depends on anything but `channel.len()`, + /// and one modulo for the whole block rather than one per sample. A `delay` above what this + /// line was sized for is clamped rather than allowed to index out of bounds (D-16.3: degrade, + /// don't panic on the audio thread); see [`MAX_BYPASS_COMPENSATION_MS`] for why that cannot + /// happen for any chain this project ships. + fn run(&mut self, channel: &mut [f32], delay: usize, emit_delayed: bool) { + let cap = self.buf.len(); + let delay = delay.min(cap - 1); + let mut write = self.write; + // Trails `write` by `delay`, so the value read at each step is the one written `delay` + // steps ago. At `delay == 0` the two indices coincide and the read would be stale by a + // whole buffer — hence the `delay > 0` guard below; a zero-delay bypass wants the input + // unchanged anyway. + let mut read = (write + cap - delay) % cap; + for sample in channel.iter_mut() { + let delayed = self.buf[read]; + self.buf[write] = *sample; + if emit_delayed && delay > 0 { + *sample = delayed; + } + write += 1; + if write == cap { + write = 0; + } + read += 1; + if read == cap { + read = 0; + } + } + self.write = write; + } +} + /// Non-RT-allocated state that only exists once [`Chain::prepare_crosscutting`] has run: -/// FR-CHAIN-030's per-channel latency-compensation ring for the bypass path. Sized once, off the -/// audio thread, to exactly `latency_samples` per channel — `process` only ever pops one sample -/// and pushes one sample per input sample, so the ring's length never moves outside -/// `[0, latency_samples]`, and it therefore never needs to grow (P1). +/// FR-CHAIN-030's per-channel latency-compensation delay for the bypass path, and the +/// [`PrepareContext`](crate::prepare::PrepareContext) the chain was prepared against, which is +/// what lets [`crate::AudioEngine::process`] check the block it is handed instead of trusting it +/// (issue #60). struct CrossCuttingState { - /// One ring per channel (`ctx.channel_config().output_channels()` many — `stage_io.rs`'s own + /// One line per channel (`ctx.channel_config().output_channels()` many — `stage_io.rs`'s own /// doc comment: `StageIo`'s channel count is fixed for the whole chain to that figure). - /// Empty (zero-capacity, never touched) when `latency_samples == 0`; see `apply_bypass`. - delay_rings: Vec>, - /// Cached copy of `Chain::latency_samples()` as it stood when `prepare_crosscutting` ran, so - /// `apply_bypass` doesn't need to re-walk `stages` (and doesn't have to borrow `stages` - /// alongside `cross_cutting`) on every block. - latency_samples: u32, + delay_lines: Vec, + /// The context `prepare_crosscutting` was called with. See [`Chain::prepared_for`]. + prepared_for: crate::prepare::PrepareContext, } impl CrossCuttingState { - /// FR-CHAIN-030's bypass path: "input routed to output at unity gain, with only the latency - /// compensation needed for sample alignment." Pop-then-push, not the more literal - /// push-then-pop: a FIFO's oldest element is unaffected by what gets appended after it, so - /// the value released is identical either way, but popping first means the ring's length - /// only ever dips to `latency_samples - 1` and returns to `latency_samples` — it never - /// touches `latency_samples + 1`, so the `VecDeque` `prepare_crosscutting` sized can never - /// need to grow (P1). - fn apply_bypass(&mut self, io: &mut StageIo<'_>) { - if self.latency_samples == 0 { - // Nothing to compensate for: leaving the buffer untouched already *is* "input routed - // to output at unity gain" with zero latency (prepare_crosscutting's doc comment). + /// FR-CHAIN-030's bypass path, and its always-on other half. + /// + /// **Every block feeds the line, whether bypass is engaged or not (issue #59).** Writing it + /// only while bypassed left it holding whatever the *last* bypass period ended with (zeros, + /// the first time), so engaging bypass emitted `delay` samples of stale content followed by a + /// hard discontinuity, and disengaging dropped the same number of samples — a click at both + /// ends of every transition, which is exactly what FR-CLAP-060 forbids. Feeding it always + /// costs one pass over the block on the non-bypassed path (nothing at all when the chain + /// reports zero latency, which is the whole of 1.0 with no resampled model loaded) and makes + /// the transition sample-accurate in both directions. + /// + /// `delay` is read from the chain's *current* `latency_samples()` on every block rather than + /// cached at preparation, so a model change that alters the reported latency (FR-CLAP-040) + /// moves the compensation with it — issue #58. + fn run_delay(&mut self, io: &mut StageIo<'_>, delay: usize, bypassed: bool) { + if delay == 0 && !bypassed { + // Nothing to record and nothing to emit: the line can only ever hand back what it is + // given, so skipping it is not a state divergence. return; } - for (ring, channel) in self.delay_rings.iter_mut().zip(io.channels_mut()) { - for sample in channel.iter_mut() { - // Prefilled with `latency_samples` zeros by `prepare_crosscutting`, so this - // `unwrap_or` only ever falls back to 0.0 in principle, never in practice — kept - // as a fallback rather than `.unwrap()` so a future bug here degrades to silence - // instead of a panic on the audio thread (D-16.3). - let delayed = ring.pop_front().unwrap_or(0.0); - ring.push_back(*sample); - *sample = delayed; - } + for (line, channel) in self.delay_lines.iter_mut().zip(io.channels_mut()) { + line.run(channel, delay, bypassed); } } @@ -108,7 +182,23 @@ impl CrossCuttingState { /// zero is already within any ceiling, so there is nothing left to clamp. Otherwise clamps /// every sample's magnitude to `ceiling_linear`, sign preserved via `f32::clamp`'s own /// symmetric-range behaviour. - fn scan_and_clamp(&mut self, io: &mut StageIo<'_>, ceiling_linear: f32, fault_count: &mut u64) { + /// + /// **`apply_ceiling` is false on the bypass path (issue #61).** FR-CHAIN-090 is a statement + /// about "the output stage"; FR-CHAIN-030 is a statement about a path that does not run the + /// output stage at all, and its own `Verify:` method — bypassed output minus delayed input is + /// silence to within −120 dBFS — is simply false above 0 dBFS if the default ceiling clamps + /// the bypassed signal. The two requirements collide only on the bypass path, and + /// FR-CHAIN-030 wins there because "routes input to output with unity gain" leaves no room + /// for a gain of anything else. The NaN scan still runs: fault containment (FR-CHAIN-080) is + /// about not sending a damaging non-finite sample to hardware, which the bypass path can do + /// just as easily as the stage path. + fn scan_and_clamp( + &mut self, + io: &mut StageIo<'_>, + ceiling_linear: f32, + apply_ceiling: bool, + fault_count: &mut u64, + ) { let faulted = io .channels_mut() .any(|channel| channel.iter().any(|s| !s.is_finite())); @@ -119,6 +209,9 @@ impl CrossCuttingState { *fault_count += 1; return; } + if !apply_ceiling { + return; + } for channel in io.channels_mut() { for sample in channel.iter_mut() { *sample = sample.clamp(-ceiling_linear, ceiling_linear); @@ -171,22 +264,33 @@ impl Chain { /// dBFS, and must keep doing so unmodified). pub fn prepare_crosscutting(&mut self, ctx: &crate::prepare::PrepareContext) { let channel_count = ctx.channel_config().output_channels() as usize; - let latency_samples = self.latency_samples(); - let delay_rings = (0..channel_count) - .map(|_| { - // Zero-capacity when latency is 0: `apply_bypass` special-cases that to a no-op - // and never touches the ring, so there is nothing worth preallocating. - let mut ring = VecDeque::with_capacity(latency_samples as usize); - ring.resize(latency_samples as usize, 0.0); - ring - }) + // Sized to the ceiling, not to today's `latency_samples()` (issue #58): with nothing + // loaded that figure is 0, and installing a resampled model raises it *after* this call + // has returned. `max` rather than a bare conversion so a chain that somehow already + // reports more than the ceiling still gets a line long enough for it. + let ceiling = + (ctx.sample_rate().hz_f64() * MAX_BYPASS_COMPENSATION_MS / 1000.0).ceil() as usize; + let capacity = ceiling.max(self.latency_samples() as usize) + 1; + let delay_lines = (0..channel_count) + .map(|_| DelayLine::new(capacity)) .collect(); self.cross_cutting = Some(CrossCuttingState { - delay_rings, - latency_samples, + delay_lines, + prepared_for: *ctx, }); } + /// The [`PrepareContext`](crate::prepare::PrepareContext) this chain was prepared against, or + /// `None` on a chain built through [`Chain::new`] alone (see `prepare_crosscutting`'s doc + /// comment for why that path is deliberately raw). + /// + /// Exists so [`crate::AudioEngine::process`] can check the `StageIo` it is handed against the + /// block size and channel count every stage sized its buffers to, rather than trusting a + /// caller and panicking inside a stage when the trust is misplaced (issue #60). + pub fn prepared_for(&self) -> Option { + self.cross_cutting.as_ref().map(|cc| cc.prepared_for) + } + /// FR-CHAIN-030: turns the chain-wide bypass on or off. RT-safe — flips one `bool`, nothing /// else — so this may be called from the audio thread's own command-handling path as well as /// from setup code. @@ -235,17 +339,22 @@ impl Chain { /// prepared for cross-cutting skips all of that and behaves exactly as before this feature /// existed. pub fn process(&mut self, io: &mut StageIo<'_>) { - if self.global_bypass { - if let Some(cross_cutting) = self.cross_cutting.as_mut() { - cross_cutting.apply_bypass(io); - } else { - // No ring to bypass through (prepare_crosscutting was never called): today's - // behaviour, unchanged. See set_global_bypass's doc comment. - for stage in &mut self.stages { - stage.process(io); - } - } - } else { + // Read *this block's* latency rather than a figure cached at preparation (issue #58): + // installing a model whose declared rate differs from the engine's raises it mid-session, + // which is the runtime change FR-CLAP-040 names. Six `Stage::latency_samples()` calls, + // each a field read behind a vtable — cheap enough to pay per block, and the alternative + // is a compensation that silently stops matching what the host was told. + let latency = self.latency_samples() as usize; + let bypassed = self.global_bypass; + + let prepared = self.cross_cutting.is_some(); + if let Some(cross_cutting) = self.cross_cutting.as_mut() { + // Runs on both paths — see `run_delay`'s doc comment (issue #59). + cross_cutting.run_delay(io, latency, bypassed); + } + if !bypassed || !prepared { + // No line to bypass through (prepare_crosscutting was never called): today's + // behaviour, unchanged. See set_global_bypass's doc comment. for stage in &mut self.stages { stage.process(io); } @@ -253,7 +362,9 @@ impl Chain { if let Some(cross_cutting) = self.cross_cutting.as_mut() { let ceiling_linear = self.output_ceiling_linear; - cross_cutting.scan_and_clamp(io, ceiling_linear, &mut self.fault_count); + // The ceiling is an output-stage statement and the bypass path does not run the + // output stage; the NaN scan applies to both. See `scan_and_clamp` (issue #61). + cross_cutting.scan_and_clamp(io, ceiling_linear, !bypassed, &mut self.fault_count); } } @@ -755,6 +866,249 @@ mod tests { assert!((out[3] - (-0.1)).abs() < 1e-5); } + // --- Issues #58/#59/#61: the bypass path's three defects, one test each. All three are + // about the *same* delay line, so they share `VariableLatency` and `run_blocks` below. --- + + /// Id `VariableLatency` answers to. Any value `Chain::apply` does not recognise itself is + /// broadcast to every stage, so this needs only to differ from the two chain-level ids. + const LATENCY_PARAM_ID: ParamId = ParamId(4242); + + /// A stage whose *declared* latency changes at runtime, which is what `NamStage` does the + /// moment a model whose declared rate differs from the engine's is installed (FR-CLAP-040, + /// `stages/nam.rs`'s `SlotResampler`). `process` is a no-op, so anything the output shows can + /// only have come from the chain's own compensation. + struct VariableLatency { + latency: u32, + } + + impl Stage for VariableLatency { + fn process(&mut self, _io: &mut StageIo<'_>) {} + fn reset(&mut self) {} + fn latency_samples(&self) -> u32 { + self.latency + } + fn tail_samples(&self) -> u32 { + 0 + } + fn apply(&mut self, change: ParamChange) { + if change.id == LATENCY_PARAM_ID { + self.latency = change.value as u32; + } + } + fn telemetry(&self, _out: &mut TelemetrySink<'_>) {} + } + + /// Drives `input` through `chain` in `block`-frame blocks inside the RT harness, calling + /// `at_block` before each one so a test can flip bypass or a parameter mid-stream. + fn run_blocks( + chain: &mut Chain, + input: &[f32], + block: usize, + mut at_block: impl FnMut(usize, &mut Chain), + ) -> Vec { + let mut out = Vec::with_capacity(input.len()); + for (i, chunk) in input.chunks(block).enumerate() { + at_block(i, chain); + let mut buf = chunk.to_vec(); + { + let mut channels: [&mut [f32]; 1] = [&mut buf]; + let mut io = StageIo::new(&mut channels, chunk.len()); + audio_section(|| chain.process(&mut io)); + } + out.extend_from_slice(&buf); + } + out + } + + /// **Issue #58.** `CrossCuttingState` used to cache `Chain::latency_samples()` at + /// `prepare_crosscutting` and size a `VecDeque` to exactly that. `build_default_chain` calls + /// that once, with nothing loaded, so the cached figure is always 0 — and + /// `NamStage::latency_samples()` becomes nonzero later, the moment a model at a different + /// declared rate is installed, which FR-CLAP-040 names explicitly as a runtime latency change. + /// The chain then reported a nonzero latency to the host while compensating for none of it. + /// + /// Committed red-first: before the fix the assertion below fails on the very first compared + /// sample, because the bypassed output is the *undelayed* input. + #[test] + fn bypass_compensation_follows_a_latency_change_made_after_prepare() { + const BLOCK: usize = 16; + const LATENCY: usize = 5; + const CHANGE_AT: usize = 2; + + let mut chain = Chain::new(vec![Box::new(VariableLatency { latency: 0 })]); + chain.prepare_crosscutting(&ctx()); + chain.set_global_bypass(true); + assert_eq!( + chain.latency_samples(), + 0, + "the line is sized while the chain still reports zero -- that is the whole setup" + ); + + // A ramp: every sample distinct, so a misalignment of even one sample is visible. + let input: Vec = (0..BLOCK * 8).map(|n| 0.001 * n as f32).collect(); + let output = run_blocks(&mut chain, &input, BLOCK, |i, chain| { + if i == CHANGE_AT { + chain.apply(ParamChange { + id: LATENCY_PARAM_ID, + value: LATENCY as f32, + }); + } + }); + + assert_eq!(chain.latency_samples(), LATENCY as u32); + for n in CHANGE_AT * BLOCK..input.len() { + let expected = input[n - LATENCY]; + assert!( + (output[n] - expected).abs() < 1e-6, + "sample {n}: bypassed output {} against an input delayed by the {LATENCY} samples \ + the chain now reports ({expected})", + output[n] + ); + } + } + + /// **Issue #59.** The delay line used to be written only while bypass was engaged, so it held + /// whatever the *last* bypass period ended with — zeros, the first time. Engaging bypass then + /// emitted `latency_samples` of that stale content followed by a hard discontinuity, and + /// disengaging dropped the same number of samples: a click at both ends of every transition, + /// which is exactly what FR-CLAP-060 ("sample-accurate and click-free, equivalent to + /// FR-CHAIN-030") forbids. + /// + /// Three phases, because the third is what proves the fix rather than merely restating it: + /// bypass off (the line must be filling), bypass on (the first `LATENCY` samples must be the + /// last `LATENCY` samples of the *previous, unbypassed* block), bypass off again, then on + /// again (the line must still be coherent across a period it was not being read from). + /// + /// Committed red-first: before the fix, phase two's first three samples are 0.0. + #[test] + fn engaging_bypass_emits_the_real_signal_rather_than_stale_ring_content() { + const BLOCK: usize = 8; + const LATENCY: usize = 3; + + // `ConstantTail::process` is a no-op, so the unbypassed path is an exact passthrough and + // every difference between the two paths is the compensation line alone. + let mut chain = Chain::new(vec![Box::new(ConstantTail { + latency: LATENCY as u32, + tail: 0, + })]); + chain.prepare_crosscutting(&ctx()); + + let input: Vec = (0..BLOCK * 4).map(|n| 0.01 * (n + 1) as f32).collect(); + let output = run_blocks(&mut chain, &input, BLOCK, |i, chain| { + // off, on, off, on. + chain.set_global_bypass(i % 2 == 1); + }); + + // Phase 0 (bypass off): a no-op stage passes the input straight through. + assert_eq!(&output[..BLOCK], &input[..BLOCK]); + // Phase 1 (bypass on): delayed by LATENCY, and the samples that delay reaches back for + // are real input from phase 0 -- not the zeros a line written only while bypassed holds. + for n in BLOCK..2 * BLOCK { + assert!( + (output[n] - input[n - LATENCY]).abs() < 1e-6, + "sample {n}: engaging bypass emitted {} instead of the input delayed by \ + {LATENCY} ({})", + output[n], + input[n - LATENCY] + ); + } + // Phase 2 (bypass off again): passthrough once more. + assert_eq!(&output[2 * BLOCK..3 * BLOCK], &input[2 * BLOCK..3 * BLOCK]); + // Phase 3 (bypass on again): the line stayed coherent through a period nothing read it. + for n in 3 * BLOCK..4 * BLOCK { + assert!( + (output[n] - input[n - LATENCY]).abs() < 1e-6, + "sample {n}: re-engaging bypass emitted {} instead of {}", + output[n], + input[n - LATENCY] + ); + } + } + + /// **Issue #61.** `scan_and_clamp` used to run in full on the bypass path, so FR-CHAIN-090's + /// ceiling (default 0 dBFS) clipped a bypassed signal — and FR-CHAIN-030's own `Verify:` + /// method, the null test, is simply false for any input above that ceiling. The two bypass + /// tests above this one keep their amplitudes deliberately under it and say so in comments, + /// so the behaviour was known and untested. + /// + /// This is `bypassed_output_nulls_against_delayed_input_to_within_120_dbfs` at an amplitude + /// that ceiling would clip, plus the converse — the clamp must still apply when bypass is + /// *off*, so the fix cannot be "stop clamping". + /// + /// Committed red-first: before the fix the residual peaks at ~0.5 (the clipped half of a 1.5 + /// peak), roughly 114 dB above the −120 dBFS floor. + #[test] + fn bypass_does_not_clamp_a_signal_above_the_output_ceiling() { + const BLOCK: usize = 64; + const TOTAL: usize = BLOCK * 8; + const LATENCY: usize = 7; + let null_floor = namir_core::db_to_linear(-120.0); + + // Peak 1.5, comfortably above the default 0 dBFS ceiling `prepare_crosscutting` activates. + let input: Vec = (0..TOTAL) + .map(|n| { + let t = n as f32 / 48_000.0; + 1.5 * (2.0 * std::f32::consts::PI * 220.0 * t).sin() + }) + .collect(); + + let mut chain = Chain::new(vec![Box::new(ConstantTail { + latency: LATENCY as u32, + tail: 0, + })]); + chain.prepare_crosscutting(&ctx()); + chain.set_global_bypass(true); + let output = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + + let delayed: Vec = std::iter::repeat_n(0.0f32, LATENCY) + .chain(input.iter().copied()) + .take(TOTAL) + .collect(); + let peak_residual = output + .iter() + .zip(&delayed) + .map(|(o, d)| (o - d).abs()) + .fold(0.0f32, f32::max); + assert!( + peak_residual <= null_floor, + "bypassed output minus delayed input peaked at {peak_residual:e}, above the \ + -120 dBFS null floor {null_floor:e}: the output ceiling is clipping a path that \ + FR-CHAIN-030 says routes input to output at unity gain" + ); + + // The converse: with bypass off, the ceiling still applies. Fixing #61 must not have + // turned FR-CHAIN-090 off. + chain.set_global_bypass(false); + let clamped = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + let peak = clamped.iter().fold(0.0f32, |m, s| m.max(s.abs())); + assert!( + peak <= 1.0 + 1e-6, + "the non-bypassed path must still clamp to the 0 dBFS default, peaked at {peak}" + ); + } + + /// FR-CHAIN-080 is *not* what issue #61 turns off on the bypass path: a non-finite sample must + /// still silence the block and raise the fault counter, whichever path produced it. + #[test] + fn fault_containment_still_runs_on_the_bypass_path() { + let mut chain = Chain::new(Vec::new()); + chain.prepare_crosscutting(&ctx()); + chain.set_global_bypass(true); + + let mut buf = [1.0f32, f32::NAN, 3.0, 4.0]; + let mut channels: [&mut [f32]; 1] = [&mut buf]; + let mut io = StageIo::new(&mut channels, 4); + audio_section(|| chain.process(&mut io)); + + for s in io.channel(0) { + assert_eq!( + *s, 0.0, + "a NaN reaching the bypass path must still silence the block" + ); + } + assert_eq!(chain.fault_count(), 1); + } + #[test] fn cross_cutting_process_does_not_allocate_in_either_path() { // Bypass path, nonzero latency (exercises the delay ring). @@ -771,8 +1125,9 @@ mod tests { let mut io = StageIo::new(&mut channels, 64); audio_section(|| chain.process(&mut io)); - // Normal (non-bypassed) path, cross-cutting still active: exercises the fault scan and - // ceiling clamp instead of the bypass ring. + // Normal (non-bypassed) path, cross-cutting still active: exercises the fault scan, the + // ceiling clamp, and -- since issue #59 -- the delay line being *fed* while bypass is off, + // which is the one path in `process` that is new work on every block of ordinary playback. chain.set_global_bypass(false); let mut buf2 = [0.1f32; 64]; let mut channels2: [&mut [f32]; 1] = [&mut buf2]; diff --git a/crates/namir-engine/src/chain_probes.rs b/crates/namir-engine/src/chain_probes.rs index 4b0615c..76f402a 100644 --- a/crates/namir-engine/src/chain_probes.rs +++ b/crates/namir-engine/src/chain_probes.rs @@ -760,3 +760,72 @@ fn nfr_perf_020_measured_group_delay_never_exceeds_the_reported_latency() { direction" ); } + +// --------------------------------------------------------------------------------------------- +// Issue #58 — bypass latency compensation against a latency that changes at runtime. +// --------------------------------------------------------------------------------------------- + +/// **Issue #58, at the configuration that actually produces it.** `chain.rs`'s own +/// `bypass_compensation_follows_a_latency_change_made_after_prepare` pins the mechanism with a +/// fake stage; this drives the real one. `build_default_chain` calls `prepare_crosscutting` once, +/// with nothing loaded, so the chain reports zero latency at that moment — and installing a NAM +/// model whose declared rate differs from the engine's engages `stages/nam.rs`'s `SlotResampler` +/// and makes it 640, which is precisely the runtime latency change FR-CLAP-040 names ("including +/// as a result of a model change under FR-NAM-050"). +/// +/// The compensation used to be frozen at that prepare-time zero, so from the model change onward +/// the chain told the host it added 640 samples while its own bypass path added none: bypassed +/// audio misaligned against the rest of the session by exactly the resampler's delay. +/// +/// **No tag.** FR-CHAIN-030's own null test is in `chain.rs` and this does not replace it; +/// FR-CLAP-040 is a statement about the *plugin* notifying the host, which lives in `namir-clap` +/// and which nothing here executes. +/// +/// The signal runs continuously across the bypass switch, and the comparison starts at the switch +/// itself rather than after a settling window — with the delay line fed on both paths (issue #59) +/// the very first bypassed sample is already correctly aligned, and a test that skipped past the +/// transition would not notice if it were not. +#[test] +fn bypass_compensation_tracks_the_latency_a_resampled_model_adds_at_runtime() { + const FRAMES: usize = 16_384; + /// Past the 20 ms handover crossfade (960 samples) and the 15 ms bypass blend many times + /// over, so `NamStage::latency_samples()` has settled on the installed slot before the switch. + const BYPASS_AT_BLOCK: usize = 128; + + let ctx = probe::ctx(ChannelConfig::Mono); + let signal = probe::sine(FRAMES, 220.0, SR, 0.25); + let input = probe::duplicated(&signal, 1); + + let mut chain = build_default_chain(&ctx).unwrap(); + probe::set_param(&mut chain, gate::THRESHOLD_DB.id, -70.0); + // A model declaring 44.1 kHz in a 48 kHz engine: the chain's only source of latency. + probe::load_nam( + &mut chain, + probe::nam_model(WaveNetShape::Nano, 43, 44_100), + &ctx, + ); + + let out = probe::run_with(&mut chain, &input, BLOCK, |i, chain| { + if i == BYPASS_AT_BLOCK { + probe::set_param(chain, namir_params::global::GLOBAL_BYPASS.id, 1.0); + } + }); + + let reported = chain.latency_samples() as usize; + assert!( + reported > 0, + "a model at a different declared rate must engage the resampler and report its latency" + ); + + let switch = BYPASS_AT_BLOCK * BLOCK; + let null_floor = db_to_linear(-120.0); + let peak_residual = (switch..FRAMES) + .map(|n| (out[0][n] - signal[n - reported]).abs()) + .fold(0.0f32, f32::max); + assert!( + peak_residual <= null_floor, + "bypassed output minus input delayed by the {reported} samples the chain reports peaked \ + at {peak_residual:e}, above the -120 dBFS null floor {null_floor:e}: the compensation is \ + not tracking a latency that changed after `prepare_crosscutting` ran" + ); +} diff --git a/crates/namir-engine/src/engine.rs b/crates/namir-engine/src/engine.rs index 4d9b3d5..03d227a 100644 --- a/crates/namir-engine/src/engine.rs +++ b/crates/namir-engine/src/engine.rs @@ -80,13 +80,21 @@ const TELEMETRY_DEFERRED_BLOCKS: u32 = ParamsId::from_key("telemetry.engine.defe /// worker is not draining (D-8.1's degradation case, made observable rather than silent). const TELEMETRY_RETIRE_BACKLOG: u32 = ParamsId::from_key("telemetry.engine.retire_backlog").0; +/// Telemetry: blocks refused because the [`StageIo`] did not match the [`PrepareContext`] the +/// chain was prepared with (issue #60). Any nonzero value is a driver bug — the host handed a +/// block bigger than the maximum it declared, or a channel count the chain was never prepared +/// for — and the alternative to refusing was a panic inside a stage, on the audio thread. +const TELEMETRY_REJECTED_BLOCKS: u32 = ParamsId::from_key("telemetry.engine.rejected_blocks").0; + /// Ring capacities, fixed at preparation (D-7.2: "pre-allocated at preparation"). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RingCapacities { /// Inbound command ring. Generous: a burst of UI parameter moves must not stall the producer. pub commands: usize, /// D-8.1's return ring. Only needs depth for a few in-flight handovers; each consumes at most - /// two slots. + /// two slots — and [`split`] raises anything below that to + /// [`RETIRE_HEADROOM_PER_OFFER`](self::RETIRE_HEADROOM_PER_OFFER), because a return ring + /// shallower than one offer's headroom can never satisfy the drain gate (issue #57). pub retire: usize, /// D-7.3's telemetry ring. Rounded up to a power of two by [`telemetry_ring`]. pub telemetry: usize, @@ -121,6 +129,8 @@ pub struct AudioEngine { /// is never handed an offer when it has nowhere to park what that offer displaces. retire_backlog: bool, deferred_blocks: u64, + /// See [`TELEMETRY_REJECTED_BLOCKS`]. + rejected_blocks: u64, } /// The worker thread's half. @@ -146,7 +156,20 @@ pub struct WorkerEndpoint { /// **Not RT-safe** — this is where every ring allocation happens, once. pub fn split(chain: Chain, caps: RingCapacities) -> (AudioEngine, WorkerEndpoint) { let (command_tx, command_rx) = ring::(caps.commands); - let (retire_tx, retire_rx) = ring::(caps.retire); + // **Raised, not trusted (issue #57).** [`Self::drain_commands`]'s gate refuses to consume an + // offer unless the return ring has `RETIRE_HEADROOM_PER_OFFER` free slots. A ring whose whole + // *capacity* is below that figure can never satisfy the gate, so the first Load or Unload to + // arrive is left in the command ring and never popped — and because the gate returns rather + // than skipping, every parameter change and reset queued behind it is stalled too, for the + // life of the engine. That is not the graceful degradation D-8.1's own clause describes (a + // ring that *fills* still drains again when the worker recovers); it is a permanent + // head-of-line block from which nothing recovers, caused by a capacity choice made once, off + // the audio thread, with no error path anywhere. + // + // Raising it here rather than returning an error keeps `split`'s infallible signature, which + // `namir-app` and `namir-clap` both call directly; a caller that asked for a ring too small to + // work gets a working one, and the figure it asked for was never observable from outside. + let (retire_tx, retire_rx) = ring::(caps.retire.max(RETIRE_HEADROOM_PER_OFFER)); let (telemetry_tx, telemetry_rx) = telemetry_ring(caps.telemetry); ( AudioEngine { @@ -161,6 +184,7 @@ pub fn split(chain: Chain, caps: RingCapacities) -> (AudioEngine, WorkerEndpoint stalled_offer: None, retire_backlog: false, deferred_blocks: 0, + rejected_blocks: 0, }, WorkerEndpoint { commands: command_tx, @@ -189,6 +213,15 @@ impl AudioEngine { /// Wait-free throughout (NFR-RT-020): every ring operation is a bounded number of atomic /// loads and stores, with no loop whose exit depends on another thread making progress. pub fn process(&mut self, io: &mut StageIo<'_>) { + if !self.io_matches_preparation(io) { + // Refused whole (issue #60). `io` is left exactly as the caller handed it in, which + // for an in-place buffer is unity-gain passthrough — audible, but the engine's job + // here is not to crash the host. Telemetry carries the count; see + // `io_matches_preparation` for why this is a check rather than a `debug_assert`. + self.rejected_blocks += 1; + self.publish_telemetry(); + return; + } self.retry_stalled_offer(); self.drain_commands(); self.collect_retired(); @@ -197,6 +230,33 @@ impl AudioEngine { self.publish_telemetry(); } + /// Whether `io` is within what the chain was prepared for: at most `max_block_size` frames, + /// and exactly the prepared channel count. + /// + /// **Why this is here at all (issue #60).** `stage_io.rs` puts the obligation on "whatever + /// drives `Chain::process`" — and this *is* that driver, while `namir-clap/src/audio.rs` + /// passes the host's own `frames_count` straight through. Nothing enforced it, and both ways + /// of violating it are a panic on the audio thread rather than a wrong sound: an over-long + /// block indexes past the per-stage dry-capture scratch (`nam.rs`, `gate.rs`, `ir.rs`), and a + /// channel count below the prepared one indexes past `io`'s own channel list (`eq.rs`). + /// + /// **A check, not a `debug_assert`.** D-16.3's rule for the audio thread is to degrade rather + /// than panic, and a `debug_assert` degrades in exactly the wrong direction — it is loud in + /// the test build, where the caller is a test that could simply be fixed, and absent in the + /// release build a host actually runs. Refusing the block behaves identically in both profiles + /// and is therefore also testable in both. + /// + /// Returns `true` unconditionally for a chain that never had `prepare_crosscutting` called on + /// it (`Chain::prepared_for` is `None`): that path is documented test/scaffolding-only, has no + /// recorded context to check against, and behaved this way before this check existed. + fn io_matches_preparation(&self, io: &StageIo<'_>) -> bool { + let Some(ctx) = self.chain.prepared_for() else { + return true; + }; + io.frames() <= ctx.max_block_size() + && io.channel_count() == ctx.channel_config().output_channels() as usize + } + /// Applies one parameter change immediately, **bypassing the command ring entirely**. /// /// **Who this is for, and why it's sound:** a caller that already holds `&mut AudioEngine` @@ -242,6 +302,13 @@ impl AudioEngine { self.retire_backlog } + /// Blocks refused because the [`StageIo`] did not match the chain's [`PrepareContext`]. Any + /// nonzero value is a driver bug — see [`Self::io_matches_preparation`]. Also published as + /// telemetry. + pub fn rejected_blocks(&self) -> u64 { + self.rejected_blocks + } + fn retry_stalled_offer(&mut self) { let Some(resource) = self.stalled_offer.take() else { return; @@ -284,6 +351,18 @@ impl AudioEngine { } let mut nam_offered = false; let mut ir_offered = false; + // **A running reservation, not a fresh `slots()` read per offer (issue #62).** + // `RETIRE_HEADROOM_PER_OFFER` is documented as headroom *per offer*, but both stages used + // to test the same pre-drain snapshot: a block carrying one Nam offer and one Ir offer + // could commit to four potential retirements having verified only two free slots. It + // degraded safely — the second retirement lands in the stage's own pen and defers — but + // the invariant the constant states simply did not hold. Every accepted offer now consumes + // its own headroom, so what the gate checks is what the comment claims. + // + // `slots()` is still re-read each time rather than snapshotted: the worker only ever + // *frees* slots, so a later read can be larger but never smaller, and re-reading lets a + // second offer through in the same block if the worker drained in between. + let mut reserved = 0usize; for _ in 0..MAX_COMMANDS_PER_BLOCK { let Some(kind) = self.commands.peek().map(Command::kind) else { @@ -300,11 +379,12 @@ impl AudioEngine { CommandKind::LoadNam | CommandKind::UnloadNam => nam_offered, _ => ir_offered, }; - if already || self.retire_backlog || self.retire.slots() < RETIRE_HEADROOM_PER_OFFER - { + let needed = reserved + RETIRE_HEADROOM_PER_OFFER; + if already || self.retire_backlog || self.retire.slots() < needed { self.deferred_blocks += 1; return; } + reserved = needed; match kind { CommandKind::LoadNam | CommandKind::UnloadNam => nam_offered = true, _ => ir_offered = true, @@ -315,8 +395,13 @@ impl AudioEngine { }; self.apply_command(command); } - // Hit the per-block cap; whatever is left waits for the next block. - self.deferred_blocks += 1; + // Hit the per-block cap. Only a *deferral* if something is actually left behind (issue + // #63): a drain whose 64th pop emptied the ring stopped because there was nothing more to + // do, not early, and counting it made the one telemetry signal that says "a control may + // have stopped responding" fire on a perfectly healthy burst of exactly 64 commands. + if self.commands.peek().is_some() { + self.deferred_blocks += 1; + } } fn apply_command(&mut self, command: Command) { @@ -365,6 +450,7 @@ impl AudioEngine { telemetry_scratch, deferred_blocks, retire_backlog, + rejected_blocks, .. } = self; let mut sink = TelemetrySink::new(telemetry_scratch); @@ -377,6 +463,10 @@ impl AudioEngine { id: TELEMETRY_RETIRE_BACKLOG, value: if *retire_backlog { 1.0 } else { 0.0 }, }); + sink.push(TelemetryEntry { + id: TELEMETRY_REJECTED_BLOCKS, + value: *rejected_blocks as f32, + }); for entry in sink.entries() { telemetry.push(entry); } @@ -748,24 +838,42 @@ mod tests { /// **D-8.1's degradation clause, exercised rather than asserted:** "If the worker dies, the /// ring fills and memory is retained but audio continues. Degradation, not failure (P8)." /// - /// A one-deep return ring that is never drained, with several handovers submitted. Audio must + /// A shallow return ring that is never drained, with several handovers submitted. Audio must /// keep flowing, finite, with no allocation — and, the load-bearing part, **no resource - /// dropped on the audio thread**, which the `Arc` strong counts prove directly. + /// dropped on the audio thread**. + /// + /// # What this test used to be, and why it proved nothing (issue #57) + /// + /// It asked for `retire: 1`, which is below [`RETIRE_HEADROOM_PER_OFFER`]. The drain gate + /// therefore refused the very first `Load` and every command behind it, forever, so **no model + /// was ever installed** — there was no handover to degrade, and the run was indistinguishable + /// from an idle engine. Its two assertions could not see that: `retire_backlog() || + /// deferred_blocks() > 0` passed on the second disjunct alone (which a permanently-blocked + /// gate raises on every block), and `Arc::strong_count(m) >= 1` is tautological, because the + /// test itself holds each `Arc` for the whole of its own body. + /// + /// Three things changed. The capacity is now `RETIRE_HEADROOM_PER_OFFER`, the shallowest ring + /// that can carry a handover at all. A model is asserted to have *actually installed*, by + /// comparing the audio against an otherwise-identical engine that was never sent one. And the + /// strong-count assertion asks for `>= 2`: one reference is the test's own, so the second is + /// the engine still holding the slot — installed, parked in a stage's pen, or sitting in the + /// return ring. That is the assertion that fails if the audio thread ever drops one. #[test] fn a_never_drained_return_ring_retains_memory_and_audio_continues() { + const BLOCKS: usize = 400; let c = ctx(); let (mut engine, mut worker) = split( crate::stages::build_default_chain(&c).unwrap(), RingCapacities { commands: 16, - retire: 1, + retire: RETIRE_HEADROOM_PER_OFFER, telemetry: 16, }, ); let models: Vec<_> = (0..4).map(|i| model(100 + i)).collect(); let mut submitted = 0usize; - let out = run_sine(&mut engine, 400, 220.0, |b| { + let out = run_sine(&mut engine, BLOCKS, 220.0, |b| { if b % 60 == 10 && submitted < models.len() { // May be refused once the command ring backs up; that is the point. let _ = worker @@ -781,23 +889,244 @@ mod tests { "sample {i} was not finite under back-pressure" ); } + + // **A model actually installed.** Without this the whole test is satisfiable by an engine + // that refused every command it was ever sent, which is exactly what it used to be. + let (mut idle_engine, _idle_worker) = build_default_engine(&c).unwrap(); + let idle = run_sine(&mut idle_engine, BLOCKS, 220.0, |_| {}); + let difference = out + .iter() + .zip(&idle) + .map(|(a, b)| (a - b).abs()) + .fold(0.0f32, f32::max); assert!( - engine.retire_backlog() || engine.deferred_blocks() > 0, - "a one-deep, never-drained return ring should have produced observable back-pressure" + difference > 1e-3, + "the run is indistinguishable from one where nothing was ever loaded (peak difference \ + {difference}): no handover happened, so nothing was degraded" ); - // Nothing was freed by the audio thread: every model this test still holds is either - // still installed, parked, or sitting in the ring — never dropped. - for m in &models { + + // Real, observable back-pressure -- and specifically the gate stalling, not the per-block + // command cap: only four commands are ever submitted, far below MAX_COMMANDS_PER_BLOCK. + assert!( + engine.deferred_blocks() > 0, + "a two-deep, never-drained return ring should have stalled the drain" + ); + + // Nothing was freed by the audio thread: every model this test handed over is still + // reachable from the engine -- installed, parked in a stage's pen, held in the return + // ring, or still sitting in the command ring -- so each carries the test's own reference + // plus at least one more. + for (i, m) in models.iter().take(submitted).enumerate() { assert!( - Arc::strong_count(m) >= 1, - "a model was freed while the test still held a reference" + Arc::strong_count(m) >= 2, + "model {i} is held only by the test ({} references): the audio thread dropped the \ + slot instead of retaining it", + Arc::strong_count(m) ); } - // Draining the worker end releases the backlog and normal service resumes. + // Draining the worker end releases the backlog and normal service resumes: a command that + // was stuck behind the gate gets consumed. + let stuck = worker.commands.slots(); while worker.retire.try_pop().is_some() {} let after = run_sine(&mut engine, 60, 220.0, |_| {}); assert!(after.iter().all(|s| s.is_finite())); + assert!( + worker.commands.slots() > stuck, + "draining the return ring must let the stalled drain make progress again" + ); + } + + /// **Issue #57's other half: `split` used to trust `caps.retire`.** Any value below + /// [`RETIRE_HEADROOM_PER_OFFER`] makes `self.retire.slots() < RETIRE_HEADROOM_PER_OFFER` + /// permanently true, so the drain gate returns on the first `Load`/`Unload` and never pops + /// it — head-of-line blocking that silently kills every parameter change and reset queued + /// behind it, for the life of the engine. + /// + /// Committed red-first: before the fix the ceiling change queued behind the load never + /// arrives, and the block comes out unclamped at 0.8. + #[test] + fn a_retire_capacity_below_one_offers_headroom_does_not_stall_the_drain() { + let c = ctx(); + let (mut engine, mut worker) = split( + crate::stages::build_default_chain(&c).unwrap(), + RingCapacities { + commands: 16, + retire: 1, + telemetry: 16, + }, + ); + + let empty = worker.commands.slots(); + submit(&mut worker, Command::load_nam(model(9), &c)); + submit( + &mut worker, + Command::Param(ParamChange { + id: ParamId(namir_params::global::OUTPUT_CEILING_DB.id.0), + value: -20.0, + }), + ); + + let mut buf = [0.8f32; BLOCK]; + let mut channels: [&mut [f32]; 1] = [&mut buf]; + let mut io = StageIo::new(&mut channels, BLOCK); + audio_section(|| engine.process(&mut io)); + + let ceiling = namir_core::db_to_linear(-20.0); + for s in io.channel(0) { + assert!( + s.abs() <= ceiling + 1e-4, + "sample {s} exceeded the -20 dB ceiling: the parameter change queued behind the \ + load never drained" + ); + } + assert_eq!( + worker.commands.slots(), + empty, + "both commands should have drained, leaving the ring empty" + ); + } + + /// **Issue #62.** [`RETIRE_HEADROOM_PER_OFFER`] is documented as headroom *per offer*, but + /// both stages used to test the same pre-drain `slots()` snapshot, so a block carrying one Nam + /// offer and one Ir offer could commit to four potential retirements having verified only two + /// free slots. + /// + /// A three-slot ring makes the difference observable: one offer fits (2 ≤ 3), two do not + /// (4 > 3). Committed red-first — before the fix both commands drain in the first block. + #[test] + fn a_second_offer_in_the_same_block_reserves_its_own_retire_headroom() { + let c = ctx(); + let (mut engine, mut worker) = split( + crate::stages::build_default_chain(&c).unwrap(), + RingCapacities { + commands: 16, + retire: RETIRE_HEADROOM_PER_OFFER + 1, + telemetry: 16, + }, + ); + + submit(&mut worker, Command::load_nam(model(21), &c)); + submit(&mut worker, Command::load_ir(ir(22), &c)); + let before = worker.commands.slots(); + + run_sine(&mut engine, 1, 220.0, |_| {}); + assert_eq!( + worker.commands.slots() - before, + 1, + "one block verified {RETIRE_HEADROOM_PER_OFFER} free slots and accepted two offers, \ + each of which may retire that many" + ); + assert!(engine.deferred_blocks() > 0); + + // The reservation is per block, not sticky: the next block takes the second offer. + run_sine(&mut engine, 1, 220.0, |_| {}); + assert_eq!( + worker.commands.slots() - before, + 2, + "the second offer should go through on the following block" + ); + } + + /// **Issue #63.** `deferred_blocks` was incremented unconditionally after the + /// [`MAX_COMMANDS_PER_BLOCK`] loop, including when the 64th pop emptied the ring — so the one + /// telemetry signal that says "a control may have stopped responding" fired on a perfectly + /// healthy burst of exactly 64 commands. + /// + /// Committed red-first: before the fix the first assertion reads 1. + #[test] + fn a_drain_that_exactly_empties_the_ring_is_not_a_deferral() { + let c = ctx(); + let (mut engine, mut worker) = build_default_engine(&c).unwrap(); + let empty = worker.commands.slots(); + for i in 0..MAX_COMMANDS_PER_BLOCK as u32 { + submit( + &mut worker, + Command::Param(ParamChange { + id: ParamId(i), + value: 0.0, + }), + ); + } + run_sine(&mut engine, 1, 220.0, |_| {}); + assert_eq!( + worker.commands.slots(), + empty, + "the whole burst should have drained" + ); + assert_eq!( + engine.deferred_blocks(), + 0, + "a drain that consumed every queued command did not stop early" + ); + + // One more than the cap *is* a deferral, so the counter still means something. + let (mut engine, mut worker) = build_default_engine(&c).unwrap(); + for i in 0..MAX_COMMANDS_PER_BLOCK as u32 + 1 { + submit( + &mut worker, + Command::Param(ParamChange { + id: ParamId(i), + value: 0.0, + }), + ); + } + run_sine(&mut engine, 1, 220.0, |_| {}); + assert_eq!(engine.deferred_blocks(), 1); + } + + /// **Issue #60: `AudioEngine::process` accepted any [`StageIo`] at all.** A block longer than + /// the `PrepareContext`'s `max_block_size` indexes past every stage's dry-capture scratch — + /// `nam.rs`, `gate.rs` and `ir.rs` all slice `self.dry[ch][..n]` — which is a panic on the + /// audio thread, from a figure `namir-clap/src/audio.rs` passes straight through from the + /// host. + /// + /// Committed red-first: before the fix this test does not fail an assertion, it panics with a + /// slice range error inside `NamStage::process`. + #[test] + fn a_block_longer_than_the_prepared_maximum_is_refused_rather_than_panicking() { + let c = ctx(); // max_block_size == BLOCK. + let (mut engine, _worker) = build_default_engine(&c).unwrap(); + + let mut buf = [0.25f32; BLOCK * 2]; + let mut channels: [&mut [f32]; 1] = [&mut buf]; + let mut io = StageIo::new(&mut channels, BLOCK * 2); + audio_section(|| engine.process(&mut io)); + + assert_eq!(engine.rejected_blocks(), 1); + for s in io.channel(0) { + assert_eq!( + *s, 0.25, + "a refused block must be left exactly as the caller handed it in" + ); + } + + // A block *within* the maximum is still processed normally, so the guard is a bound rather + // than a blanket refusal. + let mut ok_buf = [0.25f32; BLOCK]; + let mut ok_channels: [&mut [f32]; 1] = [&mut ok_buf]; + let mut ok_io = StageIo::new(&mut ok_channels, BLOCK); + audio_section(|| engine.process(&mut ok_io)); + assert_eq!(engine.rejected_blocks(), 1); + } + + /// Issue #60's other limb: a channel count the chain was never prepared for. Below the + /// prepared count `eq.rs` indexes past `io`'s own channel list; above it, every stage's + /// `self.dry[ch]` is short. Both are panics on the audio thread. + #[test] + fn a_block_with_the_wrong_channel_count_is_refused_rather_than_panicking() { + let c = ctx(); // Mono. + let (mut engine, _worker) = build_default_engine(&c).unwrap(); + + let mut left = [0.25f32; BLOCK]; + let mut right = [0.5f32; BLOCK]; + let mut channels: [&mut [f32]; 2] = [&mut left, &mut right]; + let mut io = StageIo::new(&mut channels, BLOCK); + audio_section(|| engine.process(&mut io)); + + assert_eq!(engine.rejected_blocks(), 1); + assert_eq!(io.channel(0)[0], 0.25); + assert_eq!(io.channel(1)[0], 0.5); } /// The per-block command drain is bounded — NFR-RT-040 ("worst-case per-block processing time diff --git a/crates/namir-engine/src/stages/ir.rs b/crates/namir-engine/src/stages/ir.rs index 59d5791..2e4db3e 100644 --- a/crates/namir-engine/src/stages/ir.rs +++ b/crates/namir-engine/src/stages/ir.rs @@ -548,10 +548,16 @@ impl IrStage { if crossfade.remaining == 0 { // Deferred-finalization state -- see the finalization block below and `nam.rs`'s - // identical fast path. The fade is mathematically complete but the retire pen is - // still occupied, so run only the incoming slot rather than blending in an outgoing - // one scaled by `cos(FRAC_PI_2)` (-4.4e-8 in f32, not exactly zero) and paying its - // convolution cost for as long as the deferral lasts. + // identical fast path, whose comment carries the full account of issue #56. The fade + // is mathematically complete but the retire pen was still occupied when it ended, so + // retry the finalization first, on every block: falling straight through to the render + // made this state a dead end nothing re-tested, permanently for the session. + self.try_finalize_handover(); + + // Then run only the incoming slot rather than blending in an outgoing one scaled by + // `cos(FRAC_PI_2)` (-4.4e-8 in f32, not exactly zero) and paying its convolution cost + // for as long as the deferral lasts. `incoming_idx` names the same slot either way: a + // successful finalization sets `self.active` *to* it. if let Some(slot) = &mut self.slots[incoming_idx] { let produced = slot.channel_count(); slot.process_wet(&self.dry[0][..n], &mut self.crossfade_incoming, n); @@ -617,21 +623,10 @@ impl IrStage { } if crossfade.remaining == 0 { - if self.retired.is_none() { - // **The M2 P1 violation, closed** -- identical change and identical reasoning to - // `nam.rs`'s finalization block; read that one for the full note. This used to be - // `self.slots[outgoing_idx] = None`, a drop of the outgoing `IrState`'s - // convolution ring buffers (and possibly the last `Arc`) on the audio - // thread. `take()` moves instead. Do not "simplify" this back to an assignment. - self.retired = self.slots[outgoing_idx] - .take() - .map(|slot| Resource::ir(slot, self.prepared_for)); - self.active = incoming_idx; - self.crossfade = None; - self.recompute_mix_target(); - } else { + if !self.try_finalize_handover() { // Return ring full, worker not draining: defer the bookkeeping only. The audio is - // already correct (the fast path above runs the incoming slot alone). D-8.1's + // already correct (the fast path above runs the incoming slot alone, and retries + // this finalization on every block until the pen clears -- issue #56). D-8.1's // "degradation, not failure (P8)". Dropping the outgoing slot here to make // progress is the exact bug this milestone removes. self.crossfade = Some(crossfade); @@ -640,6 +635,26 @@ impl IrStage { self.crossfade = Some(crossfade); } } + + /// `nam.rs`'s `try_finalize_handover`, for this stage -- same contract, same two call sites, + /// same `false`-means-the-pen-is-still-occupied return. Read that one's doc comment. + fn try_finalize_handover(&mut self) -> bool { + if self.retired.is_some() { + return false; + } + let outgoing_idx = self.active; + // **The M2 P1 violation, closed** -- identical change and identical reasoning to + // `nam.rs`'s. This used to be `self.slots[outgoing_idx] = None`, a drop of the outgoing + // `IrState`'s convolution ring buffers (and possibly the last `Arc`) on the + // audio thread. `take()` moves instead. Do not "simplify" this back to an assignment. + self.retired = self.slots[outgoing_idx] + .take() + .map(|slot| Resource::ir(slot, self.prepared_for)); + self.active = 1 - outgoing_idx; + self.crossfade = None; + self.recompute_mix_target(); + true + } } impl Stage for IrStage { @@ -974,6 +989,70 @@ mod tests { } } + /// **Issue #56's Ir half.** `nam.rs`'s + /// `a_handover_deferred_by_a_full_retire_pen_finalizes_once_the_pen_clears` carries the full + /// account; this is the same defect in the same shape, in `process_wet`'s `remaining == 0` + /// fast path, and it needs its own test because the two stages carry two copies of the state + /// machine. + /// + /// Committed red-first: before the fix, `crossfade` is still `Some(remaining: 0)` and `active` + /// is still 1 after the pen has been drained and further blocks processed. + #[test] + fn a_handover_deferred_by_a_full_retire_pen_finalizes_once_the_pen_clears() { + const SR: u32 = 48_000; + /// 20 ms at 48 kHz is 960 samples; this is comfortably past a whole fade. + const PAST_A_FADE: usize = 2_048; + /// Well inside one, so the next install displaces a slot still fading in. + const MID_FADE: usize = 128; + + let mut stage = stage(SR, ChannelConfig::Mono); + let taps = [0.6f32, -0.2, 0.1]; + + stage.load_ir(mono_ir(SR, &taps, 64)); + process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); + assert_eq!(stage.active, 1); + assert!(stage.crossfade.is_none()); + assert!(stage.retired.is_none()); + + stage.load_ir(mono_ir(SR, &taps, 64)); + process_constant_in_chunks(&mut stage, MID_FADE, 0.1); + stage.load_ir(mono_ir(SR, &taps, 64)); + assert!( + stage.retired.is_some(), + "the displaced slot should be parked in the pen" + ); + + process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); + assert_eq!( + stage.crossfade, + Some(Crossfade { + remaining: 0, + total: stage.crossfade_total_samples + }), + "the fade should have reached zero and deferred its finalization" + ); + assert_eq!( + stage.active, 1, + "`active` may not flip while the pen is full" + ); + + let (mut producer, mut consumer) = crate::ring::ring::(4); + { + let mut sink = RetireSink::new(&mut producer); + stage.collect_retired(&mut sink); + } + assert!(consumer.try_pop().is_some()); + + process_constant_in_chunks(&mut stage, 64, 0.1); + assert!( + stage.crossfade.is_none(), + "the deferred handover must finalize once the pen clears, not stay in it forever" + ); + assert_eq!(stage.active, 0); + assert!(stage.retired.is_some()); + assert_eq!(stage.mix_target, 1.0); + } + #[test] fn tail_samples_reports_active_irs_length() { let sample_rate = 48_000; diff --git a/crates/namir-engine/src/stages/nam.rs b/crates/namir-engine/src/stages/nam.rs index 388f778..beed1b7 100644 --- a/crates/namir-engine/src/stages/nam.rs +++ b/crates/namir-engine/src/stages/nam.rs @@ -836,12 +836,32 @@ impl NamStage { if crossfade.remaining == 0 { // Deferred-finalization state (see this method's finalization block below): the fade - // is mathematically complete but the retire pen is still occupied, so `active` has - // not flipped yet. Run only the incoming slot rather than blending in an outgoing one - // scaled by `cos(FRAC_PI_2)` — which is -4.4e-8 in f32, not exactly zero, and would - // otherwise leave a faint copy of the old model in the output for as long as the - // deferral lasts. Skipping it also avoids paying the 2x inference cost in a state - // that can persist across many blocks. + // is mathematically complete but the retire pen was still occupied when it ended, so + // `active` has not flipped yet. + // + // **Try to finalize first, every block (issue #56).** This used to fall straight + // through to the incoming-only render, which made the state a dead end: nothing else + // in `process_channel0` re-tests `self.retired`, so once entered, `active` never + // flipped, `crossfade` never cleared and the outgoing slot never reached the pen — for + // the rest of the session. The consequences were permanent and all silent: + // `latency_samples()` kept reporting the outgoing slot (FR-CLAP-040 wrong), + // `telemetry.nam.handover_active` stayed pinned at 1.0, `recompute_mix_target` never + // re-ran (so a *first* load left the stage bypassed forever), and a later install + // displaced the audible slot and re-faded from the stale outgoing one. The block + // comment below promised exactly this recovery; it simply did not exist. + // + // The retry is one `Option::is_none()` check per block. `collect_retired` empties the + // pen as soon as the worker drains, so the deferral is normally over within a block or + // two — but nothing bounds it, which is precisely why it must be retried rather than + // entered once. + self.try_finalize_handover(); + + // Run only the incoming slot rather than blending in an outgoing one scaled by + // `cos(FRAC_PI_2)` — which is -4.4e-8 in f32, not exactly zero, and would otherwise + // leave a faint copy of the old model in the output for as long as the deferral lasts. + // Skipping it also avoids paying the 2x inference cost in a state that can persist + // across many blocks. `incoming_idx` names the same slot either way: a successful + // finalization sets `self.active` *to* it. if let Some(slot) = &mut self.slots[incoming_idx] { slot.process_wet( &self.dry[0][..n], @@ -888,20 +908,7 @@ impl NamStage { } if crossfade.remaining == 0 { - if self.retired.is_none() { - // **The M2 P1 violation, closed.** This used to be `self.slots[outgoing_idx] = - // None`, i.e. a *drop* — freeing the outgoing `NamState`'s scratch and possibly - // the last `Arc` reference, on the audio thread, at the exact - // instant a handover completed. `take()` *moves*: nothing is dropped here, and - // the return ring carries the slot to a worker that can afford to free it - // (D-8.1 step 4). Do not "simplify" this back to an assignment. - self.retired = self.slots[outgoing_idx] - .take() - .map(|slot| Resource::nam(slot, self.prepared_for)); - self.active = incoming_idx; - self.crossfade = None; - self.recompute_mix_target(); - } else { + if !self.try_finalize_handover() { // The pen is still occupied because the return ring was full when // `collect_retired` last ran — i.e. the worker is not draining (D-8.1: "If the // worker dies, the ring fills and memory is retained but audio continues. @@ -910,8 +917,9 @@ impl NamStage { // Only the *bookkeeping* is deferred; the audio is already correct. `theta` has // saturated at FRAC_PI_2, so the outgoing slot is multiplied by cos(pi/2) and // contributes nothing audible, and `process_channel0`'s own fast path above skips - // running it at all. The stage simply stays in this state until a later block's - // `collect_retired` empties the pen, then finalizes. + // running it at all. The stage stays in this state until a later block's + // `collect_retired` empties the pen — and that same fast path retries the + // finalization on every block until it does (issue #56). // // The wrong "fix" here is to drop the outgoing slot to make progress. That is // exactly the bug this milestone removes; deferring costs bounded memory, and @@ -922,6 +930,38 @@ impl NamStage { self.crossfade = Some(crossfade); } } + + /// D-8.1's step-4 bookkeeping for a fade that has reached zero: move the outgoing slot into + /// the retire pen, flip `active` onto the incoming one, clear the crossfade and recompute the + /// bypass blend's target. Returns `false` — changing nothing at all — when the pen is still + /// occupied, which is the deferred-finalization state both callers document. + /// + /// **RT-safe:** one `Option::is_none()`, one `Option::take()` (a move, never a drop — see the + /// note in `install`), and three scalar assignments. + /// + /// Called from two places, and that is the fix for issue #56: once at the end of the fade in + /// `process_channel0`, and again on every subsequent block from that method's `remaining == 0` + /// fast path, so a deferral entered because the worker was not draining is left as soon as it + /// is. + fn try_finalize_handover(&mut self) -> bool { + if self.retired.is_some() { + return false; + } + let outgoing_idx = self.active; + // **The M2 P1 violation, closed.** This used to be `self.slots[outgoing_idx] = None`, i.e. + // a *drop* — freeing the outgoing `NamState`'s scratch and possibly the last + // `Arc` reference, on the audio thread, at the exact instant a handover + // completed. `take()` *moves*: nothing is dropped here, and the return ring carries the + // slot to a worker that can afford to free it (D-8.1 step 4). Do not "simplify" this back + // to an assignment. + self.retired = self.slots[outgoing_idx] + .take() + .map(|slot| Resource::nam(slot, self.prepared_for)); + self.active = 1 - outgoing_idx; + self.crossfade = None; + self.recompute_mix_target(); + true + } } impl Stage for NamStage { @@ -1686,6 +1726,155 @@ mod tests { ); } + /// **Issue #56: the deferred-finalization state was entered and never left.** + /// + /// When a fade reached `remaining == 0` while the retire pen was occupied, `process_channel0` + /// set `crossfade = Some(remaining: 0)` and skipped finalization. On every later block the + /// `remaining == 0` fast path returned *before* the finalization block, so `self.retired` was + /// never re-tested: `active` never flipped, `crossfade` never cleared, and the outgoing slot + /// never reached the pen — permanently, for the rest of the session, even after the worker + /// resumed draining. `latency_samples()` kept reporting the outgoing slot (FR-CLAP-040), the + /// `handover_active` reading stayed pinned at 1.0, and a later install would displace the + /// audible slot and re-fade from the stale outgoing one. + /// + /// The state is reached the way the engine reaches it: an install that displaces a slot still + /// fading in parks that slot in the pen, and the fade then completes with the pen occupied. + /// This test simply does not collect in between, which is what a stalled worker looks like + /// from inside the stage. + /// + /// Committed red-first: before the fix, the final three assertions all fail — `crossfade` is + /// still `Some`, `active` is still 1, and the pen is empty because the outgoing slot is stuck + /// in `slots[1]` forever. + #[test] + fn a_handover_deferred_by_a_full_retire_pen_finalizes_once_the_pen_clears() { + const SR: u32 = 48_000; + // 20 ms at 48 kHz = 960 samples; 2048 is comfortably past a whole fade. + const PAST_A_FADE: usize = 2_048; + // Well inside one, so the next install displaces a slot that is still fading in. + const MID_FADE: usize = 128; + + let mut stage = stage(SR, ChannelConfig::Mono); + + // First model: settles with nothing displaced, so the pen stays empty. + stage.load_model(tiny_model(SR)); + process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); + assert_eq!(stage.active, 1); + assert!(stage.crossfade.is_none()); + assert!(stage.retired.is_none()); + + // Second model, then a third *while the second is still fading in*: the third install + // displaces the second into the pen (`install`'s "a move, not a drop"). + stage.load_model(tiny_model(SR)); + process_constant_in_chunks(&mut stage, MID_FADE, 0.1); + stage.load_model(tiny_model(SR)); + assert!( + stage.retired.is_some(), + "the displaced slot should be parked in the pen" + ); + + // Let the third model's fade run to completion with the pen still occupied -- nothing + // collects, which is exactly D-8.1's "the worker is not draining" case. + process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); + assert_eq!( + stage.crossfade, + Some(Crossfade { + remaining: 0, + total: stage.crossfade_total_samples + }), + "the fade should have reached zero and deferred its finalization" + ); + assert_eq!( + stage.active, 1, + "`active` may not flip while the pen is full" + ); + + // The worker drains: the pen empties. + let (mut producer, mut consumer) = crate::ring::ring::(4); + { + let mut sink = RetireSink::new(&mut producer); + stage.collect_retired(&mut sink); + } + assert!(stage.retired.is_none()); + assert!( + consumer.try_pop().is_some(), + "the displaced slot reached the ring" + ); + + // One more block is all it should take. Before the fix, no number of blocks was enough. + process_constant_in_chunks(&mut stage, 64, 0.1); + assert!( + stage.crossfade.is_none(), + "the deferred handover must finalize once the pen clears, not stay in it forever" + ); + assert_eq!( + stage.active, 0, + "finalization flips `active` onto the slot that faded in" + ); + assert!( + stage.retired.is_some(), + "the outgoing slot must reach the pen, not stay stuck in `slots`" + ); + assert_eq!( + stage.mix_target, 1.0, + "`recompute_mix_target` must have re-run against the newly-active slot" + ); + } + + /// The consequence of issue #56 that is audible rather than merely wrong on paper: a + /// **first** load deferred by a full pen left `mix_target` at 0.0, so the stage stayed + /// bypassed — silent as far as the model is concerned — for the rest of the session. + /// + /// Reached the same way as the test above, but with the pen filled by an unload rather than by + /// a prior model, so the deferred handover is the one that first makes a model audible. + #[test] + fn a_deferred_first_handover_does_not_leave_the_stage_bypassed_forever() { + const SR: u32 = 48_000; + const PAST_A_FADE: usize = 2_048; + const MID_FADE: usize = 128; + + let mut stage = stage(SR, ChannelConfig::Mono); + + // Get one model settled and audible, then unload it so the stage is back to nothing + // active -- and immediately load a replacement while that unload fade is still running, + // which parks the unload's own slot in the pen. + stage.load_model(tiny_model(SR)); + process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); + { + let (mut producer, _consumer) = crate::ring::ring::(4); + let mut sink = RetireSink::new(&mut producer); + stage.collect_retired(&mut sink); + } + stage.unload(); + process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); + assert_eq!(stage.mix_target, 0.0, "an unloaded stage fades to dry"); + { + // The unload's own finalization parks the formerly-active slot; clear it so the next + // install is the ordinary pen-empty case and the deferral this test is about is + // caused by the mid-fade displacement below, not by leftovers. + let (mut producer, _consumer) = crate::ring::ring::(4); + let mut sink = RetireSink::new(&mut producer); + stage.collect_retired(&mut sink); + } + + stage.load_model(tiny_model(SR)); + process_constant_in_chunks(&mut stage, MID_FADE, 0.1); + stage.load_model(tiny_model(SR)); + assert!(stage.retired.is_some()); + process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); + assert_eq!(stage.mix_target, 0.0, "still deferred, still bypassed"); + + let (mut producer, _consumer) = crate::ring::ring::(4); + { + let mut sink = RetireSink::new(&mut producer); + stage.collect_retired(&mut sink); + } + process_constant_in_chunks(&mut stage, 64, 0.1); + assert_eq!( + stage.mix_target, 1.0, + "once the pen clears the stage must become audible again, not stay bypassed forever" + ); + } + #[test] fn latency_reports_the_active_slots_resampler_latency() { // 1:1 rate: bypassed entirely, zero added latency (D-9.2). From 2346917ce7998393305c2c18352477d70ace4510 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:46:44 +0000 Subject: [PATCH 14/44] namir-library: stop the scanner destroying what it cannot see (#65-#73) #65: an unreadable directory marked the scan complete, so its whole subtree was inferred deleted and erased. A second mechanism now carries "nobody looked here": unreadable prefixes filter removals under them. Chosen over clearing `complete`, which would suppress removals tree-wide and leave a genuinely deleted file lingering forever after one ACL-restricted folder. The single-root shape both shells run is the dangerous one -- pre-fix the entire index became removals and was then written over the shared file. #66: read_dir returned Result>, which made partial success unrepresentable, so one bad child discarded its siblings. Each per-entry failure now skips that child; a child that failed but has a path is still marked seen, so it is not inferred deleted either. #67: the settling window was centred on scan completion, which protected only files examined in the last two seconds. The index now records the scan's start, and the test is one-sided. On-disk key renamed with a serde alias so an existing index still loads. #68: the corruption policy promised a rebuild -- true for entries, false for favourites, which are hand-curated and unrecoverable. They are now mirrored to a sidecar, with the index document authoritative whenever it loads so a stale sidecar cannot resurrect a mark the user removed. #69: the staging file now carries pid and counter, so two shells no longer collide on a predictable, unowned name. #70: the size ceiling is enforced at the read via take(max + 1), not by re-stat'ing, so at most one byte past the limit is ever in memory. Non-regular paths are refused before opening, because File::open on a FIFO blocks at open and no bounded read can rescue that. #72: the folded blob is precomputed at upsert, so search allocates and folds nothing per call -- Cargo.toml's claim is now literally true. #73: directory symlinks are traversed rather than warned about; not traversing was never a decision, it fell out of asking file_type(). A symlinked collection is an ordinary setup, and a warning alone still leaves that user an empty library. Loop safety is now an explicit canonical-path guard consulted only when following a link. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-library/Cargo.toml | 11 +- crates/namir-library/src/entry.rs | 8 + crates/namir-library/src/error_codes.rs | 26 ++ crates/namir-library/src/fs.rs | 443 +++++++++++++++++++--- crates/namir-library/src/index.rs | 120 +++++- crates/namir-library/src/lib.rs | 2 +- crates/namir-library/src/scan.rs | 482 +++++++++++++++++++++++- crates/namir-library/src/search.rs | 26 +- crates/namir-library/src/store.rs | 373 +++++++++++++++--- 9 files changed, 1340 insertions(+), 151 deletions(-) diff --git a/crates/namir-library/Cargo.toml b/crates/namir-library/Cargo.toml index 53adc7d..f091791 100644 --- a/crates/namir-library/Cargo.toml +++ b/crates/namir-library/Cargo.toml @@ -26,9 +26,14 @@ serde_json = "1" # already in the tree via namir-state, rather than adding a second binary format to fuzz. # - no search-index crate (fst, tantivy): a linear scan over a precomputed lowercase blob is # sub-millisecond at 10,000 records and adds nothing a real search library would improve on. -# - no directory-walking crate (walkdir): std::fs::read_dir plus DirEntry::file_type() (which -# does not follow symlinks, so loops are impossible by construction) is sufficient and keeps -# the caller-pumped step machine's control flow local (see scan.rs). +# (Issue #72: the blob really is precomputed now -- index.rs folds it at upsert time. It was +# not when this line was written, and search.rs allocated and case-folded per entry per call.) +# - no directory-walking crate (walkdir): std::fs::read_dir plus DirEntry::file_type() is +# sufficient and keeps the caller-pumped step machine's control flow local (see scan.rs). +# This line used to add "which does not follow symlinks, so loops are impossible by +# construction"; issue #73 traded that free property for a real one, since it also meant a +# symlinked library folder -- an ordinary setup -- was silently never scanned. scan.rs now +# follows directory symlinks and carries an explicit visited-canonical-directory set. # - no namir-params, no namir-engine, no namir-worker: D-5.1's layering table permits none of # them for this crate (`xtask layering`'s LAYERING_TABLE already carries this row). diff --git a/crates/namir-library/src/entry.rs b/crates/namir-library/src/entry.rs index 0333c67..d772ec7 100644 --- a/crates/namir-library/src/entry.rs +++ b/crates/namir-library/src/entry.rs @@ -77,6 +77,14 @@ impl FileTime { pub fn as_nanos_since_epoch(self) -> i128 { self.0 } + + /// [`Self::as_nanos_since_epoch`]'s inverse — a timestamp stated directly, which is how a + /// test states one that must sit a known distance from another (D-12.1's settling window is + /// measured in nanoseconds, and waiting real seconds to construct one is neither fast nor + /// deterministic). + pub fn from_nanos_since_epoch(nanos: i128) -> Self { + FileTime(nanos) + } } /// FR-NAM-080's display metadata, plus the two fields a library search benefits from that a diff --git a/crates/namir-library/src/error_codes.rs b/crates/namir-library/src/error_codes.rs index ebb82e3..fab39bc 100644 --- a/crates/namir-library/src/error_codes.rs +++ b/crates/namir-library/src/error_codes.rs @@ -23,6 +23,30 @@ pub const FILE_UNREADABLE: ErrorCode = ErrorCode::new( rescan; otherwise check you have permission to read it.", ); +/// NFR-SEC-020 (issue #70): a path that is not a regular file was refused without being opened. +/// A FIFO or a character device named `cab.wav` reports `len() == 0`, so a size check alone waves +/// it through, and the read that follows blocks forever (a FIFO with no writer) or never ends +/// (`/dev/zero`). Refusing by file type is the check that has to come first. +pub const FILE_NOT_REGULAR: ErrorCode = ErrorCode::new( + "library.scan.file_not_regular", + Severity::Warning, + "Something with a model or IR name is not an ordinary file, so it was skipped ({detail}).", + "Only ordinary files are indexed. If this should be a model or an impulse response, replace \ + the device, pipe or folder at that path with the file itself and rescan.", +); + +/// Issue #73: a directory symlink was not followed because its target had already been walked in +/// this same scan — the visited-target set that makes a symlink loop terminate. Not an error: the +/// files are indexed under the spelling the scan reached first. +pub const SYMLINK_NOT_FOLLOWED: ErrorCode = ErrorCode::new( + "library.scan.symlink_not_followed", + Severity::Warning, + "A shortcut was not followed because it leads somewhere this scan has already been \ + ({detail}).", + "Nothing is missing: whatever it points at is already in the library under the path the scan \ + reached first. Remove the duplicate shortcut if you would rather not see this again.", +); + /// NFR-SEC-020: a file exceeded [`crate::MAX_INDEXED_FILE_BYTES`]. Still indexed (browsable) with /// no extracted metadata — see [`crate::entry::LibraryEntry::hash`]'s doc comment. pub const FILE_TOO_LARGE: ErrorCode = ErrorCode::new( @@ -80,6 +104,8 @@ mod tests { const ALL: &[ErrorCode] = &[ DIR_UNREADABLE, FILE_UNREADABLE, + FILE_NOT_REGULAR, + SYMLINK_NOT_FOLLOWED, FILE_TOO_LARGE, INDEX_CORRUPT, INDEX_SAVE_FAILED, diff --git a/crates/namir-library/src/fs.rs b/crates/namir-library/src/fs.rs index c315598..a368f78 100644 --- a/crates/namir-library/src/fs.rs +++ b/crates/namir-library/src/fs.rs @@ -5,10 +5,11 @@ //! `namir-library/benches/library_scan.rs` (M5, later) measure the incremental-scan logic without //! materialising a 10,000-file tree for every arm. +use std::io::Read; use std::path::{Path, PathBuf}; use crate::entry::FileTime; -use crate::error::LibraryError; +use crate::error::{LibraryError, LibraryWarning}; use crate::error_codes; /// One directory entry, as [`ScanFs::read_dir`] reports it. @@ -16,9 +17,14 @@ use crate::error_codes; pub struct DirEntryInfo { /// The entry's full path. pub path: PathBuf, - /// Whether this entry is a directory (never a symlink to one — see [`StdFs::read_dir`]'s doc - /// comment on why that's true by construction, not by a check). + /// Whether this entry is a directory *without* following a symlink — so `false` for a + /// symlink that points at one. [`Self::is_dir_symlink`] is the other half of that question. pub is_dir: bool, + /// Whether this entry is a symlink (or, on Windows, a directory reparse point) whose target + /// is a directory. Issue #73: symlinking a model collection into the library root is an + /// ordinary user setup, so `scan.rs` follows these — see its `visited_link_targets` set for + /// how a loop is made to terminate now that it is no longer impossible by construction. + pub is_dir_symlink: bool, /// Byte length as the directory listing reports it. For a directory, `0` — meaningless and /// never consulted. pub size: u64, @@ -26,12 +32,44 @@ pub struct DirEntryInfo { pub mtime: FileTime, } +/// One directory's listing: what could be described, plus what could not. +/// +/// Issue #66: a per-entry failure is not a per-directory failure. One locked file, reparse point +/// or cloud placeholder used to propagate out of [`ScanFs::read_dir`] and take the whole +/// directory's listing with it — and, since the scan still reached `Step::Finished`, every +/// indexed sibling became a removal. The listing therefore reports partial success rather than +/// collapsing to `Err`, and carries enough for the caller to keep what it already had: +/// [`Self::unreadable_entries`] name the paths whose siblings must not be inferred away, and +/// [`Self::fully_enumerated`] says whether the listing can be trusted to be the whole directory. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DirListing { + /// The children that could be described. + pub entries: Vec, + /// Children that appeared in the directory but could not be described (their type or + /// metadata could not be read). Known by path, so the caller can leave whatever it already + /// knows about them alone rather than concluding they are gone. + pub unreadable_entries: Vec, + /// `false` when at least one child could not even be named — an iterator-level failure, where + /// there is no path to record in [`Self::unreadable_entries`]. The directory's listing is + /// then not known to be complete, so nothing under it may be inferred to have been deleted. + pub fully_enumerated: bool, + /// One per skipped child, ready to carry into a scan's warnings. + pub warnings: Vec, +} + /// The filesystem operations `scan.rs` needs, and nothing else — deliberately narrower than /// `std::fs` so a fake implementation is easy to write completely and correctly. pub trait ScanFs: Send + Sync { /// Lists `dir`'s immediate children. Does not recurse — the caller (`scan.rs`'s step machine) - /// owns the traversal order. - fn read_dir(&self, dir: &Path) -> Result, LibraryError>; + /// owns the traversal order. `Err` only when the *directory itself* could not be opened; a + /// child that could not be described is reported inside the [`DirListing`] instead (issue + /// #66). + fn read_dir(&self, dir: &Path) -> Result; + + /// The canonical, symlink-free form of a directory path — `scan.rs`'s cycle guard for the + /// directory symlinks it now follows (issue #73). On the port rather than called directly so + /// a fake can model a symlinked tree without one existing on disk. + fn canonical_dir(&self, dir: &Path) -> Result; /// Reads `path`'s full contents, refusing (without reading past the header) anything over /// `max_bytes` — the same NFR-SEC-020 discipline `namir_worker::LoadSource::File` already @@ -47,73 +85,167 @@ pub trait ScanFs: Send + Sync { pub struct StdFs; impl ScanFs for StdFs { - fn read_dir(&self, dir: &Path) -> Result, LibraryError> { + fn read_dir(&self, dir: &Path) -> Result { let entries = std::fs::read_dir(dir).map_err(|e| { LibraryError::new( error_codes::DIR_UNREADABLE, format!("{}: {e}", dir.display()), ) })?; - let mut out = Vec::new(); + let mut listing = DirListing { + fully_enumerated: true, + ..DirListing::default() + }; for entry in entries { - let entry = entry.map_err(|e| { - LibraryError::new( - error_codes::DIR_UNREADABLE, - format!("{}: {e}", dir.display()), - ) - })?; + // Issue #66: every failure below is *this child's* failure. Skipping it and carrying + // on is what keeps one locked file, reparse point or cloud placeholder from deleting + // the whole directory's worth of index entries. + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + // No path to record: the iterator failed before naming the child, so the + // directory is not known to have been fully enumerated. + listing.fully_enumerated = false; + listing.warnings.push(LibraryWarning::new( + error_codes::DIR_UNREADABLE, + format!("{}: {e}", dir.display()), + )); + continue; + } + }; + let path = entry.path(); // DirEntry::file_type() does not follow symlinks on any platform this workspace - // targets, which is what makes a symlink loop impossible by construction here — - // this scanner never asks "what does this link point to", only "what is this entry". - let file_type = entry.file_type().map_err(|e| { - LibraryError::new( - error_codes::DIR_UNREADABLE, - format!("{}: {e}", dir.display()), - ) - })?; - let metadata = entry.metadata().map_err(|e| { - LibraryError::new( - error_codes::DIR_UNREADABLE, - format!("{}: {e}", dir.display()), - ) - })?; - out.push(DirEntryInfo { - path: entry.path(), + // targets, so `is_dir` answers "what is this entry" and the extra metadata() call + // below answers "and what does it point at" (issue #73) — deliberately two questions + // rather than one, since only the first can be asked without touching the target. + let file_type = match entry.file_type() { + Ok(t) => t, + Err(e) => { + listing.unreadable_entries.push(path.clone()); + listing.warnings.push(LibraryWarning::new( + error_codes::FILE_UNREADABLE, + format!("{}: {e}", path.display()), + )); + continue; + } + }; + let metadata = match entry.metadata() { + Ok(m) => m, + Err(e) => { + listing.unreadable_entries.push(path.clone()); + listing.warnings.push(LibraryWarning::new( + error_codes::FILE_UNREADABLE, + format!("{}: {e}", path.display()), + )); + continue; + } + }; + // Only asked of an entry that is a link, and only ever "is the target a directory" — + // a link whose target is missing simply answers no, which is the right answer. + let is_dir_symlink = file_type.is_symlink() + && std::fs::metadata(&path) + .map(|m| m.is_dir()) + .unwrap_or(false); + listing.entries.push(DirEntryInfo { + path, is_dir: file_type.is_dir(), + is_dir_symlink, size: metadata.len(), mtime: FileTime::from_system_time( metadata.modified().unwrap_or(std::time::UNIX_EPOCH), ), }); } - Ok(out) + Ok(listing) + } + + fn canonical_dir(&self, dir: &Path) -> Result { + std::fs::canonicalize(dir).map_err(|e| { + LibraryError::new( + error_codes::DIR_UNREADABLE, + format!("{}: {e}", dir.display()), + ) + }) } fn read_file(&self, path: &Path, max_bytes: usize) -> Result, LibraryError> { + // Issue #70, first half: the file *type* is checked before anything is opened. On Unix + // `File::open` on a FIFO blocks until a writer appears, so a bounded read cannot rescue a + // pipe named `cab.wav` — only not opening it can. The residual race (a regular file + // replaced by a device between this call and the open below) is a narrower one than the + // size race this method used to run, and the only alternative is a per-platform + // `O_NONBLOCK` open, which D-5.2's cfg lint reserves for `namir-platform`. let meta = std::fs::metadata(path).map_err(|e| { LibraryError::new( error_codes::FILE_UNREADABLE, format!("{}: {e}", path.display()), ) })?; - if meta.len() as usize > max_bytes { + if !meta.is_file() { return Err(LibraryError::new( - error_codes::FILE_TOO_LARGE, - format!( - "{}: {} bytes, limit {} MB", - path.display(), - meta.len(), - max_bytes / (1024 * 1024) - ), + error_codes::FILE_NOT_REGULAR, + format!("{}: not a regular file", path.display()), )); } - std::fs::read(path).map_err(|e| { + // A cheap early reject for a file that is already known to be too big, so a 4 GB WAV is + // not read up to the ceiling just to be refused. It is an optimisation, not the check: + // the bound below is what actually holds, whatever this file's length does next. + if meta.len() as usize > max_bytes { + return Err(too_large(path, meta.len(), max_bytes)); + } + + let file = std::fs::File::open(path).map_err(|e| { LibraryError::new( error_codes::FILE_UNREADABLE, format!("{}: {e}", path.display()), ) - }) + })?; + read_bounded(file, path, max_bytes, meta.len() as usize) + } +} + +fn too_large(path: &Path, len: u64, max_bytes: usize) -> LibraryError { + LibraryError::new( + error_codes::FILE_TOO_LARGE, + format!( + "{}: {} bytes, limit {} MB", + path.display(), + len, + max_bytes / (1024 * 1024) + ), + ) +} + +/// Issue #70, second half: NFR-SEC-020's ceiling enforced on the **read** rather than on a +/// `metadata()` call taken beforehand. +/// +/// The old shape — stat, compare, then `std::fs::read` — is a time-of-check/time-of-use gap: a +/// file that grows between the two calls is read into memory in full, past the limit, and a +/// character device that reports `len() == 0` streams forever. Reading through +/// `Read::take(max_bytes + 1)` makes the ceiling structural: one byte past the limit is the most +/// that can ever be in memory, and its presence is what proves the file was over it. +/// +/// `capacity_hint` is the length the file claimed a moment ago — a sizing hint only, clamped to +/// the ceiling, so an ordinary read still makes one allocation rather than growing through a dozen. +/// Being wrong about it costs a reallocation, never a byte past the bound. +fn read_bounded( + reader: impl Read, + path: &Path, + max_bytes: usize, + capacity_hint: usize, +) -> Result, LibraryError> { + let mut bytes = Vec::with_capacity(capacity_hint.min(max_bytes) + 1); + let mut limited = reader.take(max_bytes as u64 + 1); + limited.read_to_end(&mut bytes).map_err(|e| { + LibraryError::new( + error_codes::FILE_UNREADABLE, + format!("{}: {e}", path.display()), + ) + })?; + if bytes.len() > max_bytes { + return Err(too_large(path, bytes.len() as u64, max_bytes)); } + Ok(bytes) } /// An in-memory [`ScanFs`], `pub(crate)` so `scan.rs`'s own tests can reach it too — a controlled @@ -124,6 +256,12 @@ impl ScanFs for StdFs { pub(crate) struct FakeFs { dirs: std::collections::HashMap>, files: std::collections::HashMap>, + /// Children that appear in a listing but cannot be described — issue #66's locked file. + unreadable: std::collections::HashMap>, + /// Directories whose listing could not even be enumerated to the end. + partial: std::collections::HashSet, + /// Where a directory symlink points, for [`ScanFs::canonical_dir`] — issue #73. + link_targets: std::collections::HashMap, } #[cfg(test)] @@ -151,49 +289,175 @@ impl FakeFs { .push(DirEntryInfo { path: path.clone(), is_dir: false, + is_dir_symlink: false, size, mtime, }); self.files.insert(path.clone(), bytes); path } + + /// Registers `name` as a subdirectory of `parent` that appears in `parent`'s listing but + /// cannot be listed itself — the offline volume or ACL-restricted folder of issue #65. + pub(crate) fn add_unlistable_dir(&mut self, parent: &Path, name: &str) -> PathBuf { + let path = parent.join(name); + self.dirs + .entry(parent.to_path_buf()) + .or_default() + .push(DirEntryInfo { + path: path.clone(), + is_dir: true, + is_dir_symlink: false, + size: 0, + mtime: FileTime::from_system_time(std::time::UNIX_EPOCH), + }); + path + } + + /// Registers `name` in `parent` as a symlink to the directory `target` — listed like a + /// directory, canonicalising to `target` (issue #73). + pub(crate) fn add_dir_symlink(&mut self, parent: &Path, name: &str, target: &Path) -> PathBuf { + let path = parent.join(name); + self.dirs + .entry(parent.to_path_buf()) + .or_default() + .push(DirEntryInfo { + path: path.clone(), + is_dir: false, + is_dir_symlink: true, + size: 0, + mtime: FileTime::from_system_time(std::time::UNIX_EPOCH), + }); + self.link_targets.insert(path.clone(), target.to_path_buf()); + path + } + + /// Registers `name` as a child of `parent` that appears in the listing but cannot be + /// described — issue #66's locked file, cloud placeholder or reparse point. + pub(crate) fn add_unreadable_entry(&mut self, parent: &Path, name: &str) -> PathBuf { + let path = parent.join(name); + self.dirs.entry(parent.to_path_buf()).or_default(); + self.unreadable + .entry(parent.to_path_buf()) + .or_default() + .push(path.clone()); + path + } + + /// Marks `dir`'s listing as one that could not be enumerated to the end — the iterator-level + /// failure, where the skipped child has no path to record. + pub(crate) fn mark_partial(&mut self, dir: &Path) { + self.dirs.entry(dir.to_path_buf()).or_default(); + self.partial.insert(dir.to_path_buf()); + } + + /// `path` with every registered symlink on the way substituted for its target, repeatedly — + /// what a real `canonicalize` does, and what makes `read_dir` on a link path return the + /// target's children re-rooted under the link, as the real one does. Bounded, so a fake tree + /// with a link cycle in it terminates here too rather than hanging the test that built it. + fn resolve(&self, path: &Path) -> PathBuf { + let mut current = path.to_path_buf(); + for _ in 0..16 { + let mut ancestor = current.clone(); + let mut stripped: Vec = Vec::new(); + let mut substituted = None; + loop { + if let Some(target) = self.link_targets.get(&ancestor) { + let mut rebuilt = target.clone(); + for name in stripped.iter().rev() { + rebuilt.push(name); + } + substituted = Some(rebuilt); + break; + } + match (ancestor.parent(), ancestor.file_name()) { + (Some(parent), Some(name)) => { + stripped.push(name.to_os_string()); + ancestor = parent.to_path_buf(); + } + _ => break, + } + } + match substituted { + Some(next) => current = next, + None => break, + } + } + current + } } #[cfg(test)] impl ScanFs for FakeFs { - fn read_dir(&self, dir: &Path) -> Result, LibraryError> { - self.dirs.get(dir).cloned().ok_or_else(|| { - LibraryError::new( + fn read_dir(&self, dir: &Path) -> Result { + // A real read_dir follows the link and reports the target's children under the path it + // was asked about, not under the target's own path. + let real = self.resolve(dir); + let entries: Vec = self + .dirs + .get(&real) + .cloned() + .ok_or_else(|| { + LibraryError::new( + error_codes::DIR_UNREADABLE, + format!("{}: not in fake", dir.display()), + ) + })? + .into_iter() + .map(|mut e| { + if let Some(name) = e.path.file_name() { + e.path = dir.join(name); + } + e + }) + .collect(); + let unreadable_entries = self.unreadable.get(&real).cloned().unwrap_or_default(); + let mut warnings: Vec = unreadable_entries + .iter() + .map(|p| { + LibraryWarning::new( + error_codes::FILE_UNREADABLE, + format!("{}: not describable in fake", p.display()), + ) + }) + .collect(); + let fully_enumerated = !self.partial.contains(&real); + if !fully_enumerated { + warnings.push(LibraryWarning::new( error_codes::DIR_UNREADABLE, - format!("{}: not in fake", dir.display()), - ) + format!("{}: listing truncated in fake", dir.display()), + )); + } + Ok(DirListing { + entries, + unreadable_entries, + fully_enumerated, + warnings, }) } + fn canonical_dir(&self, dir: &Path) -> Result { + Ok(self.resolve(dir)) + } + fn read_file(&self, path: &Path, max_bytes: usize) -> Result, LibraryError> { // Mirrors StdFs's own ceiling check against the *claimed* size (the DirEntryInfo added // via add_file), not the real byte count -- this is what lets a test simulate an // oversized file cheaply, with a small `bytes` payload standing in for content nobody // needs to actually read. + let real = self.resolve(path); let claimed_size = self .dirs .values() .flatten() - .find(|e| e.path == path) + .find(|e| e.path == real) .map(|e| e.size); if let Some(size) = claimed_size && size as usize > max_bytes { - return Err(LibraryError::new( - error_codes::FILE_TOO_LARGE, - format!( - "{}: {size} bytes, limit {} MB", - path.display(), - max_bytes / (1024 * 1024) - ), - )); + return Err(too_large(path, size, max_bytes)); } - self.files.get(path).cloned().ok_or_else(|| { + self.files.get(&real).cloned().ok_or_else(|| { LibraryError::new( error_codes::FILE_UNREADABLE, format!("{}: not in fake", path.display()), @@ -223,13 +487,24 @@ mod tests { std::fs::write(dir.join("a.txt"), b"hello").unwrap(); std::fs::create_dir(dir.join("sub")).unwrap(); - let entries = StdFs.read_dir(&dir).unwrap(); - assert_eq!(entries.len(), 2); - let file = entries.iter().find(|e| e.path.ends_with("a.txt")).unwrap(); + let listing = StdFs.read_dir(&dir).unwrap(); + assert!(listing.fully_enumerated); + assert!(listing.unreadable_entries.is_empty()); + assert_eq!(listing.entries.len(), 2); + let file = listing + .entries + .iter() + .find(|e| e.path.ends_with("a.txt")) + .unwrap(); assert!(!file.is_dir); assert_eq!(file.size, 5); - let sub = entries.iter().find(|e| e.path.ends_with("sub")).unwrap(); + let sub = listing + .entries + .iter() + .find(|e| e.path.ends_with("sub")) + .unwrap(); assert!(sub.is_dir); + assert!(!sub.is_dir_symlink); let _ = std::fs::remove_dir_all(&dir); } @@ -261,4 +536,54 @@ mod tests { assert_eq!(err.code.id, error_codes::FILE_TOO_LARGE.id); let _ = std::fs::remove_dir_all(&dir); } + /// **Issue #70:** a path that is not a regular file must be refused by name, before it is + /// opened. On Unix a FIFO or character device named `foo.wav` reports `len() == 0`, passes the + /// size ceiling, and then blocks or streams forever inside the read. A directory stands in for + /// the whole class portably. + #[test] + fn a_non_regular_file_is_refused_as_such() { + let dir = temp_dir("not_regular"); + let err = StdFs.read_file(&dir, 1024).unwrap_err(); + assert_eq!(err.code.id, error_codes::FILE_NOT_REGULAR.id); + let _ = std::fs::remove_dir_all(&dir); + } + /// **Issue #70:** the ceiling is enforced on the read itself, so a reader that never ends — + /// `/dev/zero`, or a file being appended to right now — is stopped at the limit rather than + /// filling memory. `io::repeat` is that reader, without needing a device node or a race. + #[test] + fn a_reader_that_never_ends_is_stopped_at_the_ceiling() { + let err = read_bounded(std::io::repeat(0), Path::new("endless.wav"), 4096, 0).unwrap_err(); + assert_eq!(err.code.id, error_codes::FILE_TOO_LARGE.id); + } + + /// The bound is not off by one in the other direction: a file of exactly the ceiling is read. + #[test] + fn a_reader_of_exactly_the_ceiling_is_accepted_whole() { + let bytes = read_bounded( + std::io::repeat(7).take(4096), + Path::new("exact.wav"), + 4096, + 4096, + ) + .expect("a file of exactly the limit is within it"); + assert_eq!(bytes.len(), 4096); + assert!(bytes.iter().all(|b| *b == 7)); + } + + /// **Issue #66:** one child that cannot be described does not take its siblings with it. The + /// per-entry failure is reported by path, and the listing still says it saw the whole + /// directory — which is what lets the scanner keep the siblings *and* the failed child. + #[test] + fn std_fs_read_dir_reports_a_whole_directory_even_though_one_child_could_fail() { + let dir = temp_dir("per_entry"); + std::fs::write(dir.join("a.txt"), b"hello").unwrap(); + std::fs::write(dir.join("b.txt"), b"world").unwrap(); + + let listing = StdFs.read_dir(&dir).unwrap(); + assert_eq!(listing.entries.len(), 2); + assert!(listing.fully_enumerated); + assert!(listing.warnings.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/namir-library/src/index.rs b/crates/namir-library/src/index.rs index c2ca499..d72cbee 100644 --- a/crates/namir-library/src/index.rs +++ b/crates/namir-library/src/index.rs @@ -15,20 +15,43 @@ use crate::favourites::Favourites; /// depends on sorted order the way `namir-state`'s JSON output does. #[derive(Debug, Clone, Default)] pub struct Index { - by_path: BTreeMap, + by_path: BTreeMap, by_hash: HashMap>, - /// When the most recent *complete* scan finished, if any. `scan.rs`'s `Scanner` consults this + /// When the most recent *complete* scan **began**, if any. `scan.rs`'s `Scanner` consults this /// to close D-12.1's mtime-settling gap — see that module's doc comment on why a file whose /// mtime lands close to this timestamp is rehashed regardless of whether it matches the /// stored `(size, mtime)`. Persisted by `store.rs` alongside the entries, so the protection /// survives a restart rather than resetting to "no prior scan" every time the process starts. - last_scan_completed_at: Option, + /// + /// The scan's *start*, not its completion, since issue #67: the window has to cover every + /// file's examination time, and on any scan longer than the window itself those are nowhere + /// near the moment it finished. + last_scan_started_at: Option, /// FR-LIB-050's favourite marks, keyed by content hash — kept alongside the entries here - /// (rather than a separate file) since AQ-3's single-document store already exists and a - /// second small file would just be a second thing that can go missing independently. + /// since AQ-3's single-document store already exists. + /// + /// This field used to add that a separate file "would just be a second thing that can go + /// missing independently". Issue #68 is that the trade-off runs the other way: co-location + /// made them go missing *together*, and the index's documented corruption policy is to + /// discard everything and rescan — which rebuilds the entries and permanently destroys marks + /// no scan can reconstruct. `store.rs` therefore mirrors them to a sidecar document and + /// recovers them from it when the index cannot be read; this stays their in-memory home. favourites: Favourites, } +/// One indexed entry plus the lowercase text [`crate::search`] matches against. +/// +/// Issue #72: `Cargo.toml`'s rationale for taking no search-index dependency is "a linear scan +/// over a precomputed lowercase blob", and it was not precomputed — `filter` allocated a `String` +/// and ran a full Unicode case-fold per entry per call, i.e. ten thousand allocations on every +/// keystroke. Folding once here, where an entry enters the index, is what makes that sentence +/// true; the cost moves to `upsert`, which happens once per changed file per scan. +#[derive(Debug, Clone)] +struct Indexed { + entry: LibraryEntry, + folded: String, +} + impl Index { /// An index with nothing in it. pub fn empty() -> Self { @@ -47,12 +70,19 @@ impl Index { /// The entry at `path`, if indexed. pub fn get(&self, path: &Path) -> Option<&LibraryEntry> { - self.by_path.get(path) + self.by_path.get(path).map(|i| &i.entry) } /// Every entry, in path order. pub fn iter(&self) -> impl Iterator { - self.by_path.values() + self.by_path.values().map(|i| &i.entry) + } + + /// Every entry, in path order, paired with the precomputed lowercase text + /// [`crate::search::filter`] matches a query against (issue #72). `pub(crate)`: the folded + /// form is this crate's own search mechanism, not part of an entry's identity. + pub(crate) fn iter_searchable(&self) -> impl Iterator { + self.by_path.values().map(|i| (&i.entry, i.folded.as_str())) } /// D-11.3's consequence note, as an API: every path recorded under `hash`. More than one @@ -68,7 +98,7 @@ impl Index { /// pointing at different content (FR-LIB-070's "files that change"). pub fn upsert(&mut self, entry: LibraryEntry) { if let Some(previous) = self.by_path.get(&entry.path) { - self.remove_from_hash_bucket(previous.hash, &entry.path); + self.remove_from_hash_bucket(previous.entry.hash, &entry.path); } if let Some(hash) = entry.hash { self.by_hash @@ -76,13 +106,18 @@ impl Index { .or_default() .push(entry.path.clone()); } - self.by_path.insert(entry.path.clone(), entry); + // Folded here, once, rather than per search call -- issue #72. Recomputed on every + // upsert, so a rescan that changes an entry's metadata can never leave a stale blob + // behind, which is the one hazard a cache of derived text has. + let folded = crate::search::searchable_text(&entry); + self.by_path + .insert(entry.path.clone(), Indexed { entry, folded }); } /// Removes the entry at `path`, if any (FR-LIB-070's "files that disappear"). pub fn remove(&mut self, path: &Path) { - if let Some(entry) = self.by_path.remove(path) { - self.remove_from_hash_bucket(entry.hash, path); + if let Some(indexed) = self.by_path.remove(path) { + self.remove_from_hash_bucket(indexed.entry.hash, path); } } @@ -96,16 +131,17 @@ impl Index { } } - /// When the most recent complete scan finished, if any — `None` before this index has ever + /// When the most recent complete scan began, if any — `None` before this index has ever /// finished a scan. - pub fn last_scan_completed_at(&self) -> Option { - self.last_scan_completed_at + pub fn last_scan_started_at(&self) -> Option { + self.last_scan_started_at } - /// Records that a scan completed at `at`. `store.rs` persists this; `scan.rs`'s `Scanner` - /// reads it back via [`Self::last_scan_completed_at`] on the *next* scan. - pub(crate) fn set_last_scan_completed_at(&mut self, at: FileTime) { - self.last_scan_completed_at = Some(at); + /// Records that the scan whose results were just applied began at `at`. `store.rs` persists + /// this; `scan.rs`'s `Scanner` reads it back via [`Self::last_scan_started_at`] on the *next* + /// scan. + pub(crate) fn set_last_scan_started_at(&mut self, at: FileTime) { + self.last_scan_started_at = Some(at); } /// FR-LIB-050's favourite marks. `&mut` access, not a wrapper method per mark/unmark, since @@ -199,4 +235,52 @@ mod tests { // No hash was ever registered, so there's nothing meaningful to search for -- this test // just confirms upsert(None) doesn't panic or register a bogus bucket. } + /// **Issue #72:** the lowercase text `search::filter` matches against is computed where an + /// entry enters the index, not per entry per call — that is what `Cargo.toml`'s "a linear scan + /// over a precomputed lowercase blob" claims, and it was not true while `filter` allocated a + /// `String` and ran a full Unicode case-fold for every entry on every keystroke. + /// + /// The hazard a cache of derived text brings with it is staleness, so the re-upsert half + /// matters as much as the first: a rescan that changes a file's metadata must not leave the + /// old text searchable. + #[test] + fn the_searchable_text_is_precomputed_and_never_stale() { + use crate::entry::NamItemMetadata; + + let named = |name: &str| LibraryEntry { + path: PathBuf::from("cabs/Plexi.nam"), + kind: ItemKind::Nam, + size: 10, + mtime: FileTime::now(), + hash: None, + metadata: ItemMetadata::Nam(NamItemMetadata { + architecture: "WaveNet".to_string(), + sample_rate: Some(48_000), + name: name.to_string(), + modeled_by: String::new(), + gear_type: String::new(), + tone_type: String::new(), + description: String::new(), + }), + origin: Origin::Local, + }; + + let mut index = Index::empty(); + index.upsert(named("Before")); + let (_, folded) = index.iter_searchable().next().unwrap(); + assert!(folded.contains("plexi"), "the file stem, folded: {folded}"); + assert!(folded.contains("before"), "the metadata, folded: {folded}"); + assert!( + !folded.contains("Plexi"), + "the stored text is already lowercase, so a search folds nothing per call: {folded}" + ); + + index.upsert(named("After")); + let (_, folded) = index.iter_searchable().next().unwrap(); + assert!(folded.contains("after")); + assert!( + !folded.contains("before"), + "a re-upsert must refold, or the index would answer searches from the old metadata" + ); + } } diff --git a/crates/namir-library/src/lib.rs b/crates/namir-library/src/lib.rs index 5787ebd..a2ad09d 100644 --- a/crates/namir-library/src/lib.rs +++ b/crates/namir-library/src/lib.rs @@ -46,7 +46,7 @@ pub use entry::{ }; pub use error::{LibraryError, LibraryWarning}; pub use favourites::Favourites; -pub use fs::{DirEntryInfo, ScanFs, StdFs}; +pub use fs::{DirEntryInfo, DirListing, ScanFs, StdFs}; pub use index::Index; pub use probe::{kind_from_extension, probe}; pub use resolver::{LibraryResolver, RootsOnlyResolver}; diff --git a/crates/namir-library/src/scan.rs b/crates/namir-library/src/scan.rs index aaf6ddb..285a0d7 100644 --- a/crates/namir-library/src/scan.rs +++ b/crates/namir-library/src/scan.rs @@ -28,6 +28,19 @@ //! that a path it never reached is gone. Treating "not seen this run" as "deleted" on a cancelled //! scan would silently empty a user's library, violating both P8 and FR-LIB-070's intent — so a //! caller must check `complete` before acting on `removals`. +//! +//! **`complete` is not the whole of that rule (issue #65).** It says the queue drained, which is +//! not the same as "the tree was walked": a directory the scan was *refused* — an offline volume, +//! an ACL-restricted folder, a listing that ended early — leaves the walk able to reach +//! `Step::Finished` with a whole subtree never looked at. Concluding removals from that erases +//! every entry under it, on every scan, permanently; with a single root that is the entire index, +//! which is the same silent-erasure failure `LibraryService::open_default`'s zero-roots bug +//! caused through a different door. So a directory that could not be listed, or could not be +//! listed to the end, records its path in [`ScanDelta::unreadable_prefixes`], and +//! [`Scanner::take_delta`] excludes everything beneath those prefixes from `removals`. The scan +//! is still `complete` — the rest of the tree *was* walked, and a file genuinely deleted +//! elsewhere is still reported — but the part nobody could see degrades to "keep what we had and +//! warn" rather than "assume it is gone". use std::collections::{HashSet, VecDeque}; use std::path::{Path, PathBuf}; @@ -76,6 +89,13 @@ pub struct ScanDelta { /// `false` if the scan was cancelled (the caller stopped calling [`Scanner::step`]) before /// every directory was expanded. pub complete: bool, + /// Directories this scan could not see inside (issue #65): unlistable, listed only in part, + /// or a symlink whose target could not be resolved. Nothing under one of these paths appears + /// in [`Self::removals`], however `complete` this run was — see this module's doc comment. + pub unreadable_prefixes: Vec, + /// When this scan *started*, for D-12.1's mtime-settling baseline (issue #67). `None` only on + /// a hand-built delta; [`Scanner::take_delta`] always fills it in. + pub scan_started_at: Option, } impl Index { @@ -91,15 +111,16 @@ impl Index { for path in delta.removals { self.remove(&path); } - // D-12.1's mtime-settling protection: this scan's completion time becomes the - // baseline the *next* scan's Scanner::new reads back, via last_scan_completed_at(). - self.set_last_scan_completed_at(FileTime::now()); + // D-12.1's mtime-settling protection: this scan's *start* time becomes the baseline + // the next scan's Scanner::new reads back, via last_scan_started_at() — issue #67, + // and see MTIME_SETTLING_WINDOW_NANOS for why the start and not the finish. + self.set_last_scan_started_at(delta.scan_started_at.unwrap_or_else(FileTime::now)); } } } /// D-12.1's mtime-settling window (`docs/02-architecture.md` §12's M5 consequence note): a file -/// whose mtime lands within this much of the *previous* scan's completion time is rehashed +/// whose mtime is no older than this much before the *previous* scan began is rehashed /// unconditionally, even if its `(size, mtime)` otherwise matches what's on record. NTFS's /// documented resolution is 100 ns, but observed real-world granularity (buffered writes, /// FAT-formatted removable volumes, network shares) runs from roughly one to two seconds — this @@ -107,6 +128,24 @@ impl Index { /// a false positive (one unnecessary rehash) is far smaller than the cost of a false negative (a /// genuine edit silently invisible to every future scan, not just the next one, since the stored /// `(size, mtime)` would then match the *new* content and the file would look unchanged forever). +/// +/// # Why the previous scan's *start*, and why one-sided (issue #67) +/// +/// The ambiguity this window exists to cover is around each file's own **examination**, not +/// around the moment the scan happened to finish. A file examined at `t` and edited moments later +/// can be reported with the same mtime it already had — that is the whole false negative — and +/// `t` is anywhere inside the previous scan, which on a cold library is far longer than two +/// seconds. Anchored to completion, only the handful of files examined in the last two seconds of +/// a scan were ever protected; every file examined earlier fell outside the window by exactly the +/// amount of scan that came after it. +/// +/// Recording the previous scan's start time and asking `mtime >= start - WINDOW` covers every +/// examination time in that scan, since `start <= t` for all of them. It is one-sided for the +/// same reason: an mtime *after* the previous scan is a file written during or since that scan +/// and is exactly as suspect, whereas an mtime comfortably older than the scan's start cannot +/// have been overwritten inside its own granularity by an edit that came after it was examined. +/// The set of files this rehashes shrinks to nothing as the baseline advances — a file untouched +/// since a scan or two ago falls out of it and stays out. const MTIME_SETTLING_WINDOW_NANOS: i128 = 2_000_000_000; /// The caller-pumped scan step machine. See this module's doc comment. @@ -117,12 +156,21 @@ pub struct Scanner { /// once from the `prior` snapshot passed to [`Self::new`] — this scanner never mutates the /// caller's index directly, it only reads from this copy. prior: std::collections::HashMap, - /// When the scan that produced `prior` finished, if known — the baseline + /// When the scan that produced `prior` **started**, if known — the baseline /// [`MTIME_SETTLING_WINDOW_NANOS`] is measured against. `None` for the very first scan of a /// fresh index, in which case every file is genuinely new and the settling window has nothing /// to protect against. - prior_scan_completed_at: Option, + prior_scan_started_at: Option, + /// When *this* scan started, recorded at construction so it survives into the delta and + /// becomes the next scan's baseline (issue #67). + started_at: FileTime, seen: HashSet, + /// Canonical paths of every directory this scan has expanded, plus the targets of every + /// directory symlink it has followed — issue #73's cycle guard. Consulted only when deciding + /// whether to follow a symlink, so an ordinary directory is never skipped for being in it; + /// each distinct target is followed at most once, so a link that leads back into the tree (or + /// to another link that does) terminates instead of recursing forever. + visited_dirs: HashSet, delta: ScanDelta, files_examined: usize, files_hashed: usize, @@ -141,23 +189,25 @@ impl Scanner { pending_dirs: roots.into_iter().collect(), pending_files: VecDeque::new(), prior: prior_map, - prior_scan_completed_at: prior.last_scan_completed_at(), + prior_scan_started_at: prior.last_scan_started_at(), + started_at: FileTime::now(), seen: HashSet::new(), + visited_dirs: HashSet::new(), delta: ScanDelta::default(), files_examined: 0, files_hashed: 0, } } - /// Whether `mtime` falls close enough to the previous scan's completion time that it might - /// belong to an edit this scan's `(size, mtime)` comparison alone cannot distinguish from "no - /// change" — see [`MTIME_SETTLING_WINDOW_NANOS`]. + /// Whether `mtime` is recent enough, relative to when the previous scan *began*, that it + /// might belong to an edit this scan's `(size, mtime)` comparison alone cannot distinguish + /// from "no change" — see [`MTIME_SETTLING_WINDOW_NANOS`]. fn within_settling_window(&self, mtime: FileTime) -> bool { - let Some(completed_at) = self.prior_scan_completed_at else { + let Some(started_at) = self.prior_scan_started_at else { return false; }; - let delta = mtime.as_nanos_since_epoch() - completed_at.as_nanos_since_epoch(); - delta.abs() <= MTIME_SETTLING_WINDOW_NANOS + mtime.as_nanos_since_epoch() + >= started_at.as_nanos_since_epoch() - MTIME_SETTLING_WINDOW_NANOS } fn progress(&self) -> ScanProgress { @@ -186,16 +236,36 @@ impl Scanner { } fn expand_dir(&mut self, fs: &dyn ScanFs, dir: &Path) { - let entries = match fs.read_dir(dir) { - Ok(entries) => entries, + // Recorded before the listing, so a symlink *inside* this directory that points back at + // it is recognised straight away rather than expanding a second copy of it (issue #73). + // A directory that cannot be canonicalised simply isn't recorded — the guard degrades to + // "follow the link", which still terminates, rather than to "skip it". + if let Ok(canonical) = fs.canonical_dir(dir) { + self.visited_dirs.insert(canonical); + } + let listing = match fs.read_dir(dir) { + Ok(listing) => listing, Err(e) => { + // Issue #65: nobody looked inside, so nothing inside may be inferred to be gone. self.delta .warnings .push(LibraryWarning::new(e.code, e.detail)); + self.delta.unreadable_prefixes.push(dir.to_path_buf()); return; } }; - for entry in entries { + self.delta.warnings.extend(listing.warnings); + // Issue #66: a child that could not be described is skipped, not fatal -- but it was + // *seen*, so whatever the index already knows about it stays. Only a path nobody + // encountered is a removal. + for path in listing.unreadable_entries { + self.seen.insert(path); + } + if !listing.fully_enumerated { + // A listing that ended early is not evidence about the children it never reached. + self.delta.unreadable_prefixes.push(dir.to_path_buf()); + } + for entry in listing.entries { // Non-UTF-8 paths are not indexed. serde_json can only serialise a PathBuf that is // valid UTF-8 (store.rs's on-disk format is JSON text), and reconstructing an // OsString from arbitrary bytes needs platform-specific APIs D-5.2's cfg lint @@ -213,6 +283,10 @@ impl Scanner { self.pending_dirs.push_back(entry.path.clone()); continue; } + if entry.is_dir_symlink { + self.expand_dir_symlink(fs, &entry.path); + continue; + } if probe::kind_from_extension(&entry.path).is_some() { self.pending_files.push_back(entry); } @@ -221,6 +295,42 @@ impl Scanner { } } + /// Issue #73: a symlink to a directory is followed, guarded by a visited set of canonical + /// targets. + /// + /// Not following one was never a decision, only a side effect of asking `file_type()` (which + /// does not follow links) and nothing else — and its cost was never recorded: a user who + /// symlinks a model collection into the library root saw an empty library and no diagnostic + /// at all, which is an entirely ordinary setup on Linux and macOS. Following it makes that + /// setup work; the visited set is what replaces the loop-safety the old shape got for free. + /// Each canonical target is followed at most once, so a link that points at an ancestor, at a + /// sibling that points back, or at itself is expanded once and then recognised and skipped — + /// the walk always terminates, and the second spelling is reported rather than silently + /// dropped. + fn expand_dir_symlink(&mut self, fs: &dyn ScanFs, link: &Path) { + let canonical = match fs.canonical_dir(link) { + Ok(canonical) => canonical, + Err(e) => { + self.delta + .warnings + .push(LibraryWarning::new(e.code, e.detail)); + self.delta.unreadable_prefixes.push(link.to_path_buf()); + return; + } + }; + if !self.visited_dirs.insert(canonical) { + self.delta.warnings.push(LibraryWarning::new( + error_codes::SYMLINK_NOT_FOLLOWED, + format!("{}", link.display()), + )); + // Whatever was indexed under this spelling on an earlier scan is still on disk; this + // scan simply reached it by another name. Not a removal. + self.delta.unreadable_prefixes.push(link.to_path_buf()); + return; + } + self.pending_dirs.push_back(link.to_path_buf()); + } + fn examine_file(&mut self, fs: &dyn ScanFs, info: DirEntryInfo) { self.files_examined += 1; self.seen.insert(info.path.clone()); @@ -286,13 +396,19 @@ impl Scanner { /// computed once `Step::Finished` is reached, at which point `take_delta` fills them in from /// `prior`'s paths that were never `seen`). pub fn take_delta(mut self) -> ScanDelta { + self.delta.scan_started_at = Some(self.started_at); if self.delta.complete { + let unreadable = std::mem::take(&mut self.delta.unreadable_prefixes); self.delta.removals = self .prior .keys() .filter(|p| !self.seen.contains(*p)) + // Issue #65: a path under a directory this scan could not see inside was never + // looked for, so its absence from `seen` is not evidence of anything. + .filter(|p| !unreadable.iter().any(|prefix| p.starts_with(prefix))) .cloned() .collect(); + self.delta.unreadable_prefixes = unreadable; } self.delta } @@ -666,4 +782,338 @@ mod tests { "the new content's hash must be recorded" ); } + /// **Issue #65:** a directory that cannot be listed must not let the scan conclude that + /// everything under it is gone. + /// + /// `complete` used to mean only that the queue drained. An ACL-restricted folder was warned + /// about and skipped, the walk still reached `Step::Finished`, and every path under it became + /// a removal — on every scan, so those entries were dropped and never came back. + #[test] + fn an_unreadable_directory_does_not_erase_its_subtree() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + let locked = root.join("locked"); + + // A prior index holding one entry inside the directory that will fail to list. + let mut index = Index::empty(); + index.upsert(LibraryEntry { + path: locked.join("a.nam"), + kind: crate::entry::ItemKind::Nam, + size: 10, + mtime: FileTime::now(), + hash: None, + metadata: crate::entry::ItemMetadata::None, + origin: Origin::Local, + }); + + // The root lists `locked` as a directory, but `locked` itself is not registered in the + // fake -- read_dir on it fails, exactly as an ACL-restricted folder does. + let mut fake = FakeFs::new(); + fake.add_unlistable_dir(&root, "locked"); + + let delta = Scanner::new(vec![root.clone()], &index).run_to_completion(&fake); + assert_eq!( + delta.warnings.len(), + 1, + "the unreadable directory must be reported" + ); + assert!( + delta.removals.is_empty(), + "a directory that could not be listed must not make its contents look deleted: {:?}", + delta.removals + ); + + index.apply(delta); + assert_eq!(index.len(), 1, "the subtree must survive"); + } + /// **Issue #65, the whole-index form.** The same defect with a single root — the shape both + /// product shells actually run, since `LibraryService::open_at` hard-codes one — is the + /// historical zero-roots erasure through another door: an offline volume or a permissions + /// change makes `read_dir` on the sole root fail, and a `complete` scan then reports the + /// *entire* index as removals, which is saved over the shared index file. + #[test] + fn an_unreadable_sole_root_never_reports_the_whole_index_as_removals() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + + let mut index = Index::empty(); + for name in ["a.nam", "b.nam", "c.wav"] { + index.upsert(LibraryEntry { + path: root.join(name), + kind: crate::entry::ItemKind::Nam, + size: 10, + mtime: FileTime::now(), + hash: None, + metadata: crate::entry::ItemMetadata::None, + origin: Origin::Local, + }); + } + + // Nothing registered at all: read_dir on the root itself fails. + let delta = Scanner::new(vec![root.clone()], &index).run_to_completion(&FakeFs::new()); + assert_eq!(delta.warnings.len(), 1); + assert_eq!(delta.unreadable_prefixes, vec![root.clone()]); + assert!( + delta.removals.is_empty(), + "a root that could not be listed must not empty the index: {:?}", + delta.removals + ); + + index.apply(delta); + assert_eq!(index.len(), 3, "the index must survive an unreadable root"); + } + + /// **Issue #66:** one child that cannot be described is not a reason to lose the directory. + /// + /// The port used to propagate a per-entry failure out of `read_dir` with `?`, so one locked + /// file, reparse point or cloud placeholder made the whole directory unlistable — and, via + /// issue #65's `complete`, turned every indexed sibling into a removal. The child itself must + /// not be inferred away either: it was seen, it just could not be described. + /// + /// A genuinely deleted third file is still reported, so this is a precise degradation rather + /// than "warn once and stop concluding anything". + #[test] + fn one_undescribable_child_costs_neither_its_siblings_nor_itself() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + + let mut index = Index::empty(); + for name in ["kept.nam", "locked.nam", "gone.nam"] { + index.upsert(LibraryEntry { + path: root.join(name), + kind: crate::entry::ItemKind::Nam, + size: 1, + mtime: FileTime::from_system_time(std::time::UNIX_EPOCH), + hash: None, + metadata: crate::entry::ItemMetadata::None, + origin: Origin::Local, + }); + } + + let mut fake = FakeFs::new(); + fake.add_file( + &root, + "kept.nam", + 1, + FileTime::from_system_time(std::time::UNIX_EPOCH), + b"x".to_vec(), + ); + fake.add_unreadable_entry(&root, "locked.nam"); + + let delta = Scanner::new(vec![root.clone()], &index).run_to_completion(&fake); + assert!(delta.complete); + assert_eq!(delta.warnings.len(), 1, "the locked child is reported"); + assert_eq!( + delta.warnings[0].code.id, + error_codes::FILE_UNREADABLE.id, + "a per-entry failure is a file's failure, not the directory's" + ); + assert_eq!( + delta.removals, + vec![root.join("gone.nam")], + "only the file that really is gone -- the sibling survives and so does the locked one" + ); + + index.apply(delta); + let mut paths: Vec = index.iter().map(|e| e.path.clone()).collect(); + paths.sort(); + assert_eq!(paths, vec![root.join("kept.nam"), root.join("locked.nam")]); + } + + /// A listing that could not be enumerated to the end concludes nothing about the children it + /// never reached — the iterator-level half of issue #66, where the skipped child has no path + /// to record and so the whole directory has to be treated as unseen. + #[test] + fn a_listing_that_ended_early_suppresses_removals_under_it() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + + let mut index = Index::empty(); + index.upsert(LibraryEntry { + path: root.join("unseen.nam"), + kind: crate::entry::ItemKind::Nam, + size: 1, + mtime: FileTime::from_system_time(std::time::UNIX_EPOCH), + hash: None, + metadata: crate::entry::ItemMetadata::None, + origin: Origin::Local, + }); + + let mut fake = FakeFs::new(); + fake.mark_partial(&root); + + let delta = Scanner::new(vec![root.clone()], &index).run_to_completion(&fake); + assert!(delta.complete); + assert_eq!(delta.unreadable_prefixes, vec![root.clone()]); + assert!(delta.removals.is_empty()); + } + + /// **Issue #67:** D-12.1's settling window has to cover every file's own examination time, and + /// on any scan longer than the window those are nowhere near the moment the scan finished. + /// + /// The scenario is an ordinary cold scan of a real library: it begins at `S` and runs for two + /// minutes. A file examined a minute in is edited moments later, in place, to the same length, + /// and the filesystem reports the mtime it already had. Anchored to *completion*, the recorded + /// timestamp is `S + 120 s` and the file's mtime `S + 60 s` sits a minute outside a two-second + /// window, so the edit is skipped -- and, its stored `(size, mtime)` now matching the new + /// content, it is invisible to every future scan as well. Anchored to the scan's *start*, an + /// mtime at or after `S - 2 s` is suspect, which every examination time in that scan is. + #[test] + fn a_file_edited_during_a_long_scan_is_not_invisible_forever() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + let path = root.join("a.nam"); + + let scan_started_at = FileTime::now(); + let examined_at = FileTime::from_nanos_since_epoch( + scan_started_at.as_nanos_since_epoch() + 60 * 1_000_000_000, + ); + + let mut fake = FakeFs::new(); + fake.add_file(&root, "a.nam", 100, examined_at, vec![1u8; 100]); + let mut index = Index::empty(); + index.apply(Scanner::new(vec![root.clone()], &index.clone()).run_to_completion(&fake)); + // The long scan's own baseline, as Index::apply would have recorded it. + index.set_last_scan_started_at(scan_started_at); + let first_hash = index.get(&path).unwrap().hash; + + // The same-length edit, reported with the mtime it already had. + let mut edited = FakeFs::new(); + edited.add_file(&root, "a.nam", 100, examined_at, vec![2u8; 100]); + let delta = Scanner::new(vec![root.clone()], &index).run_to_completion(&edited); + + assert_eq!( + delta.upserts.len(), + 1, + "a file whose mtime falls inside the previous scan must be rehashed, wherever in that \ + scan it happened to be examined" + ); + assert_ne!(delta.upserts[0].hash, first_hash); + } + + /// The other side of issue #67's rule: widening the window must not turn the incremental scan + /// into a full one. A file untouched since well before the previous scan began is still + /// skipped without being read. + #[test] + fn a_file_older_than_the_previous_scan_is_still_skipped() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + + let scan_started_at = FileTime::now(); + let long_before = FileTime::from_nanos_since_epoch( + scan_started_at.as_nanos_since_epoch() - 3_600 * 1_000_000_000, + ); + + let mut fake = FakeFs::new(); + fake.add_file(&root, "a.nam", 100, long_before, vec![1u8; 100]); + let mut index = Index::empty(); + index.apply(Scanner::new(vec![root.clone()], &index.clone()).run_to_completion(&fake)); + index.set_last_scan_started_at(scan_started_at); + + let delta = Scanner::new(vec![root.clone()], &index).run_to_completion(&fake); + assert!( + delta.upserts.is_empty(), + "an unchanged, old file must not be rehashed" + ); + } + + /// **Issue #73, the decision:** a directory symlink *is* followed. + /// + /// Symlinking a model collection into the library root is an ordinary setup, and not + /// traversing it was never chosen — it fell out of asking `file_type()` (which does not follow + /// links) and nothing else, leaving that user with an empty library and no diagnostic at all. + /// The loop-safety that shape got for free is replaced by an explicit visited set of canonical + /// directories; the two tests below pin both halves. + #[test] + fn a_symlinked_library_folder_is_traversed() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + let collection = PathBuf::from("/fake/elsewhere/collection"); + + let mut fake = FakeFs::new(); + fake.add_file( + &collection, + "amp.nam", + 4, + FileTime::from_system_time(std::time::UNIX_EPOCH), + b"junk".to_vec(), + ); + fake.add_dir_symlink(&root, "models", &collection); + + let delta = Scanner::new(vec![root.clone()], &Index::empty()).run_to_completion(&fake); + assert!(delta.warnings.is_empty(), "{:?}", delta.warnings); + assert_eq!( + delta + .upserts + .iter() + .map(|e| e.path.clone()) + .collect::>(), + vec![root.join("models").join("amp.nam")], + "the linked collection is indexed, under the path the user actually browses" + ); + } + + /// Issue #73's other half: following links means loops are no longer impossible by + /// construction, so they are made impossible by the visited set instead. A link pointing at a + /// directory this scan has already walked is reported and skipped — the walk terminates, the + /// files are not indexed twice, and the user is told why the second spelling is not listed. + #[test] + fn a_symlink_loop_terminates_and_is_reported() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + + let mut fake = FakeFs::new(); + fake.add_file( + &root, + "amp.nam", + 4, + FileTime::from_system_time(std::time::UNIX_EPOCH), + b"junk".to_vec(), + ); + fake.add_dir_symlink(&root, "self", &root); + + let delta = Scanner::new(vec![root.clone()], &Index::empty()).run_to_completion(&fake); + assert_eq!( + delta.upserts.len(), + 1, + "indexed once, not once per spelling" + ); + assert_eq!(delta.warnings.len(), 1); + assert_eq!( + delta.warnings[0].code.id, + error_codes::SYMLINK_NOT_FOLLOWED.id + ); + } + + /// A skipped symlink is not a deletion either: whatever an earlier scan indexed under that + /// spelling is still on disk, reached this time under another name. + #[test] + fn a_skipped_symlink_does_not_remove_what_was_indexed_under_it() { + use crate::fs::FakeFs; + let root = PathBuf::from("/fake/root"); + + let mut index = Index::empty(); + index.upsert(LibraryEntry { + path: root.join("self").join("amp.nam"), + kind: crate::entry::ItemKind::Nam, + size: 4, + mtime: FileTime::from_system_time(std::time::UNIX_EPOCH), + hash: None, + metadata: crate::entry::ItemMetadata::None, + origin: Origin::Local, + }); + + let mut fake = FakeFs::new(); + fake.add_file( + &root, + "amp.nam", + 4, + FileTime::from_system_time(std::time::UNIX_EPOCH), + b"junk".to_vec(), + ); + fake.add_dir_symlink(&root, "self", &root); + + let delta = Scanner::new(vec![root.clone()], &index).run_to_completion(&fake); + assert!(delta.removals.is_empty(), "{:?}", delta.removals); + } } diff --git a/crates/namir-library/src/search.rs b/crates/namir-library/src/search.rs index 0dbc72a..3098d27 100644 --- a/crates/namir-library/src/search.rs +++ b/crates/namir-library/src/search.rs @@ -35,7 +35,12 @@ impl Query { /// The lowercase text one entry is searched against: its file stem, plus every metadata field /// FR-LIB-040 names (name, author, gear/tone type, description for a model; nothing extra for an /// IR, whose header fields aren't free text). -fn searchable_text(entry: &LibraryEntry) -> String { +/// +/// Issue #72: called **once per entry per upsert**, by `index.rs`, and stored beside the entry — +/// not once per entry per `filter` call, which is what made "a linear scan over a precomputed +/// lowercase blob" (`Cargo.toml`'s reason for depending on no search-index crate) untrue. The +/// text it produces is unchanged; only when it runs is. +pub(crate) fn searchable_text(entry: &LibraryEntry) -> String { let mut blob = String::new(); if let Some(stem) = entry.path.file_stem().and_then(|s| s.to_str()) { blob.push_str(stem); @@ -58,13 +63,20 @@ fn searchable_text(entry: &LibraryEntry) -> String { /// Every entry in `index` matching `query` — an entry matches if every one of `query`'s terms is /// a substring of its [`searchable_text`]. An empty query matches everything. +/// +/// Allocates nothing per entry and case-folds nothing per call: the folded text is the one the +/// index already holds (issue #72). pub fn filter<'a>(index: &'a Index, query: &'a Query) -> impl Iterator { - index.iter().filter(move |entry| { - query.is_empty() || { - let blob = searchable_text(entry); - query.terms.iter().all(|term| blob.contains(term.as_str())) - } - }) + index + .iter_searchable() + .filter(move |(_, folded)| { + query.is_empty() + || query + .terms + .iter() + .all(|term| folded.contains(term.as_str())) + }) + .map(|(entry, _)| entry) } /// FR-LIB-060: the entry immediately after `current` in `ordered` (a caller-supplied ordering — diff --git a/crates/namir-library/src/store.rs b/crates/namir-library/src/store.rs index 44799a1..b2f0cac 100644 --- a/crates/namir-library/src/store.rs +++ b/crates/namir-library/src/store.rs @@ -25,6 +25,22 @@ //! [`LibraryWarning`], never a hard error: the next scan repopulates it from scratch. A missing //! file (the ordinary first-run case) produces no warning at all; that is not corruption. //! +//! **Favourites are exempt from that policy (issue #68).** "Discard everything and rescan" is +//! only harmless for what a rescan can rebuild. FR-LIB-050's favourite marks are hand-curated and +//! a scan cannot reproduce a single one of them, so a malformed byte used to destroy the user's +//! whole favourites list permanently — under a warning that said the index "will be rebuilt by +//! the next scan", which was true of the entries and false of the marks. `index.rs`'s own note +//! had the trade-off backwards: co-locating them avoided "a second thing that can go missing +//! independently" at the price of making them go missing *together*. +//! +//! They are therefore mirrored to a small sidecar document beside the index +//! ([`IndexStore::favourites_path`]), written by the same atomic discipline, and recovered on a +//! corrupt open — first by re-reading the damaged index leniently (a `format_version` this build +//! rejects is still perfectly good JSON, and so is a document with one bad entry in it), then +//! from the sidecar. The index document keeps carrying them too and stays the authority whenever +//! it loads, so the sidecar can never resurrect a mark the user has since removed: it is +//! consulted only when the document it mirrors could not be read at all. +//! //! **Rejected:** an append-only log with compaction (D-12.3's other named option) — it can tear //! on a crash mid-append, which atomic whole-file replacement cannot, and needs its own //! compaction policy, for an incremental-write saving (avoiding rewriting a few MB) that does not @@ -38,9 +54,11 @@ //! (both new crates must build for `aarch64-linux-android`/`aarch64-apple-ios`, NFR-PORT-030) for //! a few-MB rebuildable cache on a **Must** is a weaker case than that one already was. +use std::ffi::OsString; use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use serde::{Deserialize, Serialize}; @@ -60,10 +78,16 @@ struct OnDisk { /// so it survives a restart. `#[serde(default)]` so an index written before this field /// existed still loads (D-11.2's tolerant-reading spirit, applied to this crate's own format /// rather than namir-state's). - #[serde(default)] - last_scan_completed_at: Option, + /// + /// Issue #67 changed what this records — the scan's start rather than its completion — so the + /// key changed with it. The `alias` reads an index written by a build that stored the + /// completion time: using it as a start time is a *narrower* window than it should be, and + /// therefore no worse than the `None` the rename would otherwise produce, for the single + /// scan it takes to be rewritten. + #[serde(default, alias = "last_scan_completed_at")] + last_scan_started_at: Option, /// FR-LIB-050's favourite marks. `#[serde(default)]` for the same forward-compatibility - /// reason as `last_scan_completed_at`. + /// reason as `last_scan_started_at`. #[serde(default)] favourites: Favourites, } @@ -89,15 +113,50 @@ impl IndexStore { let mut warnings = Vec::new(); let index = match Self::try_load(&path) { LoadOutcome::Loaded(index) => index, - LoadOutcome::FirstRun => Index::empty(), + // Issue #68: the entries are a cache and may be dropped; the favourite marks are not + // and may not. Recovered from whatever of the two documents can still be read. + LoadOutcome::FirstRun => Self::empty_with_recovered_favourites(&path), LoadOutcome::Corrupt(warning) => { warnings.push(warning); - Index::empty() + Self::empty_with_recovered_favourites(&path) } }; (IndexStore { path }, index, warnings) } + /// The sidecar document FR-LIB-050's marks are mirrored into — see this module's doc comment. + /// `.favourites.json`, beside the index so the two move together. + pub fn favourites_path(path: &Path) -> PathBuf { + path.with_extension("favourites.json") + } + + /// An empty index carrying whatever favourites survived (issue #68). Tried in order of + /// freshness: the index document itself, read leniently — a `format_version` this build + /// refuses, or one bad entry among ten thousand, still leaves a perfectly readable + /// `favourites` array — and then the sidecar, which is what survives a document too damaged + /// to parse as JSON at all, or one that was deleted outright. + fn empty_with_recovered_favourites(path: &Path) -> Index { + let mut index = Index::empty(); + let recovered = Self::salvage_favourites(path) + .or_else(|| Self::read_favourites_sidecar(path)) + .unwrap_or_default(); + *index.favourites_mut() = recovered; + index + } + + fn salvage_favourites(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let favourites: Favourites = + serde_json::from_value(value.get("favourites")?.clone()).ok()?; + (!favourites.is_empty()).then_some(favourites) + } + + fn read_favourites_sidecar(path: &Path) -> Option { + let bytes = std::fs::read(Self::favourites_path(path)).ok()?; + serde_json::from_slice(&bytes).ok() + } + fn try_load(path: &Path) -> LoadOutcome { let bytes = match std::fs::read(path) { Ok(bytes) => bytes, @@ -133,8 +192,8 @@ impl IndexStore { for entry in on_disk.entries { index.upsert(entry); } - if let Some(at) = on_disk.last_scan_completed_at { - index.set_last_scan_completed_at(at); + if let Some(at) = on_disk.last_scan_started_at { + index.set_last_scan_started_at(at); } *index.favourites_mut() = on_disk.favourites; LoadOutcome::Loaded(index) @@ -144,50 +203,84 @@ impl IndexStore { /// temporary file lives in the same directory as the destination so the final `rename` is /// guaranteed to be within one filesystem (a cross-filesystem rename is not atomic on every /// platform, and some fail outright). + /// + /// FR-LIB-050's favourites are mirrored to [`Self::favourites_path`] in the same call. That + /// write's failure is deliberately **not** this call's failure (issue #68): the marks are also + /// inside the index document that just landed, so a sidecar that could not be written costs + /// redundancy, not data — and reporting a save failure for an index that saved correctly would + /// be the opposite of P8's "failure degrades". pub fn save_atomic(&self, index: &Index) -> Result<(), LibraryError> { let on_disk = OnDisk { format_version: STORE_FORMAT_VERSION, entries: index.iter().cloned().collect(), - last_scan_completed_at: index.last_scan_completed_at(), + last_scan_started_at: index.last_scan_started_at(), favourites: index.favourites().clone(), }; let bytes = serde_json::to_vec_pretty(&on_disk) .expect("an Index built from LibraryEntry values always serialises"); + let favourites = serde_json::to_vec_pretty(index.favourites()) + .expect("a Favourites is a list of hex strings and always serialises"); + + let index_result = self.write_atomic(&self.path, &bytes); + // Attempted whichever way the index write went: if that one failed, the sidecar is the + // only place these marks now exist. + let _ = self.write_atomic(&Self::favourites_path(&self.path), &favourites); + index_result + } - let tmp_path = self.path.with_extension("tmp"); - let mut file = File::create(&tmp_path).map_err(|e| { - LibraryError::new( - error_codes::INDEX_SAVE_FAILED, - format!("{}: {e}", tmp_path.display()), - ) - })?; - file.write_all(&bytes).map_err(|e| { - LibraryError::new( - error_codes::INDEX_SAVE_FAILED, - format!("{}: {e}", tmp_path.display()), - ) - })?; - file.sync_all().map_err(|e| { - LibraryError::new( - error_codes::INDEX_SAVE_FAILED, - format!("{}: {e}", tmp_path.display()), - ) - })?; - drop(file); - - std::fs::rename(&tmp_path, &self.path).map_err(|e| { - LibraryError::new( - error_codes::INDEX_SAVE_FAILED, - format!( - "renaming {} to {}: {e}", - tmp_path.display(), - self.path.display() - ), - ) - }) + /// One staged-then-renamed write. `dest`'s directory holds the staging file, so the rename + /// never crosses a filesystem. + fn write_atomic(&self, dest: &Path, bytes: &[u8]) -> Result<(), LibraryError> { + let tmp_path = stage_path(dest); + let result = (|| { + let mut file = File::create(&tmp_path).map_err(|e| save_failed(&tmp_path, e))?; + file.write_all(bytes) + .map_err(|e| save_failed(&tmp_path, e))?; + file.sync_all().map_err(|e| save_failed(&tmp_path, e))?; + drop(file); + std::fs::rename(&tmp_path, dest).map_err(|e| { + LibraryError::new( + error_codes::INDEX_SAVE_FAILED, + format!("renaming {} to {}: {e}", tmp_path.display(), dest.display()), + ) + }) + })(); + if result.is_err() { + // A staging name is unique per write (below), so a failed write that left its file + // behind would leave a new one behind every time. + let _ = std::fs::remove_file(&tmp_path); + } + result } } +fn save_failed(tmp_path: &Path, e: std::io::Error) -> LibraryError { + LibraryError::new( + error_codes::INDEX_SAVE_FAILED, + format!("{}: {e}", tmp_path.display()), + ) +} + +/// Issue #69: the staging file's name, unique to this process and to this write. +/// +/// It used to be `dest.with_extension("tmp")` — one fixed name, unowned and unlocked. Both product +/// shells resolve the same index path through `LibraryService::open_default`, so the standalone app +/// running while a DAW loads the CLAP plugin — the ordinary case, not a contrived one — had two +/// processes `File::create`ing (that is, truncating) and writing into the same staging file at +/// once, after which whichever `rename`d published a blend of both. `rename`'s atomicity says +/// nothing about that: it guarantees the *destination* is never seen half-written, not that the +/// source was ever whole. +/// +/// The process id separates processes; the counter separates concurrent writes inside one process +/// (both shells can be hosted in a single process, and nothing here is otherwise serialised). +fn stage_path(dest: &Path) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + let serial = NEXT.fetch_add(1, Ordering::Relaxed); + let mut name: OsString = dest.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".{}-{serial}.tmp", std::process::id())); + dest.with_file_name(name) +} + enum LoadOutcome { Loaded(Index), FirstRun, @@ -249,29 +342,29 @@ mod tests { /// D-12.1's mtime-settling protection must survive a restart, or a process that reopens the /// index right after a scan loses the very window it's supposed to guard. #[test] - fn last_scan_completed_at_survives_a_save_and_reload() { + fn last_scan_started_at_survives_a_save_and_reload() { let path = temp_index_path("scan_completed_at"); let (store, mut index, _) = IndexStore::open(path.clone()); let stamp = FileTime::now(); - index.set_last_scan_completed_at(stamp); + index.set_last_scan_started_at(stamp); store.save_atomic(&index).unwrap(); let (_, reloaded, _) = IndexStore::open(path.clone()); - assert_eq!(reloaded.last_scan_completed_at(), Some(stamp)); + assert_eq!(reloaded.last_scan_started_at(), Some(stamp)); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } /// D-11.2's tolerant-reading spirit, applied to this crate's own on-disk format: an index - /// written before `last_scan_completed_at` existed (or by a future build that omits it for + /// written before `last_scan_started_at` existed (or by a future build that omits it for /// some other reason) must still load. #[test] - fn a_missing_last_scan_completed_at_field_defaults_to_none() { + fn a_missing_last_scan_started_at_field_defaults_to_none() { let path = temp_index_path("no_scan_completed_at_field"); std::fs::write(&path, br#"{"format_version": 1, "entries": []}"#).unwrap(); let (_, index, warnings) = IndexStore::open(path.clone()); assert!(warnings.is_empty()); - assert_eq!(index.last_scan_completed_at(), None); + assert_eq!(index.last_scan_started_at(), None); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } @@ -342,9 +435,195 @@ mod tests { store.save_atomic(&index).unwrap(); assert!(path.exists()); + assert_eq!( + leftover_temp_files(path.parent().unwrap()), + Vec::::new(), + "no staging file may survive a successful save -- and since issue #69 gave each write \ + its own name, one left behind would be a new one every time" + ); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + /// **Issue #68:** a corrupt index file must not take the user's hand-curated favourites + /// with it. The entries are a rebuildable cache; the favourite marks are not. + #[test] + fn favourites_survive_a_corrupt_index_file() { + let path = temp_index_path("favourites_vs_corruption"); + let (store, mut index, _) = IndexStore::open(path.clone()); + index.upsert(sample_entry()); + let favourite = ContentHash::of(b"a treasured model"); + index.favourites_mut().mark(favourite); + store.save_atomic(&index).unwrap(); + + // The index document is damaged -- a truncated write, a bad byte, a future format_version. + std::fs::write(&path, b"{ not json at all").unwrap(); + + let (_, reloaded, warnings) = IndexStore::open(path.clone()); + assert!( + reloaded.is_empty(), + "the entries are a cache and may be dropped" + ); + assert_eq!(warnings.len(), 1); + assert!( + reloaded.favourites().is_favourite(favourite), + "favourites are not rebuildable by a rescan and must survive" + ); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// **Issue #69:** the staging file's name must not be a fixed name every writer in every + /// process picks. Both product shells open the same index path through + /// `LibraryService::open_default`, so the standalone app and a plugin instance staging their + /// saves through one `library-index.tmp` — truncating and writing into it simultaneously — is + /// the ordinary case, and `rename`'s atomicity does not help when two writers share one + /// staging file. + /// + /// Demonstrated deterministically rather than by racing two threads and hoping the window is + /// hit: anything already sitting on the deterministic name breaks this writer's save outright, + /// which is only possible because the name is predictable and unowned. + #[test] + fn a_save_is_not_broken_by_something_occupying_a_predictable_temp_name() { + let path = temp_index_path("temp_name_collision"); + let (store, mut index, _) = IndexStore::open(path.clone()); + index.upsert(sample_entry()); + + // Whatever a second writer would stage through, this writer must not depend on it being + // free. A directory stands in for "occupied by someone else" in a way no platform lets + // File::create silently take over. + std::fs::create_dir_all(path.with_extension("tmp")).unwrap(); + + store + .save_atomic(&index) + .expect("a save must not depend on a shared, predictable staging name being free"); + + let (_, reloaded, warnings) = IndexStore::open(path.clone()); + assert!(warnings.is_empty()); + assert_eq!(reloaded.len(), 1); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + /// Every staging file in this test's directory, whatever it is called — the point of issue + /// #69's fix is that the name is no longer predictable, so a test cannot name it either. + fn leftover_temp_files(dir: &Path) -> Vec { + let mut found: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "tmp")) + .collect(); + found.sort(); + found + } + + /// **Issue #69:** two writers must not stage through one file. The names differ per process + /// and per write, and both still sit beside the destination so the final rename stays within + /// one filesystem. + #[test] + fn each_write_stages_through_its_own_file_beside_the_destination() { + let dest = PathBuf::from("/some/dir/library-index.json"); + let first = stage_path(&dest); + let second = stage_path(&dest); + + assert_ne!(first, second, "two concurrent writes must not share a file"); + assert_eq!(first.parent(), dest.parent()); + assert_eq!(second.parent(), dest.parent()); + let name = first.file_name().unwrap().to_str().unwrap(); + assert!( + name.contains(&std::process::id().to_string()), + "another process must not pick this name: {name}" + ); + assert!(name.ends_with(".tmp")); + } + + /// A failed save takes its own staging file with it, rather than leaving one behind per + /// attempt now that the names are unique. + #[test] + fn a_failed_save_leaves_no_staging_file_behind() { + let path = temp_index_path("failed_save_cleanup"); + let (store, mut index, _) = IndexStore::open(path.clone()); + index.upsert(sample_entry()); + let _ = std::fs::remove_file(&path); + std::fs::create_dir_all(&path).unwrap(); + + store.save_atomic(&index).unwrap_err(); + assert_eq!( + leftover_temp_files(path.parent().unwrap()), + Vec::::new() + ); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// **Issue #68**, the version-bump form: a `format_version` this build refuses is still + /// perfectly readable JSON, so the marks come back out of the very document that was rejected. + #[test] + fn favourites_survive_an_index_written_by_a_future_build() { + let path = temp_index_path("favourites_vs_future_version"); + let favourite = ContentHash::of(b"a treasured model"); + std::fs::write( + &path, + format!(r#"{{"format_version": 99, "entries": [], "favourites": ["{favourite}"]}}"#), + ) + .unwrap(); + + let (_, index, warnings) = IndexStore::open(path.clone()); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].code.id, error_codes::INDEX_CORRUPT.id); + assert!(index.is_empty()); + assert!(index.favourites().is_favourite(favourite)); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// **Issue #68**, the deleted-index form: the marks are mirrored beside the index, so even + /// deleting the index outright to force a rescan keeps them. + #[test] + fn favourites_survive_the_index_file_being_deleted() { + let path = temp_index_path("favourites_vs_deletion"); + let (store, mut index, _) = IndexStore::open(path.clone()); + index.upsert(sample_entry()); + let favourite = ContentHash::of(b"a treasured model"); + index.favourites_mut().mark(favourite); + store.save_atomic(&index).unwrap(); + std::fs::remove_file(&path).unwrap(); + + let (_, reloaded, warnings) = IndexStore::open(path.clone()); + assert!(warnings.is_empty(), "a missing index is not corruption"); + assert!(reloaded.is_empty()); + assert!(reloaded.favourites().is_favourite(favourite)); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// The sidecar must never resurrect a mark the user has removed: the index document is the + /// authority whenever it loads, and the sidecar is consulted only when it does not. + #[test] + fn the_index_document_outranks_the_sidecar_whenever_it_loads() { + let path = temp_index_path("favourites_precedence"); + let (store, mut index, _) = IndexStore::open(path.clone()); + let favourite = ContentHash::of(b"a treasured model"); + index.favourites_mut().mark(favourite); + store.save_atomic(&index).unwrap(); + + // Unmarked, and saved again -- both documents are rewritten. + index.favourites_mut().unmark(favourite); + store.save_atomic(&index).unwrap(); + + let (_, reloaded, _) = IndexStore::open(path.clone()); + assert!(!reloaded.favourites().is_favourite(favourite)); + + // Even with a stale sidecar (a save whose sidecar write failed, say), an index that loads + // is believed. + std::fs::write( + IndexStore::favourites_path(&path), + format!(r#"["{favourite}"]"#), + ) + .unwrap(); + let (_, reloaded, warnings) = IndexStore::open(path.clone()); + assert!(warnings.is_empty()); assert!( - !path.with_extension("tmp").exists(), - "temp file must not survive a successful save" + !reloaded.favourites().is_favourite(favourite), + "a readable index document is the authority on its own favourites" ); let _ = std::fs::remove_dir_all(path.parent().unwrap()); From 66c876c710aabadb6b99e582a8d6015be44c462e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:48:50 +0000 Subject: [PATCH 15/44] Bound the notice list vertically, the half of #42 nobody had looked at Eight of the nine notices issues (#15, #39-#41, #43-#45, #99) were already fixed by 0a993da, verified against the tree rather than the commit message. #99 duplicates #15 and was filed about ninety minutes before the fix landed. The one live defect is on the axis nobody reported, and W10's own fix created it: adding a remedy line under every message and capping the list at 16 traded the horizontal overflow for a vertical one. Measured by driving the real render at the CLAP editor's 960x640, where can_resize() is false: 13 Dismiss buttons drawn, 3 clipped away entirely, and not one FR-UI-020 control painted -- the notice list had swallowed the window. Permanently undismissable notices in a list that nothing but Dismiss empties, which is step 14's failure. The list is now a scroll area capped at a third of the window height. The bound is a fraction of the window rather than a row count, because a row's height depends on how far its text wraps -- a row bound would repeat one level up the exact mistake the horizontal fix removed. Tests read the layout render actually painted rather than a hand-copied one, and one of them guards the failure the fix itself could introduce: a bound that hides rather than clips. #98 is not needed for this; a resizable editor would have been a workaround. D-16.1 gains a Consequence note: it still described three catalogue fields while the tree has carried a fourth, and a substitution vocabulary, since W10. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-ui/src/app.rs | 83 +++++- crates/namir-ui/src/notices.rs | 282 ++++++++++++++---- docs/02-architecture.md | 29 ++ .../fr-ui-070-non-modal-error-notices.md | 38 +++ 4 files changed, 367 insertions(+), 65 deletions(-) diff --git a/crates/namir-ui/src/app.rs b/crates/namir-ui/src/app.rs index d9102e2..bb698ed 100644 --- a/crates/namir-ui/src/app.rs +++ b/crates/namir-ui/src/app.rs @@ -484,7 +484,7 @@ mod tests { // Frame 1: press on it. Pressing alone changes nothing, so nothing may reach the host yet. let _ = ctx.run_ui( frame_input( - 0.1, + 0.2, vec![ egui::Event::PointerMoved(pos), egui::Event::PointerButton { @@ -544,4 +544,85 @@ mod tests { ); assert_eq!(namir_ui.host.dispatched, dispatched); } + + /// **Issue #42's other axis, at the layer that owns the container.** The horizontal half of + /// that issue — a long notice pushing `Dismiss` past the right edge — is fixed and asserted in + /// `notices`' own tests. The vertical half is a property of *this* module, because it is here + /// that the notice list is given the top panel to live in: a full `MAX_NOTICES` list, each row + /// two lines tall since FR-UI-070's remedy line was added beneath the message, in a CLAP + /// editor fixed at 960x640 with `can_resize() == false`. + /// + /// Before `notices::render` bounded the list, that measured ~736 px of rows in a 640 px + /// window: three `Dismiss` buttons were clipped away entirely — undismissable, in a window + /// that cannot be widened, from a list nothing else removes — and the top panel had swallowed + /// the screen so completely that **not one FR-UI-020 control was painted**. Both halves are + /// asserted, by the text `render` really painted rather than by a layout constant. + #[test] + fn a_full_notice_list_leaves_the_rest_of_the_screen_on_a_960x640_editor() { + const EDITOR: egui::Rect = + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(960.0, 640.0)); + const CODE: namir_core::ErrorCode = namir_core::ErrorCode::new( + "ui.example.file_missing", + namir_core::Severity::Error, + "The file could not be found ({detail}).", + "Check the file is still where the library lists it, then rescan.", + ); + + let snapshot = UiSnapshot { + notices: (0..crate::MAX_NOTICES as u64) + .map(|i| crate::host::UiNotice { + id: i, + code: CODE, + detail: format!( + "C:/Users/somebody/Documents/Namir/Library/marshall/plexi-1959-bright-\ + channel-take-{i}.nam: the file could not be read (os error 2)" + ), + }) + .collect(), + ..UiSnapshot::default() + }; + let mut view = ViewState::default(); + let mut intents = Vec::new(); + + // Two frames: `egui` sizes a panel from what it measured the frame before, so nothing + // inside the top panel is painted on the first one. + let ctx = egui::Context::default(); + let _ = ctx.run_ui(frame_input(0.0, Vec::new()), |ui| { + render(ui, &mut view, &snapshot, &mut intents); + }); + let output = ctx.run_ui(frame_input(0.1, Vec::new()), |ui| { + render(ui, &mut view, &snapshot, &mut intents); + }); + let painted = painted_texts(&output); + + for (text, rect) in &painted { + if text == "Dismiss" { + assert!( + EDITOR.contains_rect(*rect), + "a Dismiss button at {rect:?} falls outside a {EDITOR:?} editor that cannot \ + be resized -- that notice can never be removed" + ); + } + } + assert!( + painted.iter().any(|(text, _)| text == "Dismiss"), + "the notices are on screen at all" + ); + + // The screen the notices share. One element from each of the other two panels, so a top + // panel that has taken the window cannot pass this. + for element in ["Library", "Input Trim"] { + let rect = painted + .iter() + .find(|(text, _)| text == element) + .map(|(_, rect)| *rect) + .unwrap_or_else(|| { + panic!("a full notice list left no room to paint {element:?} at all") + }); + assert!( + EDITOR.contains_rect(rect), + "{element:?} was pushed to {rect:?}, outside a {EDITOR:?} editor" + ); + } + } } diff --git a/crates/namir-ui/src/notices.rs b/crates/namir-ui/src/notices.rs index d3a6b84..d446c45 100644 --- a/crates/namir-ui/src/notices.rs +++ b/crates/namir-ui/src/notices.rs @@ -35,6 +35,11 @@ //! plugin's real geometry, because a test that passes only in a wide default window is the defect //! rather than the check. //! +//! **The same defect had a second axis, and M14's own fix is what put it there** -- the remedy line +//! below doubled every row's height and the cap of sixteen bounded the list's length without +//! bounding the space it takes, so a full list clipped its last rows off the bottom of the same +//! editor. [`render`]'s doc comment carries that half. +//! //! **The remedy line costs vertical space in the top panel**, and FR-UI-020's own manual run //! records that the 960x640 editor already cannot show every element at once. That cost is //! accepted rather than overlooked: it is paid only while a notice is showing, the notice is @@ -52,15 +57,58 @@ use crate::host::UiNotice; /// How many notices a shell keeps on screen at once (see [`push_deduplicated`]). pub const MAX_NOTICES: usize = 16; +/// The largest share of the window's height the notice list may occupy before it starts to +/// scroll. See [`render`] for why the list needs a bound at all and why the bound is a fraction of +/// the window rather than a number of rows. +pub const MAX_NOTICE_AREA_FRACTION: f32 = 1.0 / 3.0; + /// Renders every notice in `notices`, each as its own non-modal, dismissible line. Appends /// [`UiIntent::DismissNotice`] to `intents` for whichever notice's dismiss button was clicked /// this frame (at most one per frame, since a click can only land on one button). +/// +/// # The list is bounded on screen, not only in memory (issue #42, vertical axis) +/// +/// Issue #42 is "some notices can never be dismissed in the CLAP plugin", and M14 fixed the axis +/// it was reported on: a *long* notice pushed `Dismiss` past the right edge of an editor fixed at +/// 960x640 that `can_resize() == false`. The same pass added FR-UI-070's remedy line beneath every +/// message and capped the list at [`MAX_NOTICES`] — which together put the identical defect on the +/// other axis, and measurably so. Sixteen notices at ~46 px a row is ~736 px of content in a +/// 640 px editor: driving the real `namir_ui::render` at exactly that geometry drew **thirteen** +/// `Dismiss` buttons and clipped three away, and the top panel had by then swallowed the whole +/// window, so not one FR-UI-020 control was painted either. A notice nobody can reach is a notice +/// nobody can dismiss, and the plugin's escape hatch — widen the window — still does not exist. +/// +/// So the list gets the same treatment its length already had: it is bounded, and the overflow +/// stays reachable. The notices live in a vertical [`egui::ScrollArea`] capped at +/// [`MAX_NOTICE_AREA_FRACTION`] of the window height, which shrinks to its content while the list +/// is short (one notice still costs one row, not a third of the screen) and scrolls once it is +/// not. +/// +/// **A fraction of the window, not a row count.** A row's height depends on how far its text +/// wraps, which depends on the width the shell gives it, so no constant number of rows is safe at +/// every geometry — a bound in rows would be the same class of mistake as a `Dismiss` button whose +/// position depends on the length of the label beside it. The fraction also leaves the rest of the +/// screen its majority share by construction, which is the property FR-UI-020's single-screen +/// layout actually needs. pub fn render(ui: &mut Ui, notices: &[UiNotice], intents: &mut Vec) { - for notice in notices { - if render_one(ui, notice).clicked() { - intents.push(UiIntent::DismissNotice { id: notice.id }); - } + if notices.is_empty() { + return; } + let max_height = ui.ctx().content_rect().height() * MAX_NOTICE_AREA_FRACTION; + egui::ScrollArea::vertical() + .id_salt("namir_ui_notices") + .max_height(max_height) + // Never shrink horizontally: the row is laid out right-to-left, so the dismiss button is + // placed against this area's right edge, and an area narrower than the panel would move it + // back inside the text's reach -- the very coupling this row's layout exists to break. + .auto_shrink([false, true]) + .show(ui, |ui| { + for notice in notices { + if render_one(ui, notice).clicked() { + intents.push(UiIntent::DismissNotice { id: notice.id }); + } + } + }); } /// Draws one notice's row and returns its dismiss button's `Response`. @@ -239,9 +287,13 @@ mod tests { /// /// The detail is deliberately far longer than anything the catalogue produces -- the point is /// that the button's position does not depend on the text at all. + /// + /// The rectangle comes from what [`render`] itself *painted*, not from a second call to + /// `render_one`: since the list acquired a bounding scroll area (see `render`'s doc comment), + /// a row drawn outside that area is not the row a user can click, and a test that measured one + /// would be back to measuring a layout `render` never drew. #[test] fn a_long_notice_keeps_its_dismiss_button_reachable_in_a_960x640_editor() { - const EDITOR: egui::Vec2 = egui::vec2(960.0, 640.0); let long_detail = "C:/Users/somebody/Documents/Namir/Library/marshall/\ a-very-long-model-name-of-the-kind-a-capture-session-produces-\ plexi-1959-bright-channel-treble-boosted-take-3.nam: \ @@ -249,79 +301,117 @@ mod tests { let notices = vec![notice(7, SAMPLE, long_detail)]; let ctx = egui::Context::default(); - let mut button_rect = None; - let _ = ctx.run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, EDITOR)), - ..Default::default() - }, - // `render_one`, which is what `render` itself calls -- see its doc comment for why the - // rectangle must come from the real layout and not from a copy of it. - |ui| button_rect = Some(render_one(ui, ¬ices[0]).rect), - ); - let rect = button_rect.expect("dismiss button laid out"); + let rects = dismiss_button_rects(&ctx, ¬ices, EDITOR, Vec::new()); + assert_eq!(rects.len(), 1, "one notice, one dismiss button"); assert!( - rect.max.x <= EDITOR.x && rect.min.x >= 0.0, - "Dismiss button at {rect:?} is outside a {EDITOR:?} editor" + EDITOR.contains_rect(rects[0]), + "Dismiss button at {:?} is outside a {EDITOR:?} editor", + rects[0] ); - let pos = rect.center(); let mut intents = Vec::new(); - let _ = ctx.run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, EDITOR)), - events: click_at(pos), - ..Default::default() - }, - |ui| { - render(ui, ¬ices, &mut intents); - }, - ); + let _ = ctx.run_ui(frame(EDITOR, click_at(rects[0].center())), |ui| { + render(ui, ¬ices, &mut intents); + }); assert_eq!(intents, vec![UiIntent::DismissNotice { id: 7 }]); } + /// **Issue #42 on its other axis.** A full [`MAX_NOTICES`] list is ~736 px of rows in a 640 px + /// editor that cannot be resized, so before `render` bounded the list it simply clipped the + /// last three notices away -- and, having taken the whole window for the top panel, painted no + /// FR-UI-020 control at all. Both halves are asserted here: nothing is drawn outside the + /// editor, and the notices stop short of owning the screen. #[test] - fn dismissing_a_notice_emits_its_own_id_not_anothers() { + fn a_full_notice_list_does_not_take_the_whole_editor() { + let notices: Vec = (0..MAX_NOTICES as u64) + .map(|i| notice(i, SAMPLE, &format!("C:/Namir/Library/model-{i}.nam"))) + .collect(); + let ctx = egui::Context::default(); - let notices = vec![notice(7, SAMPLE, "first"), notice(9, SAMPLE, "second")]; + let rects = dismiss_button_rects(&ctx, ¬ices, EDITOR, Vec::new()); + assert!(!rects.is_empty(), "a bounded list still shows notices"); + for rect in &rects { + assert!( + EDITOR.contains_rect(*rect), + "a Dismiss button at {rect:?} falls outside a {EDITOR:?} editor that cannot be \ + resized -- that notice can never be removed" + ); + } + let lowest = rects.iter().map(|r| r.max.y).fold(f32::MIN, f32::max); + let bound = EDITOR.height() * MAX_NOTICE_AREA_FRACTION; + assert!( + lowest <= bound, + "the notice list reaches {lowest} px in a {EDITOR:?} editor, past its {bound} px bound" + ); + } - // Discover the second notice's dismiss-button position by driving `render`'s own per-row - // function for both rows, in order -- the second row's vertical position depends on the - // first row already having been laid out above it. - let mut button_pos = None; - let _ = ctx.run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(500.0, 300.0), - )), - ..Default::default() - }, - |ui| { - for (i, notice) in notices.iter().enumerate() { - let response = render_one(ui, notice); - if i == 1 { - button_pos = Some(response.rect.center()); - } - } + /// The overflow a bound creates has to stay reachable, or the bound has merely moved the + /// undismissable notice rather than removed it. Scrolls the notice area to its end and clicks + /// what is then the lowest button, which must be the **last** notice in the list. + /// + /// Unlike the two tests above this one does *not* reproduce issue #42 — an unbounded list is + /// trivially "scrolled to its end" — it guards the failure mode the **fix** could introduce, + /// which is a bound that hides notices instead of clipping them. + #[test] + fn the_last_notice_of_a_full_list_is_reachable_by_scrolling() { + let notices: Vec = (0..MAX_NOTICES as u64) + .map(|i| notice(i, SAMPLE, &format!("C:/Namir/Library/model-{i}.nam"))) + .collect(); + + let ctx = egui::Context::default(); + // A wheel event applies to whatever the pointer is over, so the pointer is put inside the + // notice area first; the delta is far larger than the list is tall, and `egui` clamps. + let over_notices = egui::pos2(EDITOR.width() / 2.0, 20.0); + let scroll = vec![ + egui::Event::PointerMoved(over_notices), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -4000.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, }, - ); - let pos = button_pos.expect("dismiss button laid out"); + ]; + let rects = dismiss_button_rects(&ctx, ¬ices, EDITOR, scroll); + let lowest = rects + .iter() + .copied() + .max_by(|a, b| a.center().y.total_cmp(&b.center().y)) + .expect("a Dismiss button was drawn"); + assert!(EDITOR.contains_rect(lowest), "{lowest:?}"); let mut intents = Vec::new(); - let _ = ctx.run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(500.0, 300.0), - )), - events: click_at(pos), - ..Default::default() - }, - |ui| { - render(ui, ¬ices, &mut intents); - }, + let _ = ctx.run_ui(frame(EDITOR, click_at(lowest.center())), |ui| { + render(ui, ¬ices, &mut intents); + }); + assert_eq!( + intents, + vec![UiIntent::DismissNotice { + id: MAX_NOTICES as u64 - 1 + }], + "scrolled to the end, the lowest button must belong to the last notice" ); + } + + #[test] + fn dismissing_a_notice_emits_its_own_id_not_anothers() { + const WINDOW: egui::Rect = + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(500.0, 300.0)); + let notices = vec![notice(7, SAMPLE, "first"), notice(9, SAMPLE, "second")]; + + let ctx = egui::Context::default(); + let rects = dismiss_button_rects(&ctx, ¬ices, WINDOW, Vec::new()); + assert_eq!(rects.len(), 2, "two notices, two dismiss buttons"); + // The second row is the lower one; its button is the one this test means to click. + let second = rects + .iter() + .copied() + .max_by(|a, b| a.center().y.total_cmp(&b.center().y)) + .expect("two buttons"); + + let mut intents = Vec::new(); + let _ = ctx.run_ui(frame(WINDOW, click_at(second.center())), |ui| { + render(ui, ¬ices, &mut intents); + }); assert_eq!(intents, vec![UiIntent::DismissNotice { id: 9 }]); } @@ -336,6 +426,70 @@ mod tests { assert!(intents.is_empty()); } + /// The CLAP editor's real geometry -- fixed, and `can_resize() == false` + /// (`crates/namir-clap/src/gui.rs`). Every layout assertion in this module is made at it, + /// because a check that passes only in a generous standalone window is the defect rather than + /// the check. + const EDITOR: egui::Rect = + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(960.0, 640.0)); + + /// One frame's input at `window`, carrying `events`. + fn frame(window: egui::Rect, events: Vec) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(window), + events, + ..Default::default() + } + } + + /// Where [`render`] actually put each `Dismiss` button, read off the shapes it painted. + /// + /// Several frames, because two `egui` behaviours make a single one unrepresentative: a scroll + /// area is sized from what it measured the frame *before*, so the first frame's clip rectangle + /// is a placeholder and nothing inside it is drawn yet; and a wheel delta is applied smoothly + /// over the frames that follow it rather than all at once. So `events` are delivered on the + /// second frame, once there is a real area under the pointer to receive them, and the + /// measurement is taken after the scroll has come to rest -- which is also the state the + /// caller's own click frame will be in. + /// + /// Going through the paint output rather than through a second call to `render_one` is the + /// same rule this module's `render_one` doc comment records: measure the layout that was + /// drawn, never a copy of it. + fn dismiss_button_rects( + ctx: &egui::Context, + notices: &[UiNotice], + window: egui::Rect, + events: Vec, + ) -> Vec { + let mut discard = Vec::new(); + let _ = ctx.run_ui(frame(window, Vec::new()), |ui| { + render(ui, notices, &mut discard); + }); + let mut output = ctx.run_ui(frame(window, events), |ui| { + render(ui, notices, &mut discard); + }); + for _ in 0..16 { + output = ctx.run_ui(frame(window, Vec::new()), |ui| { + render(ui, notices, &mut discard); + }); + } + + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) if text.galley.text() == "Dismiss" => { + out.push(text.visual_bounding_rect()); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut rects = Vec::new(); + for clipped in &output.shapes { + walk(&clipped.shape, &mut rects); + } + rects + } + /// One press-and-release of the primary button at `pos`. fn click_at(pos: egui::Pos2) -> Vec { vec![ diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 4356c1d..93561e9 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -1748,6 +1748,35 @@ identifier, a severity, and a user-facing message template. *Consequence:* FR-ERR-020 requires every user-visible error to map to a catalogue entry, verified statically. The catalogue is the single source for that check and for the user documentation. +*Consequence (added M14, 2026-08-28) — an entry has **four** fields, and its template has a defined +substitution vocabulary.* The decision above names three (identifier, severity, message template), +and the tree has carried a fourth since M14's W10 without this decision saying so; recorded here +rather than left to be rediscovered from the type. + +The fourth is `remedy: &'static str` — what the user can do — added because FR-UI-070's third clause +("an error shall state ... what the user can do") was met by **none** of the catalogue's entries +when a human ran `docs/manual-tests/fr-ui-070-non-modal-error-notices.md` on 2026-08-27 (issue #41). +It is a field rather than a trailing sentence in the template for two reasons that are the same +reason twice: nothing mechanical can tell a remedy sentence from any other sentence, so the clause +would go back to being met by review alone — which is how it came to be met nowhere — and the UI +cannot style or omit half of one string. All 74 entries carry one, and two checks enforce it from +opposite sides: `xtask error-catalogue` rejects an empty literal at the source line, and +`namir_core::assert_unique_ids` rejects an empty value in a crate's enumerated `ALL` slice. + +The message template's `{placeholder}` notation was decorative until the same pass: **nothing in the +tree substituted anything**, and a template reading `The {direction} device "{device}"` reached a +real screen with the braces in it (issue #15). `ErrorCode::render` now fills exactly one token, +`{detail}`, from the one free-text string every error producer in the tree already carries; a +template with no token gets the detail appended in parentheses instead. A named-field map was +rejected — it would have threaded names through five crates to reach the same sentence, and several +named slots plus an appended detail is what makes a notice say everything twice. `xtask +error-catalogue` refuses any other `{...}` in a template, so the notation is now implemented rather +than aspirational, at one token wide. + +This does not disturb D-16.2 below: `render` allocates, and the audio thread never reaches it — +it never holds an `ErrorCode` at all, reporting through the telemetry ring as numbers that the +non-RT side maps to entries. + **Decision D-16.2** — The audio thread emits **numeric fault codes** through the telemetry ring. All formatting, allocation and logging happen on the UI or worker side. diff --git a/docs/manual-tests/fr-ui-070-non-modal-error-notices.md b/docs/manual-tests/fr-ui-070-non-modal-error-notices.md index 3052322..00a9672 100644 --- a/docs/manual-tests/fr-ui-070-non-modal-error-notices.md +++ b/docs/manual-tests/fr-ui-070-non-modal-error-notices.md @@ -442,3 +442,41 @@ notice names where it went. **Step 9's re-run should check the file is actually that concerns a device tells the user to edit `audio-settings.json` and restart. That is what the program can currently do, stated plainly rather than pointing at a control that does not exist; FR-IO-070's third clause and roadmap §15 item 16 still own it. + +--- + +## The other axis of finding 7 — 2026-08-28 + +**Appended, not edited. Nothing above moves, the `Result:` line included.** + +Re-reading issue #42 against the code W10 left produced a second instance of the *same* defect, on +the axis nobody had looked at, and it was **created by W10's own fix rather than left over from +before it**. Two of that pass's changes cost vertical space — FR-UI-070's remedy line beneath every +message, and a list capped at sixteen rather than bounded on screen — and the top panel had no +bound of any kind. Driving the real `namir_ui::render` at the CLAP editor's own 960x640 with a full +sixteen-notice list measured the result: **thirteen `Dismiss` buttons drawn and three clipped away +entirely**, in an editor that `can_resize() == false` and a list nothing but `Dismiss` empties — the +exact sentence step 14 failed on, one rotation round. Worse, the notices had by then taken the whole +window: **not one FR-UI-020 control was painted**, not the library panel, not a single parameter. + +Fixed in `crates/namir-ui/src/notices.rs`: the list is drawn inside a vertical scroll area capped at +a third of the window's height, which shrinks to its content while the list is short and scrolls +once it is not. The bound is a *fraction of the window*, not a number of rows, because a row's +height depends on how far its text wraps — a row count would repeat, one level up, the mistake of a +button whose position depends on the length of the label beside it. + +Three in-process assertions, all at 960x640 and all reading the layout `render` really painted +rather than a copy of it: `notices::tests::a_full_notice_list_does_not_take_the_whole_editor` +(nothing drawn outside the editor, and the list stops at its bound), +`the_last_notice_of_a_full_list_is_reachable_by_scrolling` (the bound hides nothing: scrolled to the +end, the lowest button belongs to the **last** notice and a real click dismisses it), and +`app::tests::a_full_notice_list_leaves_the_rest_of_the_screen_on_a_960x640_editor` (the Library +panel and a parameter control are both still on screen with a full list showing). The first and +third were confirmed red against the code as W10 left it. + +**What a re-run should do about step 14.** The induction that matters is now *many* notices as well +as a long one: fill the list — sixteen distinct failed loads will do it — and check that the panel +stops after a few rows and scrolls, that the rest of the screen is still there, and that the last +notice in the list can be scrolled to and dismissed. A mouse wheel over the notice area is the +gesture; there is no scrollbar drag to rely on if the host swallows the wheel, and if it does, +**that** is the finding. From bb9d6c9f16b76dedebd302543da455843ad46c6f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:10:45 +0000 Subject: [PATCH 16/44] Make generated fixtures reproducible across platforms (#133, #136, #137, #138) #136's non-determinism is the platform libm -- not the toolchain, not evaluation order. rand_pcg, IEEE arithmetic and ryu printing are all bit-exact, and Rust contracts no FMA, so the only entry points were three: calibration's f32 sin, infer's f32 tanh, and the LSTM's tanh and exp. This sandbox's f32::tanh(-0.544) returns -0.49600992 where correctly-rounded is -0.49600986, 2 ULP out; its f64 libm is not. That settles M14's A2 drift: A2 inference is LeakyReLU only, so sin in the calibration probe is the whole explanation -- which is why "1.94.1 vs 1.98" never fit the evidence of ~50,000 RNG weights all matching. detmath.rs replaces those four calls with f64-only implementations. No new dependency, deliberately: one would move Cargo.lock, THIRD-PARTY-NOTICES.md and the dependency register, none of which this change should touch. A source guard test fails if a platform transcendental is reintroduced, which is the only kind of test that catches that. Committed bytes checked by regenerating in memory: the WaveNet and A2 goldens and the nam fuzz seed come back byte-identical. lstm_tiny.nam would move 4 weights by 1 ULP, but nothing regenerates it, so no committed bytes moved. The LSTM parity print goes from -inf dB to -125.1 dB, well inside its -100 dB bar -- the -inf was bit-exactness bought by both sides sharing one libm, not agreement. #133: an unreadable destination was treated as known junk and deleted. Only a readback that succeeds and returns junk now clears it; unreadable retries. #137: the cache key folds in generator versions plus two derived fingerprints, so a changed weight layout is caught mechanically rather than by remembering to bump a constant. #138: JSON Pointer segments are escaped per RFC 6901. Verified no committed corpus moves -- no key in any seed document contains / or ~. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-fixtures/src/detmath.rs | 405 ++++++++++++++++++++ crates/namir-fixtures/src/ir.rs | 10 + crates/namir-fixtures/src/lib.rs | 3 + crates/namir-fixtures/src/library.rs | 350 +++++++++++++++-- crates/namir-fixtures/src/mutate.rs | 89 ++++- crates/namir-fixtures/src/nam/infer.rs | 6 +- crates/namir-fixtures/src/nam/lstm_infer.rs | 16 +- crates/namir-fixtures/src/nam/mod.rs | 161 +++++++- 8 files changed, 989 insertions(+), 51 deletions(-) create mode 100644 crates/namir-fixtures/src/detmath.rs diff --git a/crates/namir-fixtures/src/detmath.rs b/crates/namir-fixtures/src/detmath.rs new file mode 100644 index 0000000..83cbe0e --- /dev/null +++ b/crates/namir-fixtures/src/detmath.rs @@ -0,0 +1,405 @@ +//! Bit-reproducible elementary functions, for the parts of this crate whose *output bytes* are a +//! fixture other machines have to reproduce. +//! +//! # Why this module exists +//! +//! D-19.1's premise is that a fixture is regenerable from its `(shape, seed)`: the file is the +//! artifact, the generator is the recipe, and anyone can re-run the recipe and get the file back. +//! `std`'s `f32::tanh`, `f32::sin` and `f64::exp` quietly break that premise. Rust delegates all +//! three to the platform's libm, and libms do not agree bit for bit — the C standard requires +//! neither correct rounding nor a shared implementation for any of them. Everything else in these +//! generators is exact: `rand_pcg` produces the same bits everywhere, `+ - * /` and `sqrt` are +//! IEEE-754 correctly rounded, Rust performs no FMA contraction or reassociation without an +//! explicit request, and `serde_json` prints floats through `ryu`, which is exact. +//! +//! This is not a theoretical hazard; it has already cost this project a CI round. M14's +//! `the_a2_golden_models_match_their_generator` was a byte comparison, and it failed on all three +//! CI platforms while passing on the machine that wrote the goldens — **two bytes out of 205 986**, +//! `config.head_scale` and the trailing weight that mirrors it (`0.15790403` locally, +//! `0.15790401` on every runner). Every one of the ~50 000 RNG-derived weights matched exactly. +//! `head_scale` is calibrated as `base * (target_rms / measured_rms)`, and `measure_output_rms` +//! runs a whole inference pass, so one differing libm result anywhere in it lands in that one +//! float. The A2 generator's *only* transcendental is `calibration_probe`'s `sin` — A2 inference +//! itself is LeakyReLU, pure arithmetic — which is as direct a fingerprint of libm as the evidence +//! could offer. That test was relaxed to a relative tolerance rather than fixed; this module fixes +//! the cause. +//! +//! # What is guaranteed here, and what is not +//! +//! Every function below is built exclusively from IEEE-754 `f64` arithmetic — add, subtract, +//! multiply, divide, comparison, and bit-level scaling by powers of two — with no library call and +//! no table lookup. Each of those operations is correctly rounded and fully specified by the +//! standard, so **the same input produces the same bits on every platform this project targets**, +//! independent of the C library, the compiler version and the instruction set. (The one +//! environment that would break the claim is a target evaluating `f64` in x87 extended precision — +//! 32-bit x86 without SSE2 — which this project does not build for.) +//! +//! Accuracy is a *separate* claim and a weaker one: these are faithful, not proven +//! correctly-rounded. Each is evaluated in `f64` and rounded once to `f32` at the end, so a +//! result carrying ~1e-16 of relative error in `f64` lands on the correctly-rounded `f32` for all +//! but inputs sitting within that distance of an `f32` midpoint. The module's own tests sweep each +//! function against `std`'s **double**-precision counterpart, rounded once, and assert agreement +//! within one `f32` ULP — with over 99% of samples agreeing exactly. They deliberately do not +//! compare against `std`'s *single*-precision functions, which on this sandbox are themselves up to +//! two ULP out (a worked example is in the tests); that error is a property of one platform's libm, +//! which is the thing this module exists to keep out of a fixture. +//! +//! # Scope +//! +//! Only the `nam` generation and reference-inference path uses this, because only that path's +//! output is a checked-in fixture: `crates/namir-nam/tests/golden/*.nam` and +//! `crates/namir-nam/fuzz/corpus/load_nam/valid_nano.json`. [`crate::ir`]'s designed filters still +//! call `std` — their output is never committed, only compared under a tolerance — and +//! [`crate::resample_response`] likewise. + +/// `ln(2)` split so that `k * LN2_HI` is *exact* for every `k` a range reduction can produce: +/// `LN2_HI` carries only the top 33 significant bits, leaving room for a 20-bit integer factor in +/// a 53-bit mantissa. The pair sums to `ln(2)` to about 1e-27, so the reduced argument keeps full +/// precision instead of inheriting the rounding error of a single-`f64` `ln(2)`. +const LN2_HI: f64 = 6.931_471_803_691_238e-1; +const LN2_LO: f64 = 1.908_214_929_270_587_7e-10; +const LOG2_E: f64 = std::f64::consts::LOG2_E; + +/// `pi/2` split three ways, for the same reason [`LN2_HI`]/[`LN2_LO`] are split two ways: each +/// part is truncated to 33 significant bits or fewer, so `n * PIO2_i` is exact for `|n| < 2^20` +/// and the subtractions below lose nothing. Three parts rather than two because a sine argument +/// can be far larger, relative to `pi/2`, than an exponent argument is relative to `ln(2)`. +const PIO2_1: f64 = 1.570_796_326_734_125_6; +const PIO2_2: f64 = 6.077_100_506_506_192e-11; +const PIO2_3: f64 = 2.022_266_248_711_166_5e-21; + +/// The largest `|x|` [`sin_f32`]'s exact argument reduction covers (`2^20 * pi/2`). Above it the +/// reduction falls back to plain `f64` arithmetic: still bit-identical everywhere — that is this +/// module's whole point and it holds for every input — but no longer accurate, because the +/// three-part constant above runs out of bits. Nothing in this crate comes close: the calibration +/// probe's largest argument is about 230 radians. +const SIN_EXACT_REDUCTION_LIMIT: f64 = 1_647_099.0; + +/// `2^k` as an `f64`, for `-1022 <= k <= 1023`, by writing the exponent field directly. Exact, and +/// deliberately not `2.0f64.powi(k)` — `powi` is a compiler intrinsic whose lowering is not +/// something this module wants to depend on. +fn pow2(k: i32) -> f64 { + debug_assert!((-1022..=1023).contains(&k)); + f64::from_bits(((k + 1023) as u64) << 52) +} + +/// `e^x`, to within about one `f64` ULP over the whole finite range, using only arithmetic. +/// +/// Cody-Waite range reduction (`x = k*ln2 + r`, `|r| <= ln2/2`) followed by the Taylor series for +/// `e^r`, whose terms fall off as `0.347^n / n!`: truncating after `r^16/16!` leaves a remainder +/// around 1e-23 relative, far inside `f64`'s own resolution. Horner's form keeps it to sixteen +/// multiply-divide-adds. +pub fn exp_f64(x: f64) -> f64 { + if x.is_nan() { + return x; + } + if x > 709.9 { + return f64::INFINITY; + } + if x < -745.2 { + return 0.0; + } + + let k = (x * LOG2_E).round(); + let k_i = k as i32; + // Both products are exact (see LN2_HI), so `r` carries no reduction error of its own. + let r = (x - k * LN2_HI) - k * LN2_LO; + + // Horner on `e^r = 1 + r/1 (1 + r/2 (1 + ... r/16))`, innermost term first. + let mut sum = 1.0f64; + for n in (1..=16u32).rev() { + sum = sum * r / f64::from(n) + 1.0; + } + + // Split the scaling in two so a result that is subnormal or near the top of the range still + // goes through two in-range `pow2` factors rather than one out-of-range one. + let half = k_i.clamp(-1000, 1000) / 2; + sum * pow2(half) * pow2(k_i - half) +} + +/// `tanh(x)` for an `f32`, computed in `f64` and rounded once. +/// +/// `(e^{2x} - 1) / (e^{2x} + 1)`, with the tail cut off at `|x| > 20` (where the true value is +/// within 1e-17 of ±1, far inside `f32`'s resolution, and where `e^{2x}` would otherwise reach +/// infinity and turn the quotient into a NaN). The cancellation in `e^{2x} - 1` as `x` approaches +/// zero is harmless at this width: at `x = 1e-3` it costs about three decimal digits of a +/// sixteen-digit `f64` intermediate, leaving ten more than the `f32` result can express. +pub fn tanh_f32(x: f32) -> f32 { + let xd = f64::from(x); + if xd.is_nan() { + return x; + } + if xd > 20.0 { + return 1.0; + } + if xd < -20.0 { + return -1.0; + } + let e = exp_f64(2.0 * xd); + ((e - 1.0) / (e + 1.0)) as f32 +} + +/// The logistic sigmoid `1 / (1 + e^{-x})` for an `f32`, computed in `f64` and rounded once — the +/// LSTM gate nonlinearity, and the second place `std`'s `exp` used to enter a generated fixture. +pub fn sigmoid_f32(x: f32) -> f32 { + let xd = f64::from(x); + if xd.is_nan() { + return x; + } + (1.0 / (1.0 + exp_f64(-xd))) as f32 +} + +/// `sin(r)` for `|r| <= pi/4`, by Taylor series. Terms fall off as `0.7854^n / n!`; stopping after +/// `r^19/19!` leaves under 1e-19 absolute, below `f64`'s resolution at this magnitude. +fn sin_kernel(r: f64) -> f64 { + let r2 = r * r; + let mut sum = -1.0 / 121_645_100_408_832_000.0; // -1/19! + for (denom, sign) in [ + (355_687_428_096_000.0f64, 1.0f64), // 17! + (1_307_674_368_000.0, -1.0), // 15! + (6_227_020_800.0, 1.0), // 13! + (39_916_800.0, -1.0), // 11! + (362_880.0, 1.0), // 9! + (5_040.0, -1.0), // 7! + (120.0, 1.0), // 5! + (6.0, -1.0), // 3! + ] { + sum = sum * r2 + sign / denom; + } + r + r * r2 * sum +} + +/// `cos(r)` for `|r| <= pi/4`, by Taylor series, truncated after `r^20/20!` on the same argument +/// as [`sin_kernel`]'s. +fn cos_kernel(r: f64) -> f64 { + let r2 = r * r; + let mut sum = 1.0 / 2_432_902_008_176_640_000.0; // 1/20! + for (denom, sign) in [ + (6_402_373_705_728_000.0f64, -1.0f64), // 18! + (20_922_789_888_000.0, 1.0), // 16! + (87_178_291_200.0, -1.0), // 14! + (479_001_600.0, 1.0), // 12! + (3_628_800.0, -1.0), // 10! + (40_320.0, 1.0), // 8! + (720.0, -1.0), // 6! + (24.0, 1.0), // 4! + (2.0, -1.0), // 2! + ] { + sum = sum * r2 + sign / denom; + } + 1.0 + r2 * sum +} + +/// `sin(x)` for an `f32`, computed in `f64` and rounded once. +/// +/// Cody-Waite reduction to `|r| <= pi/4` plus a quadrant index, then [`sin_kernel`]/[`cos_kernel`]. +/// See [`SIN_EXACT_REDUCTION_LIMIT`] for the (unreachable, in this crate) argument magnitude at +/// which the reduction stops being exact — determinism is unaffected there, only accuracy. +pub fn sin_f32(x: f32) -> f32 { + let xd = f64::from(x); + if !xd.is_finite() { + return f32::NAN; + } + + let n = (xd * std::f64::consts::FRAC_2_PI).round(); + let r = if xd.abs() <= SIN_EXACT_REDUCTION_LIMIT { + ((xd - n * PIO2_1) - n * PIO2_2) - n * PIO2_3 + } else { + xd - n * std::f64::consts::FRAC_PI_2 + }; + + // `n mod 4` decides which of ±sin, ±cos the reduced argument feeds. + let quadrant = (n as i64).rem_euclid(4); + let y = match quadrant { + 0 => sin_kernel(r), + 1 => cos_kernel(r), + 2 => -sin_kernel(r), + _ => -cos_kernel(r), + }; + y as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The property the whole module exists for cannot be tested from one machine — "these bytes + /// are the same on Windows, Linux and macOS" needs three machines. What *is* testable here is + /// the premise that makes it true: every one of these functions is built from IEEE-754 + /// arithmetic alone, which is specified to the bit. So these tests check the other half — that + /// the deterministic implementations are *also* accurate enough to stand in for `std`'s — + /// leaving the determinism claim resting on the operations used, not on a measurement. + /// + /// One ULP of `f32` is the bar, measured against the **`f64`** function of the same name — + /// `f64::from(x).tanh() as f32`, not `x.tanh()`. That is deliberate, and the reason is the + /// issue itself: this sandbox's single-precision libm is not correctly rounded, so comparing + /// against it would measure its error rather than this module's. A concrete case found while + /// writing these tests: for `x = -0.544f32` the true `tanh` is `-0.4960098653132381`, whose + /// nearest `f32` is `-0.49600986` — what this module returns — while `x.tanh()` returns + /// `-0.49600992`, two ULP away. Rounding the `f64` result once is the accurate reference, and + /// the fact that `f32`'s libm misses it by two ULP *here* is exactly the platform-to-platform + /// variation the module removes from generated fixtures. + /// + /// At one ULP a substituted value moves a calibrated `head_scale` by ~6e-8 relative, orders of + /// magnitude below every tolerance the golden tests assert. + fn assert_within_one_ulp(ours: f32, theirs: f32, label: &str) { + if ours == theirs { + return; + } + assert!( + ours.is_finite() && theirs.is_finite(), + "{label}: {ours} vs std's {theirs}" + ); + let ulps = (ours.to_bits() as i64 - theirs.to_bits() as i64).abs(); + assert!( + ulps <= 1, + "{label}: {ours} vs std's {theirs} ({ulps} ULP apart)" + ); + } + + #[test] + fn tanh_agrees_with_std_to_within_one_ulp() { + let mut exact = 0u32; + let mut total = 0u32; + for i in -40_000i32..40_000 { + let x = i as f32 / 1_000.0; + let ours = tanh_f32(x); + let theirs = f64::from(x).tanh() as f32; + assert_within_one_ulp(ours, theirs, &format!("tanh({x})")); + exact += u32::from(ours == theirs); + total += 1; + } + assert!( + exact * 100 >= total * 99, + "only {exact}/{total} tanh samples matched std exactly" + ); + } + + #[test] + fn sigmoid_agrees_with_the_std_expression_to_within_one_ulp() { + for i in -40_000i32..40_000 { + let x = i as f32 / 1_000.0; + assert_within_one_ulp( + sigmoid_f32(x), + (1.0 / (1.0 + (-f64::from(x)).exp())) as f32, + &format!("sigmoid({x})"), + ); + } + } + + #[test] + fn sin_agrees_with_std_to_within_one_ulp_across_the_probes_range() { + // The calibration probe's arguments run to about 230 radians; sweep well past that. + let mut exact = 0u32; + let mut total = 0u32; + for i in -100_000i32..100_000 { + let x = i as f32 / 200.0; + let ours = sin_f32(x); + let theirs = f64::from(x).sin() as f32; + assert_within_one_ulp(ours, theirs, &format!("sin({x})")); + exact += u32::from(ours == theirs); + total += 1; + } + assert!( + exact * 100 >= total * 99, + "only {exact}/{total} sin samples matched std exactly" + ); + } + + #[test] + fn exp_agrees_with_std_across_a_wide_range() { + for i in -70_000i32..70_000 { + let x = f64::from(i) / 100.0; + let ours = exp_f64(x); + let theirs = x.exp(); + let ulps = (ours.to_bits() as i128 - theirs.to_bits() as i128).abs(); + assert!(ulps <= 2, "exp({x}): {ours} vs std's {theirs} ({ulps} ULP)"); + } + } + + #[test] + fn the_edges_behave() { + assert_eq!(tanh_f32(0.0), 0.0); + assert_eq!(tanh_f32(100.0), 1.0); + assert_eq!(tanh_f32(-100.0), -1.0); + assert!(tanh_f32(f32::NAN).is_nan()); + assert_eq!(sigmoid_f32(0.0), 0.5); + assert_eq!(sigmoid_f32(-1_000.0), 0.0); + assert_eq!(sigmoid_f32(1_000.0), 1.0); + assert_eq!(sin_f32(0.0), 0.0); + assert!(sin_f32(f32::INFINITY).is_nan()); + assert_eq!(exp_f64(0.0), 1.0); + assert_eq!(exp_f64(1_000.0), f64::INFINITY); + assert_eq!(exp_f64(-1_000.0), 0.0); + assert!(exp_f64(f64::NAN).is_nan()); + } + + /// The static half of the guarantee, and the test that was red before this module existed: + /// **no file in the `.nam` generation path may call a platform transcendental**. Accuracy + /// tests cannot catch a regression here — a reintroduced `f32::tanh` would agree with + /// `detmath::tanh_f32` to a ULP on this machine and still make the generated fixture + /// platform-dependent, which is the whole defect. What makes a fixture reproducible is *which + /// operations* produced it, so that is what this checks. + /// + /// Scoped to `src/nam/`, the only path whose output is a checked-in artifact. `sqrt` is + /// deliberately absent from the list: IEEE-754 requires it to be correctly rounded, so it is + /// as reproducible as multiplication. + #[test] + fn the_nam_generator_calls_no_platform_transcendental() { + const FORBIDDEN: [&str; 10] = [ + ".tanh()", ".sin()", ".cos()", ".exp()", ".exp2()", ".ln()", ".log10()", ".log2()", + ".powf(", ".powi(", + ]; + let nam_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/nam"); + let mut scanned = 0; + let mut offences = Vec::new(); + for entry in std::fs::read_dir(&nam_dir).expect("src/nam is readable") { + let path = entry.expect("a readable directory entry").path(); + if path.extension().is_none_or(|e| e != "rs") { + continue; + } + let source = std::fs::read_to_string(&path).expect("a readable source file"); + scanned += 1; + for (n, line) in source.lines().enumerate() { + // Comments name these functions constantly (this file included); only code counts. + let code = line.split("//").next().unwrap_or(""); + for needle in FORBIDDEN { + if code.contains(needle) { + offences.push(format!("{}:{}: {}", path.display(), n + 1, line.trim())); + } + } + } + } + assert!( + scanned >= 4, + "expected to scan the whole nam module, saw {scanned} files" + ); + assert!( + offences.is_empty(), + "the .nam generator must go through `crate::detmath`, not the platform libm, or its \ + output bytes stop being reproducible across platforms:\n{}", + offences.join("\n") + ); + } + + /// Sanity on the reduction: `sin` of an exact multiple of pi must be tiny, and the quadrant + /// walk must produce the right signs. + #[test] + fn sin_reduces_correctly_over_many_periods() { + for k in 0..200 { + let x = (k as f32) * std::f32::consts::PI; + assert!( + sin_f32(x).abs() < 1e-3, + "sin({k}pi) = {} is not near zero", + sin_f32(x) + ); + let peak = ((k as f32) + 0.5) * std::f32::consts::PI; + let expected = if k % 2 == 0 { 1.0 } else { -1.0 }; + assert!( + (sin_f32(peak) - expected).abs() < 1e-3, + "sin(({k}+0.5)pi) = {} should be {expected}", + sin_f32(peak) + ); + } + } +} diff --git a/crates/namir-fixtures/src/ir.rs b/crates/namir-fixtures/src/ir.rs index 7833032..ce12af7 100644 --- a/crates/namir-fixtures/src/ir.rs +++ b/crates/namir-fixtures/src/ir.rs @@ -2,6 +2,16 @@ //! as-is from the S-2 spike's `fixtures` module), plus the designed minimum-phase filter D-9.5 //! lists that neither spike implemented. +/// Bumped by hand whenever anything in this module changes the *bytes* it generates for a given +/// set of arguments. The counterpart of [`crate::nam::GENERATOR_VERSION`], with the same single +/// consumer and the same reason: [`crate::library`]'s corpus cache holds `.wav` files this module +/// produced, and a cache key that does not name this generator will keep serving the previous +/// version's files after it changes. +/// +/// Version 1 is the pre-existing generator (introduced, not bumped, when the cache key first +/// started folding it in). +pub const GENERATOR_VERSION: u32 = 1; + /// A unit impulse: the simplest possible analytically-known IR. Convolving with it is the /// identity, so any deviation in a convolution engine's output shows up as pure engine error. pub fn delta(len: usize) -> Vec { diff --git a/crates/namir-fixtures/src/lib.rs b/crates/namir-fixtures/src/lib.rs index cdb9782..2332f74 100644 --- a/crates/namir-fixtures/src/lib.rs +++ b/crates/namir-fixtures/src/lib.rs @@ -3,6 +3,8 @@ //! D-19.1 mandates: everything in it is deterministic from a seed, nothing reaches for OS //! randomness, and nothing here is captured audio. //! +//! - [`detmath`] — bit-reproducible `sin`/`tanh`/`exp`, so a generated `.nam` fixture's bytes do +//! not depend on which platform's libm generated it. //! - [`nam`] — WaveNet `.nam` fixtures (parity + performance rows). //! - [`ir`] — convolution correctness fixtures (delta / delayed delta / decaying noise / //! designed minimum-phase). @@ -27,6 +29,7 @@ // `namir-clap`'s golden input vector and preset, and `namir-ui`'s brand-mark blob. // trace: NFR-LIC-050 +pub mod detmath; pub mod ir; pub mod library; pub mod mutate; diff --git a/crates/namir-fixtures/src/library.rs b/crates/namir-fixtures/src/library.rs index 26939b7..c571e9c 100644 --- a/crates/namir-fixtures/src/library.rs +++ b/crates/namir-fixtures/src/library.rs @@ -47,9 +47,16 @@ //! [`generate_shared_corpus`] writes into a content-addressed directory under the workspace //! `target/` (this module's private `cache_root` function), not into the repo, and not //! regenerated on every run. The directory name is keyed on a hash of `GENERATOR_VERSION`, the -//! seed, and the composition constants above, so changing any of them (including bumping -//! `GENERATOR_VERSION` by hand after any change to this module's generation logic) invalidates -//! stale cached output automatically rather than silently serving last run's corpus. A cache hit +//! seed, the composition constants above, **and the two generators whose output the corpus +//! actually is** — [`crate::nam::GENERATOR_VERSION`], [`crate::ir::GENERATOR_VERSION`] and the +//! serialized `.nam` shape (this module's private `cache_signature` function) — so changing any of +//! them (including bumping a `GENERATOR_VERSION` by hand after a change to the corresponding +//! module's generation logic) invalidates stale cached output automatically rather than silently +//! serving last run's corpus. Naming the two other generators is not decoration: nothing in +//! `cargo test` re-validates a cached corpus, so before the key folded them in, a change to +//! `nam/mod.rs`'s weight layout left this cache serving the old layout's files indefinitely and +//! the failure surfaced as a `namir-library` scan test rejecting them, pointing nowhere near the +//! cause. A cache hit //! reads one small JSON manifest and stats two files (this module's private `try_load_cached` //! function) — it never re-walks or re-hashes all 10,000 files. Building is race-safe across //! concurrent processes/threads sharing @@ -113,6 +120,12 @@ const _: () = assert!(BANKS * FOLDERS_PER_BANK * FILES_PER_LEAF == TOTAL_COUNT); const _: () = assert!(IR_SLOTS_PER_LEAF * BANKS * FOLDERS_PER_BANK == IR_COUNT); const _: () = assert!((FILES_PER_LEAF - IR_SLOTS_PER_LEAF) * BANKS * FOLDERS_PER_BANK == NAM_COUNT); +/// The `.nam` shape every model in the corpus is generated from — the cheapest one, per this +/// module's doc comment on structural uniformity. Named rather than repeated at its two use +/// sites (`base_nam_model` and `cache_signature`) so the shape the cache key describes cannot +/// drift from the shape the corpus is actually built with. +const CORPUS_NAM_SHAPE: WaveNetShape = WaveNetShape::Nano; + /// Each generated IR's length in samples — small deliberately (see this module's doc comment on /// tiny files understating real per-file cost). const IR_LEN_SAMPLES: usize = 1_024; @@ -178,7 +191,7 @@ const MUTABLE_NAM_COUNT: usize = 4; /// degenerate model, which is a bug in the seed choice, not a condition a corpus-scale caller /// should have to handle per call. fn base_nam_model(seed: u64) -> nam::NamModel { - nam::generate(WaveNetShape::Nano, seed) + nam::generate(CORPUS_NAM_SHAPE, seed) .unwrap_or_else(|e| panic!("library corpus base NAM model is degenerate: {e}")) } @@ -233,18 +246,61 @@ fn cache_root() -> PathBuf { workspace_target_dir().join("namir-fixtures-cache") } -/// The cache key for a shared corpus generated from `seed`: [`namir_core::ContentHash`] of a -/// string folding in [`GENERATOR_VERSION`] and every constant that affects composition, so a -/// change to any of them changes the key and therefore the cache directory name, rather than -/// silently reusing an incompatible directory. Truncated to 16 hex characters (64 bits, ample -/// collision resistance for a cache-directory name) purely to keep the resulting nested path -/// short on Windows. -fn cache_key(seed: u64) -> String { - let signature = format!( +/// Everything the bytes of a cached corpus depend on, as one string — the input [`cache_key`] +/// hashes. Parameterized over the two *other* modules' generator versions rather than reading +/// them directly so a test can vary them; [`cache_key`] passes the real ones. +/// +/// # Why the two generator versions are in here +/// +/// [`GENERATOR_VERSION`] covers this module's own logic and the constants below cover its +/// composition, but the corpus's actual file content comes from [`crate::nam::generate`] and +/// [`crate::ir::decaying_noise`], which this signature named nothing about. Changing the WaveNet +/// weight layout in `nam/mod.rs` therefore left a warm `target/namir-fixtures-cache` serving the +/// old layout's `.nam` files under an unchanged key, indefinitely — and no `cargo test` path +/// re-validates a cached corpus (a hit reads the manifest and stats two files, by design), so the +/// visible symptom was a `namir-library` scan test failing against files the current parser +/// rejects, with nothing pointing at the cache. Both generators now carry a +/// `GENERATOR_VERSION` of their own and both are folded in here. +/// +/// The `.nam` side additionally folds in the *shape* the corpus is built from, serialized: it is +/// derived rather than hand-maintained, needs no inference pass to compute (so a cache hit stays +/// as cheap as it was), and so catches a change to [`WaveNetShape::Nano`]'s topology even if +/// whoever made it forgets to bump [`crate::nam::GENERATOR_VERSION`]. +fn cache_signature(seed: u64, nam_generator_version: u32, ir_generator_version: u32) -> String { + let nam_shape = serde_json::to_string(&nam::shape_signature(CORPUS_NAM_SHAPE)) + .expect("a layer-config list always serializes (plain numbers, strings and arrays)"); + let nam_init = nam_weight_fingerprint(seed); + format!( "namir-fixtures-library-corpus|v{GENERATOR_VERSION}|seed={seed}|ir={IR_COUNT}|\ nam={NAM_COUNT}|banks={BANKS}|folders={FOLDERS_PER_BANK}|leaf={FILES_PER_LEAF}|\ - ir_len={IR_LEN_SAMPLES}|ir_tau={IR_TAU_SAMPLES}|ir_rate={IR_SAMPLE_RATE}" - ); + ir_len={IR_LEN_SAMPLES}|ir_tau={IR_TAU_SAMPLES}|ir_rate={IR_SAMPLE_RATE}|\ + nam_gen=v{nam_generator_version}|ir_gen=v{ir_generator_version}|nam_shape={nam_shape}|\ + nam_init={nam_init}" + ) +} + +/// A hash of the weights [`nam::uncalibrated_weights`] produces for this corpus's shape and +/// `seed` — the layout-sensitive half of the cache key, and the one that needs no hand +/// maintenance: reordering `build_weights`' sections or changing an initialisation scale changes +/// these bytes and therefore the cache directory. Costs one seeded RNG pass over the cheapest +/// shape's ~2,000 weights (microseconds), so a cache hit stays cheap; deliberately not a hash of a +/// fully generated model, which would put `generate`'s two inference passes on every hit. +fn nam_weight_fingerprint(seed: u64) -> String { + let weights = nam::uncalibrated_weights(CORPUS_NAM_SHAPE, seed); + let mut bytes = Vec::with_capacity(weights.len() * 4); + for w in &weights { + bytes.extend_from_slice(&w.to_le_bytes()); + } + ContentHash::of(&bytes).to_string()[..16].to_string() +} + +/// The cache key for a shared corpus generated from `seed`: [`namir_core::ContentHash`] of +/// [`cache_signature`], so a change to anything that signature names changes the key and +/// therefore the cache directory name, rather than silently reusing an incompatible directory. +/// Truncated to 16 hex characters (64 bits, ample collision resistance for a cache-directory +/// name) purely to keep the resulting nested path short on Windows. +fn cache_key(seed: u64) -> String { + let signature = cache_signature(seed, nam::GENERATOR_VERSION, ir::GENERATOR_VERSION); let hash = ContentHash::of(signature.as_bytes()).to_string(); hash[..16].to_string() } @@ -455,6 +511,10 @@ const PUBLISH_RETRY_BACKOFF: Duration = Duration::from_millis(100); /// permanently block every future attempt. Content is deterministic per `(seed, key)`, so /// clearing and re-publishing loses nothing even in the case this turns out to have been a /// genuine, resolvable race rather than stale state. +/// +/// **"Clear whatever is there" means whatever is *known* not to be a valid corpus, never whatever +/// merely failed to read.** See [`publish_built_corpus`], which this function delegates the +/// publish half to. fn build_and_publish_corpus(dir: &Path, seed: u64, key: &str) -> io::Result { record_build_attempt(seed); let nonce = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); @@ -477,48 +537,107 @@ fn build_and_publish_corpus(dir: &Path, seed: u64, key: &str) -> io::Result io::Result { // Diagnostics only -- captures the *last* attempt's state so a persistent (not transient) // failure reports something actionable instead of the same opaque message every time. Not // load-bearing for the retry logic itself. let mut last_rename_err: Option = None; let mut last_readback_err: Option = None; + let mut published = false; for attempt in 0..MAX_PUBLISH_ATTEMPTS { match try_load_cached(dir, seed, key) { Ok(Some(corpus)) => { // A valid corpus already sits at `dir` -- ours from an earlier attempt in this // same loop, or a genuine other winner's. Either way, use it. - let _ = fs::remove_dir_all(&tmp_dir); + let _ = fs::remove_dir_all(tmp_dir); return Ok(corpus); } - Ok(None) => last_readback_err = None, + Ok(None) => { + last_readback_err = None; + // Nothing valid at `dir`, and that is *known*, not assumed. Clear whatever is + // there -- absent, corrupt, or stale -- before trying to claim the path, so a + // non-empty-but-invalid destination can never make every future `rename` fail + // forever. A `NotFound` error here (the ordinary case: nothing to clear) is + // expected and ignored. + let _ = fs::remove_dir_all(dir); + match fs::rename(tmp_dir, dir) { + Ok(()) => { + published = true; + break; + } + Err(e) => last_rename_err = Some(e), + } + } + // Unreadable, so unknown: never destroy what could not be inspected. Just wait and + // look again. Err(e) => last_readback_err = Some(e), } - // Nothing valid at `dir`. Clear whatever is there -- absent, corrupt, or stale -- before - // trying to claim the path, so a non-empty-but-invalid destination can never make every - // future `rename` fail forever. A `NotFound` error here (the ordinary case: nothing to - // clear) is expected and ignored. - let _ = fs::remove_dir_all(dir); - last_rename_err = fs::rename(&tmp_dir, dir).err(); - if attempt + 1 < MAX_PUBLISH_ATTEMPTS { std::thread::sleep(PUBLISH_RETRY_BACKOFF); } } - // One last check: the final iteration's rename may have succeeded even though the loop ran - // out of attempts before re-checking. - if let Some(corpus) = try_load_cached(dir, seed, key)? { - let _ = fs::remove_dir_all(&tmp_dir); - return Ok(corpus); + // Read back what is now at `dir`: after a successful `rename` this is our own publish, and + // otherwise it is a last chance for a concurrent winner's to have become visible. + let readback = try_load_cached(dir, seed, key); + let _ = fs::remove_dir_all(tmp_dir); + match readback { + Ok(Some(corpus)) => return Ok(corpus), + Ok(None) => {} + Err(e) => last_readback_err = Some(e), } - let _ = fs::remove_dir_all(&tmp_dir); + if published { + return Err(io::Error::other(format!( + "corpus directory {} was published but did not read back as a valid corpus (last \ + try_load_cached error: {last_readback_err:?})", + dir.display() + ))); + } Err(io::Error::other(format!( - "corpus directory missing immediately after publish (after {MAX_PUBLISH_ATTEMPTS} \ - attempts; last rename error: {last_rename_err:?}; last try_load_cached error: \ - {last_readback_err:?})" + "could not publish the corpus to {} after {MAX_PUBLISH_ATTEMPTS} attempts; the \ + destination was left untouched if it could not be read (last rename error: \ + {last_rename_err:?}; last try_load_cached error: {last_readback_err:?})", + dir.display() ))) } @@ -714,6 +833,115 @@ mod tests { ); } + /// The other half of the publish step's failure handling, and the one the self-healing path + /// above got wrong: a destination that **cannot be read** is not a destination that is known + /// to be junk. `try_load_cached` returns `Err` for any non-`NotFound` I/O failure reading + /// `_manifest.json` — a permission error, an antivirus lock, a Windows sharing violation — + /// and the publish step used to feed that straight into the same `remove_dir_all(dir)` it + /// uses for a stale destination, deleting a valid, published, possibly-being-read corpus on + /// the strength of one failed `open`. + /// + /// The unreadable manifest here is a *directory* where the file belongs: `fs::read` fails on + /// it with a non-`NotFound` error on every platform this project builds for (`IsADirectory` + /// on Linux/macOS, `PermissionDenied` on Windows), which reproduces the failure shape exactly + /// without permission games, an injected fault, or a real race. + #[test] + fn a_destination_that_cannot_be_read_is_never_deleted() { + let base = workspace_target_dir() + .join("namir-fixtures-publish-test") + .join("a_destination_that_cannot_be_read_is_never_deleted"); + let _ = fs::remove_dir_all(&base); + let dir = base.join("lib-corpus-unreadable"); + let tmp_dir = base.join("lib-corpus-unreadable.tmp-0"); + fs::create_dir_all(&dir).expect("create the destination"); + fs::create_dir_all(&tmp_dir).expect("create the source"); + fs::write(tmp_dir.join("payload.bin"), b"freshly built corpus").expect("write the source"); + + // Stands in for the 10,000 files a published corpus holds, and for the caller that is + // reading them while this publish attempt runs. + let published = dir.join("published_file.bin"); + fs::write(&published, b"a valid corpus another process is mid-read of") + .expect("write the published file"); + fs::create_dir_all(dir.join(MANIFEST_FILE_NAME)).expect("make the manifest unreadable"); + + let err = publish_built_corpus(&tmp_dir, &dir, 4_242, "unreadable-destination") + .expect_err("an unreadable destination cannot be published to"); + + assert!( + published.exists(), + "the publish step deleted a corpus it could not read ({err})" + ); + assert_eq!( + fs::read(&published).expect("the published file is still readable"), + b"a valid corpus another process is mid-read of", + "the published corpus was replaced rather than left alone" + ); + + let _ = fs::remove_dir_all(&base); + } + + /// The complement of the test above: a destination that reads back as *demonstrably* not a + /// corpus (`Ok(None)`, not `Err`) is still cleared and claimed — the self-healing behaviour + /// the retry loop exists for, checked here at the publish seam rather than through a full + /// 10,000-file build. + #[test] + fn a_destination_that_reads_back_as_junk_is_still_cleared_and_claimed() { + const JUNK_SEED: u64 = 4_243; + let base = workspace_target_dir() + .join("namir-fixtures-publish-test") + .join("a_destination_that_reads_back_as_junk_is_still_cleared_and_claimed"); + let _ = fs::remove_dir_all(&base); + let dir = base.join("lib-corpus-junk"); + let tmp_dir = base.join("lib-corpus-junk.tmp-0"); + let key = cache_key(JUNK_SEED); + fs::create_dir_all(&dir).expect("create the destination"); + fs::create_dir_all(&tmp_dir).expect("create the source"); + fs::write( + dir.join("leftover_junk.bin"), + b"an interrupted build's leftovers", + ) + .expect("write the stale destination"); + + // A minimal but genuinely valid corpus in the source: one file plus a manifest naming it, + // which is all `try_load_cached` reads back (it stats the first and last entry, not all + // 10,000, by design). + let bytes = b"published payload"; + fs::write(tmp_dir.join("only.bin"), bytes).expect("write the source file"); + let manifest = Manifest { + generator_version: GENERATOR_VERSION, + key: key.clone(), + seed: JUNK_SEED, + entries: (0..TOTAL_COUNT) + .map(|_| ManifestEntry { + rel_path: "only.bin".to_string(), + kind: EntryKind::Ir, + hash: ContentHash::of(bytes).to_string(), + }) + .collect(), + }; + fs::write( + tmp_dir.join(MANIFEST_FILE_NAME), + serde_json::to_vec(&manifest).expect("manifest serializes"), + ) + .expect("write the source manifest"); + + let corpus = publish_built_corpus(&tmp_dir, &dir, JUNK_SEED, &key) + .expect("a junk destination should be cleared and claimed"); + + assert_eq!(corpus.root, dir); + assert!( + !dir.join("leftover_junk.bin").exists(), + "the stale content should have been cleared" + ); + assert!( + dir.join("only.bin").exists(), + "our build should be at `dir`" + ); + assert!(!tmp_dir.exists(), "the temp directory should be consumed"); + + let _ = fs::remove_dir_all(&base); + } + #[test] fn the_cache_is_actually_reused_on_a_second_call() { // Deliberately not a wall-clock timing assertion: on a shared/possibly-antivirus-scanned @@ -742,6 +970,64 @@ mod tests { assert_ne!(a.root, b.root); } + /// The corpus is `nam::generate`'s and `ir::decaying_noise`'s output, so a change to either + /// of those generators has to change this module's cache key — otherwise a warm cache serves + /// the previous generator's files forever, and nothing re-validates a cached corpus to catch + /// it. Checked by varying each generator's version through `cache_signature` (the same + /// function `cache_key` hashes, taking the versions as arguments precisely so this test can + /// move them) rather than by asserting the signature contains a particular substring: what + /// matters is that the *key* changes, not how the version is spelled inside it. + #[test] + fn bumping_either_generators_version_changes_the_cache_key() { + let key_of = |s: &str| ContentHash::of(s.as_bytes()).to_string()[..16].to_string(); + let (nam_v, ir_v) = (nam::GENERATOR_VERSION, ir::GENERATOR_VERSION); + let current = cache_signature(TEST_SEED, nam_v, ir_v); + + assert_eq!( + key_of(¤t), + cache_key(TEST_SEED), + "cache_key must hash exactly the signature this test varies" + ); + assert_ne!( + key_of(¤t), + key_of(&cache_signature(TEST_SEED, nam_v + 1, ir_v)), + "a bump to nam::GENERATOR_VERSION left the cache key unchanged" + ); + assert_ne!( + key_of(¤t), + key_of(&cache_signature(TEST_SEED, nam_v, ir_v + 1)), + "a bump to ir::GENERATOR_VERSION left the cache key unchanged" + ); + } + + /// The mechanical half of the same guard: the `.nam` shape the corpus is built from, *and* + /// the weights that shape initialises to, are folded into the signature as data — so changing + /// the topology, the weight layout or an initialisation scale invalidates the cache even if + /// nobody remembers to bump `nam::GENERATOR_VERSION`. That is the issue's own scenario + /// ("change the WaveNet weight layout, and a warm cache keeps serving the old layout") + /// answered without a hand-maintained constant. + #[test] + fn the_cache_key_covers_the_nam_shape_the_corpus_is_built_from() { + let signature = cache_signature(TEST_SEED, nam::GENERATOR_VERSION, ir::GENERATOR_VERSION); + let shape = serde_json::to_string(&nam::shape_signature(CORPUS_NAM_SHAPE)).unwrap(); + assert!( + signature.contains(&shape), + "the corpus's own shape is missing from the cache signature: {signature}" + ); + let other = serde_json::to_string(&nam::shape_signature(WaveNetShape::Lite)).unwrap(); + assert_ne!(shape, other, "two shapes must serialize differently"); + + assert!( + signature.contains(&nam_weight_fingerprint(TEST_SEED)), + "the corpus's initialised weights are missing from the cache signature: {signature}" + ); + assert_ne!( + nam_weight_fingerprint(TEST_SEED), + nam_weight_fingerprint(TEST_SEED + 1), + "the weight fingerprint must depend on the corpus seed" + ); + } + #[test] fn shared_corpus_has_the_expected_counts_and_tree_shape() { let corpus = generate_shared_corpus(TEST_SEED).expect("generate"); diff --git a/crates/namir-fixtures/src/mutate.rs b/crates/namir-fixtures/src/mutate.rs index 14084e0..b9bc181 100644 --- a/crates/namir-fixtures/src/mutate.rs +++ b/crates/namir-fixtures/src/mutate.rs @@ -100,6 +100,32 @@ fn truncate(data: &[u8], rng: &mut impl Rng) -> Vec { data[..cut].to_vec() } +/// Appends one segment to a JSON Pointer, escaping it per RFC 6901 §3: `~` becomes `~0` and `/` +/// becomes `~1`, in that order (reversing the order would re-escape the `~` the second rule just +/// introduced). +/// +/// Not a formality. Every pointer in this module is built by string concatenation and then handed +/// to `serde_json`'s `pointer`/`pointer_mut`, which un-escape what they are given — so an +/// unescaped `/` inside a key silently *splits* the segment and the lookup resolves to `None`. +/// `drop_field`, `corrupt_number`, `null_field` and `retype_field` all treat `None` as "nothing to +/// do" and return the input, which puts a byte-identical duplicate of the seed file into the fuzz +/// corpus in place of a mutant: a mutation kind that appears to have run and did nothing. Keys +/// carrying `/` or `~` are reachable in real input — `.nam` files pass training metadata through +/// verbatim — so this is a live case, not a theoretical one. +fn push_pointer_segment(path: &str, segment: &str) -> String { + let mut out = String::with_capacity(path.len() + segment.len() + 1); + out.push_str(path); + out.push('/'); + for ch in segment.chars() { + match ch { + '~' => out.push_str("~0"), + '/' => out.push_str("~1"), + _ => out.push(ch), + } + } + out +} + /// Walks a JSON value, collecting every `(container, key)` pair addressable for field removal — /// `container` is a JSON Pointer (RFC 6901) to the object that owns `key`. Recurses into arrays /// too (indices become pointer segments) so a field nested inside `config.layers[1]` is as @@ -109,12 +135,12 @@ fn collect_object_keys(value: &Value, path: &str, out: &mut Vec<(String, String) Value::Object(map) => { for (k, v) in map { out.push((path.to_string(), k.clone())); - collect_object_keys(v, &format!("{path}/{k}"), out); + collect_object_keys(v, &push_pointer_segment(path, k), out); } } Value::Array(items) => { for (i, v) in items.iter().enumerate() { - collect_object_keys(v, &format!("{path}/{i}"), out); + collect_object_keys(v, &push_pointer_segment(path, &i.to_string()), out); } } _ => {} @@ -127,12 +153,12 @@ fn collect_number_paths(value: &Value, path: &str, out: &mut Vec) { Value::Number(_) => out.push(path.to_string()), Value::Object(map) => { for (k, v) in map { - collect_number_paths(v, &format!("{path}/{k}"), out); + collect_number_paths(v, &push_pointer_segment(path, k), out); } } Value::Array(items) => { for (i, v) in items.iter().enumerate() { - collect_number_paths(v, &format!("{path}/{i}"), out); + collect_number_paths(v, &push_pointer_segment(path, &i.to_string()), out); } } _ => {} @@ -203,7 +229,7 @@ fn object_mut<'a>( /// The current value of `container_ptr`'s `key` field, addressed the same way /// [`collect_object_keys`] built the pointer. fn child<'a>(value: &'a Value, container_ptr: &str, key: &str) -> Option<&'a Value> { - value.pointer(&format!("{container_ptr}/{key}")) + value.pointer(&push_pointer_segment(container_ptr, key)) } /// Replaces one random object field's value with `null`, keeping the key. See @@ -312,6 +338,59 @@ mod tests { assert_eq!(mutate(&[], Mutation::Truncate, 1), Vec::::new()); } + /// A key containing `/` or `~` used to defeat every JSON-aware mutation kind: the pointer to + /// its container was built by raw concatenation, `serde_json` un-escaped it back into a + /// different path, the lookup missed, and `drop_field`/`corrupt_number`/`null_field`/ + /// `retype_field` all returned the input **unchanged** — writing a byte-identical copy of the + /// seed file into the corpus as if it were a mutant. Both special characters are reachable in + /// real input, since `.nam` files carry exporter training-metadata keys through verbatim. + /// + /// Every addressable field in this document sits behind such a key, so *whichever* field a + /// given seed picks, the mutation has to change something. Swept over many seeds rather than + /// pinned to one: which field a seed reaches is an implementation detail, "no seed produces a + /// no-op" is the property. + #[test] + fn a_key_containing_a_slash_or_a_tilde_is_still_mutable() { + let data = serde_json::json!({ + "a/b": {"c~d": 1, "e/~f": [2, 3]}, + "g~1h": {"i//j": 4} + }) + .to_string() + .into_bytes(); + + for mutation in [ + Mutation::DropField, + Mutation::CorruptNumber, + Mutation::NullField, + Mutation::RetypeField, + ] { + for seed in 0..40u64 { + let mutated = mutate(&data, mutation, seed); + assert_ne!( + mutated, data, + "{mutation:?} with seed {seed} silently returned its input unchanged: the \ + JSON Pointer for a key containing `/` or `~` did not resolve" + ); + // The fallback byte flip would also change the bytes -- but only by corrupting + // the JSON. These four kinds are meant to produce a structurally *valid* + // document with one field changed, which is what makes them different seeds for + // a fuzzer than `ByteFlip` already is. + serde_json::from_slice::(&mutated).unwrap_or_else(|e| { + panic!("{mutation:?} with seed {seed} fell back to a byte flip: {e}") + }); + } + } + } + + #[test] + fn pointer_segments_are_escaped_per_rfc_6901() { + assert_eq!(push_pointer_segment("", "plain"), "/plain"); + assert_eq!(push_pointer_segment("/a", "b/c"), "/a/b~1c"); + assert_eq!(push_pointer_segment("/a", "b~c"), "/a/b~0c"); + // `~` first, then `/`: escaping in the other order would turn `~` into `~01`. + assert_eq!(push_pointer_segment("", "~/"), "/~0~1"); + } + #[test] fn drop_field_removes_a_key_that_was_present() { let data = sample_nam_json(); diff --git a/crates/namir-fixtures/src/nam/infer.rs b/crates/namir-fixtures/src/nam/infer.rs index dd81dfb..b709d63 100644 --- a/crates/namir-fixtures/src/nam/infer.rs +++ b/crates/namir-fixtures/src/nam/infer.rs @@ -98,9 +98,13 @@ fn dilated_conv( out } +/// `crate::detmath::tanh_f32`, not `f32::tanh`: this activation runs inside the calibration pass +/// whose result is written into a checked-in `.nam` fixture, and the platform libm's `tanhf` is +/// not bit-identical across platforms (see `detmath`'s module doc for the CI failure that showed +/// it). Rounding a `f64`-computed value once is also the more accurate of the two. fn tanh_inplace(x: &mut [f32]) { for v in x.iter_mut() { - *v = v.tanh(); + *v = crate::detmath::tanh_f32(*v); } } diff --git a/crates/namir-fixtures/src/nam/lstm_infer.rs b/crates/namir-fixtures/src/nam/lstm_infer.rs index 8292077..c79c839 100644 --- a/crates/namir-fixtures/src/nam/lstm_infer.rs +++ b/crates/namir-fixtures/src/nam/lstm_infer.rs @@ -92,9 +92,12 @@ impl<'a> WeightReader<'a> { } } -/// `activations::sigmoid`, `activations.h:64-67`. +/// `activations::sigmoid`, `activations.h:64-67`. Evaluated through [`crate::detmath`] rather than +/// `f32::exp` for the reason that module's doc comment gives: this runs inside the calibration +/// pass whose result becomes the bytes of a checked-in fixture, and the platform libm is not +/// bit-identical across platforms. fn sigmoid(x: f32) -> f32 { - 1.0 / (1.0 + (-x).exp()) + crate::detmath::sigmoid_f32(x) } /// One LSTM cell: upstream's `LSTMCell` (`lstm.h:17-61`), weights and evolving state together, @@ -158,11 +161,12 @@ impl<'a> LstmCell<'a> { // Every c[k] first (`lstm.cpp:61-63`) ... for k in 0..hidden_size { self.c[k] = sigmoid(self.ifgo[f_off + k]) * self.c[k] - + sigmoid(self.ifgo[i_off + k]) * self.ifgo[g_off + k].tanh(); + + sigmoid(self.ifgo[i_off + k]) * crate::detmath::tanh_f32(self.ifgo[g_off + k]); } // ... then every h[k], from the just-updated c (`lstm.cpp:65-66`). for k in 0..hidden_size { - self.xh[input_size + k] = sigmoid(self.ifgo[o_off + k]) * self.c[k].tanh(); + self.xh[input_size + k] = + sigmoid(self.ifgo[o_off + k]) * crate::detmath::tanh_f32(self.c[k]); } } @@ -287,8 +291,8 @@ mod tests { pre(gate_order[2]), pre(gate_order[3]), ); - let c = sigmoid(zf) * c_prev + sigmoid(zi) * zg.tanh(); - let h = sigmoid(zo) * c.tanh(); + let c = sigmoid(zf) * c_prev + sigmoid(zi) * crate::detmath::tanh_f32(zg); + let h = sigmoid(zo) * crate::detmath::tanh_f32(c); (h, c) } diff --git a/crates/namir-fixtures/src/nam/mod.rs b/crates/namir-fixtures/src/nam/mod.rs index fdfe73d..1d89865 100644 --- a/crates/namir-fixtures/src/nam/mod.rs +++ b/crates/namir-fixtures/src/nam/mod.rs @@ -11,6 +11,22 @@ mod a2_infer; mod infer; mod lstm_infer; +/// Bumped by hand whenever anything in this module changes the *bytes* it generates for a given +/// `(shape, seed)` — a weight-layout change, a different constrained-init scale, a new metadata +/// field, a change to the calibration pass. +/// +/// Its one consumer is [`crate::library`]'s corpus cache key: that cache stores `.nam` files this +/// module produced, under a key that used to fold in only `library`'s own composition constants, +/// so a change *here* left a warm `target/namir-fixtures-cache` serving the previous layout's +/// files indefinitely — and nothing in `cargo test` re-validates a cached corpus, so the symptom +/// was a `namir-library` scan test failing against files the current parser rejects, with no hint +/// that a stale cache was the cause. Bumping this invalidates every cached corpus built from an +/// older generator. +/// +/// Version 1 is the pre-existing generator (this constant was introduced, not bumped, when the +/// cache key first started folding it in). +pub const GENERATOR_VERSION: u32 = 1; + use rand::Rng; use rand::SeedableRng; use serde::{Deserialize, Serialize}; @@ -224,6 +240,39 @@ struct ShapeParams { layers: usize, } +/// The placeholder `head_scale` [`generate`] builds its weights with, before the calibration pass +/// replaces it. Named so [`uncalibrated_weights`] reproduces `generate`'s first pass exactly +/// rather than by repeating a literal. +const BASE_HEAD_SCALE: f32 = 0.02; + +/// Exactly the weight vector [`generate`]'s **first** pass produces for `(shape, seed)`: seeded, +/// constrained-init, with [`BASE_HEAD_SCALE`] still in the trailing slot. No calibration, and so +/// no inference — this is RNG and arithmetic only, microseconds for the small shapes, which is +/// what makes it usable where calling `generate` would not be. +/// +/// Exposed for [`crate::library`]'s cache key, and worth the exposure for a reason a version +/// constant cannot cover: this fingerprints the weight *layout and initialisation* themselves, so +/// reordering `build_weights`' sections or changing a fan-in scale invalidates a cached corpus +/// mechanically — no hand-bump of [`GENERATOR_VERSION`] required, and no inference pass paid on +/// every cache hit. What it does not see is a change confined to the calibration pass or to the +/// metadata strings; those still need the constant bumped. +pub fn uncalibrated_weights(shape: WaveNetShape, seed: u64) -> Vec { + let specs = shape.layer_configs(); + let mut rng = rand_pcg::Pcg64::seed_from_u64(seed); + build_weights(&specs, &mut rng, BASE_HEAD_SCALE) +} + +/// The topology [`generate`] builds for `shape`, before any weights or calibration — cheap to +/// compute (no RNG, no inference pass) and fully determined by `shape`. +/// +/// Exposed for one purpose: a caller that *caches* generated `.nam` files can fold this into its +/// cache key and have a change to a shape's topology invalidate that cache mechanically, without +/// depending on someone remembering to bump [`GENERATOR_VERSION`] by hand. See +/// [`crate::library`]'s `cache_signature`, its only caller. +pub fn shape_signature(shape: WaveNetShape) -> Vec { + shape.layer_configs() +} + impl WaveNetShape { fn params(self) -> ShapeParams { match self { @@ -446,7 +495,7 @@ fn calibration_probe(seed: u64) -> Vec { (0..n) .map(|i| { let t = i as f32 / SAMPLE_RATE as f32; - 0.3 * (2.0 * std::f32::consts::PI * 220.0 * t).sin() + 0.3 * crate::detmath::sin_f32(2.0 * std::f32::consts::PI * 220.0 * t) + 0.05 * rng.gen_range(-1.0f32..1.0) }) .collect() @@ -483,7 +532,18 @@ fn build_model(shape: WaveNetShape, weights: Vec, head_scale: f32) -> NamMo } /// Generates a deterministic, RMS-calibrated `.nam` WaveNet fixture: same `(shape, seed)` always -/// produces byte-identical weights and output. +/// produces byte-identical weights and output — **on every platform, not merely on every run of +/// one machine**. +/// +/// That distinction is the whole reason [`crate::detmath`] exists, and it was not always true: +/// the calibration pass below runs a full inference, so until this generator stopped calling the +/// platform libm, one differing `tanh` or `sin` anywhere in it moved `head_scale` (and the +/// trailing weight mirroring it) by a ULP, and CI regenerated the checked-in goldens into +/// different bytes than the machine that wrote them. Everything else here was already exact: +/// `rand_pcg`'s bits, IEEE arithmetic, and `ryu`'s float printing. +/// [`tests::calibration_is_reproducible_across_platforms_to_the_bit`] pins the calibrated value +/// for every shape a checked-in fixture is generated from, so a platform that disagrees fails +/// here rather than silently writing a different golden. /// /// Two passes: build weights with constrained init and a placeholder `head_scale`, measure the /// resulting output RMS over a calibration probe, then rescale `head_scale` so the *calibrated* @@ -491,7 +551,7 @@ fn build_model(shape: WaveNetShape, weights: Vec, head_scale: f32) -> NamMo /// can't produce a finite, sane RMS (D-19.1's hazard) — the caller should try a different seed. pub fn generate(shape: WaveNetShape, seed: u64) -> Result { let specs = shape.layer_configs(); - let base_head_scale = 0.02f32; + let base_head_scale = BASE_HEAD_SCALE; let mut rng = rand_pcg::Pcg64::seed_from_u64(seed); let weights = build_weights(&specs, &mut rng, base_head_scale); @@ -950,8 +1010,17 @@ fn build_a2_model(shape: A2Shape, weights: Vec, head_scale: f32) -> A2Model } /// Generates a deterministic, RMS-calibrated `.nam` A2 fixture: same `(shape, seed)` always -/// produces byte-identical weights and output. The A2 analogue of [`generate`]; see that -/// function's doc comment for the two-pass calibration shape this follows unchanged. +/// produces byte-identical weights and output, on every platform. The A2 analogue of +/// [`generate`]; see that function's doc comment for the two-pass calibration shape this follows +/// unchanged, and for the cross-platform claim. +/// +/// This is the shape that *demonstrated* the problem [`crate::detmath`] fixes. A2 inference is +/// LeakyReLU throughout — pure arithmetic, no libm — so the only transcendental anywhere in this +/// function was [`calibration_probe`]'s `sin`, and that alone was enough for M14's CI to +/// regenerate `a2_full.nam` with a different `head_scale` on all three runners. The committed +/// bytes now come back identical here; `namir-nam`'s `the_a2_golden_models_match_their_generator` +/// still compares that one value under a relative tolerance rather than byte for byte, which is +/// no longer necessary but is harmless. pub fn generate_a2(shape: A2Shape, seed: u64) -> Result { let specs = shape.layer_configs(); let base_head_scale = 0.02f32; @@ -1051,8 +1120,11 @@ fn build_lstm_model(shape: LstmShape, weights: Vec) -> LstmModel { } /// Generates a deterministic, RMS-calibrated `.nam` LSTM fixture: same `(shape, seed)` always -/// produces byte-identical weights and output. The LSTM counterpart of [`generate`]; see that -/// function's doc comment for the two-pass shape this follows. +/// produces byte-identical weights and output, on every platform — the LSTM counterpart of +/// [`generate`]; see that function's doc comment for the two-pass shape this follows and for what +/// "on every platform" cost. This architecture reaches the platform libm twice over (`tanh` *and* +/// the sigmoid's `exp`), and its calibration scales the whole head tail rather than one float, so +/// a libm difference used to land in several weights rather than one. /// /// The one structural difference from `generate`: WaveNet has a single trailing `head_scale` /// float to rescale for calibration; LSTM has no such scalar (see `build_lstm_weights`'s doc @@ -1114,6 +1186,81 @@ mod tests { } } + /// Determinism *across machines*, not just within one run — the property "same `(shape, seed)` + /// gives byte-identical weights" claims and [`generation_is_deterministic_for_a_given_seed`] + /// cannot check, because a second call on the same machine reproduces that machine's own libm + /// exactly. + /// + /// Until [`crate::detmath`] landed, the claim was false: `head_scale` is calibrated through a + /// full inference pass, so one differing `tanh`/`sin`/`exp` anywhere in it moved that float, + /// and M14 watched exactly that happen — the checked-in A2 goldens regenerated to + /// `head_scale: 0.15790401` on all three CI runners and `0.15790403` on the machine that wrote + /// them. These pins are the tripwire that failure needed: the calibrated value for each shape + /// a checked-in fixture is generated from, to the bit. Nothing about the numbers themselves is + /// significant — what matters is that every platform agrees on them, so a machine that + /// disagrees fails *here*, at the generator, naming the cause, instead of silently writing a + /// different golden or corpus file. + /// + /// If one of these ever fails, do not adjust the constant. The generator has either changed + /// (then the paired committed fixtures and their externally-rendered references must be + /// regenerated together — see `namir-nam/tests/golden_reference.rs`'s recipe) or something in + /// the calibration path has stopped being reproducible (then find it: every operation on that + /// path is required to be IEEE-754-specified arithmetic). + #[test] + fn calibration_is_reproducible_across_platforms_to_the_bit() { + // `wavenet_nano.nam` and `fuzz/corpus/load_nam/valid_nano.json`'s shapes and seeds. + for (shape, seed, bits) in [ + (WaveNetShape::Nano, 30u64, 0x3f1c_9606u32), + (WaveNetShape::Nano, 1, 0x3e52_e218), + ] { + let model = generate(shape, seed).unwrap_or_else(|e| panic!("{shape:?}/{seed}: {e}")); + assert_eq!( + model.config.head_scale.to_bits(), + bits, + "{shape:?}/{seed}: calibrated head_scale is {} ({:#010x}), not the value every \ + platform must agree on", + model.config.head_scale, + model.config.head_scale.to_bits() + ); + assert_eq!( + model.weights.last().copied().map(f32::to_bits), + Some(bits), + "{shape:?}/{seed}: the trailing weight must mirror head_scale exactly" + ); + } + + // `a2_full.nam` and `a2_lite.nam` — the two shapes whose committed bytes M14's CI could + // not reproduce. + for (shape, bits) in [ + (A2Shape::Full, 0x3e21_b198u32), + (A2Shape::Lite, 0x3f55_7617), + ] { + let model = generate_a2(shape, 30).unwrap_or_else(|e| panic!("{shape:?}: {e}")); + assert_eq!( + model.config.head_scale.to_bits(), + bits, + "{shape:?}: calibrated head_scale is {} ({:#010x}), not the value every platform \ + must agree on", + model.config.head_scale, + model.config.head_scale.to_bits() + ); + } + + // LSTM calibrates by scaling the head tail rather than a single `head_scale` float, so the + // pin is on that tail. `lstm_tiny.nam`'s shape and seed. + let lstm = generate_lstm(LstmShape::Tiny, 30).expect("lstm tiny generates"); + let tail: Vec = lstm.weights[lstm.weights.len() - 3..] + .iter() + .map(|w| w.to_bits()) + .collect(); + assert_eq!( + tail, + vec![0x416b_ca35, 0xc186_f4ec, 0x0000_0000], + "LSTM head tail is {:?}, not the values every platform must agree on", + &lstm.weights[lstm.weights.len() - 3..] + ); + } + #[test] fn generation_is_deterministic_for_a_given_seed() { let a = generate(WaveNetShape::Standard, 42).unwrap(); From 3c993640547b2ec637e2906c8d17451deaf548bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:11:30 +0000 Subject: [PATCH 17/44] Declare the new corpus assets, regenerate the plan NFR-LIC-050: my own commit 3a5f515 added four fuzz-corpus files and a state corpus preset without recording their provenance. All five are generated -- the corpus files by the two generate_*_fuzz_corpus examples from seeded namir- fixtures mutations, the preset hand-authored from the format spec's worked example -- matching how every sibling in those directories is already declared. `xtask assets` is not in AGENTS.md's command list, which is why I had not been running it; the wave-3 gate runs did not cover it. Plan row: FR-GATE-020 returns to plain, the detector fix having closed it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-fixtures/assets.lock | 5 +++++ docs/03-test-plan.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/namir-fixtures/assets.lock b/crates/namir-fixtures/assets.lock index 3ee0d65..88c66ff 100644 --- a/crates/namir-fixtures/assets.lock +++ b/crates/namir-fixtures/assets.lock @@ -21,11 +21,15 @@ crates/namir-ir/fuzz/corpus/probe_wav/mutated_0.bin 172 128de7f254e74d92587bead1 crates/namir-ir/fuzz/corpus/probe_wav/mutated_1.bin 22 5c12c3c03937bde3b9a6524ec01efed575ad63bf62f941bac92d9f75b4fc7690 generated crates/namir-ir/fuzz/corpus/probe_wav/mutated_2.bin 172 e58c28f83ed74215bf127288fea28227be97c5bd120ea3baccbba224d43bbced generated crates/namir-ir/fuzz/corpus/probe_wav/mutated_3.bin 172 f2764a3c263c4a5a73219e506661e5446461b75d8aefdb7f36e6d55cb4fb017d generated +crates/namir-ir/fuzz/corpus/probe_wav/mutated_4.bin 172 e5a93f18ce8ae9af8ecfc4ee1850dc48b513b4e975d7aad6618ce9de2d643ff0 generated +crates/namir-ir/fuzz/corpus/probe_wav/mutated_5.bin 172 7bd02d43f78adde9f1dcf1df077016b887f480b305c16901cab0955b56ff6400 generated crates/namir-ir/fuzz/corpus/probe_wav/valid_delta.wav 172 4dfc3359f6a97e6e9688e143df2c15c4d82ec1ef879ad82aa498567ee4ac497c generated crates/namir-nam/fuzz/corpus/load_nam/mutated_0.bin 7638 bed0ee6a786576da4fda5b804c527026feefc26569abd6e0ba758921010582a3 generated crates/namir-nam/fuzz/corpus/load_nam/mutated_1.bin 998 216e2add66aca352470ddbc4369b213af5b8c79fdd6ff26540ec424390299d25 generated crates/namir-nam/fuzz/corpus/load_nam/mutated_2.bin 5141 da1284add61cb3495752605696ddc1e47a0f9fc85842f967cd6b2b478e56a0b2 generated crates/namir-nam/fuzz/corpus/load_nam/mutated_3.bin 5159 f248e38d6bcd8d30439a921b7ef9799321f3ec821cb51b0e1f72c98879a0c92d generated +crates/namir-nam/fuzz/corpus/load_nam/mutated_4.bin 5160 f1956a7f18653848cf4cf95d213afafb77fbd27a1f7abf0b7fdca404e49a1cf2 generated +crates/namir-nam/fuzz/corpus/load_nam/mutated_5.bin 5149 d94cc5acd5903057b75fab7ee14aea2aa0719911d809acab07bd54715b64933a generated crates/namir-nam/fuzz/corpus/load_nam/valid_nano.json 7638 7db35fbd8d6c227353525b13e787fabfb88778562f17cc69dd2b21a2ec07b75e generated crates/namir-nam/tests/golden/a2_full.nam 205986 9cea282d2e177cb3265046f093757ff023719b6a812f1b5dfb4e063061f8a18e generated crates/namir-nam/tests/golden/a2_full_reference.wav 1920044 8ceee6870bb58f673f581e9fe981fd41ee0b423a69cf2317cfade4d648990a68 generated @@ -45,5 +49,6 @@ crates/namir-state/tests/corpus/unreleased-v1/full.namirpreset 986 a48a8d9e7fcac crates/namir-state/tests/corpus/unreleased-v1/future-version.namirpreset 73 48376504b9a314e8800d0673681475c80b52423911b25efaf1c992b55e778828 generated crates/namir-state/tests/corpus/unreleased-v1/legacy-global-section.namirpreset 132 aaca262ed95e0e9a4b26fba334ea40146b6ef68d2c0f623b5e41f38275f653d9 generated crates/namir-state/tests/corpus/unreleased-v1/minimal.namirpreset 26 f83070701468b0a74d5a036d9641fb9cce6e178f63873d6d0245ad5f06675c5b generated +crates/namir-state/tests/corpus/unreleased-v1/references.namirpreset 773 6b2cb3de725faeb34925241861290eaccf3d6dac609ffbc31154c6c072163c84 generated crates/namir-state/tests/corpus/unreleased-v1/unknown-fields.namirpreset 238 2ef762cddb9a8f88d67994d63a905d40aa8ad5e0e40d0f455058b547b3082e6f generated crates/namir-ui/src/brand/namir_mark.alpha 34376 e2fe4192d1a6e88840851685cac80ac9a8c15e76ffbd947153805e95c997e86a generated diff --git a/docs/03-test-plan.md b/docs/03-test-plan.md index 5c549d3..fd51da3 100644 --- a/docs/03-test-plan.md +++ b/docs/03-test-plan.md @@ -36,7 +36,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | FR-ERR-060 | S | **PARTIAL** — `workspace`: FR-ERR-060 — the method's "no network-capable dependency is linked" remains a by-name deny list its own comment calls deliberately non-exhaustive: a network-capable crate not on it enters Cargo.lock with this gate green, and the compensating control is that xtask attribution fails until a human adds the new crate to THIRD-PARTY-NOTICES.md, which is review rather than a build-time classification; closes M8 | | FR-ERR-070 | S | `workspace` | | FR-GATE-010 | U | `namir-engine` | -| FR-GATE-020 | U | **PARTIAL** — `namir-dsp`: FR-GATE-020 — the method ("exactly one close event") is asserted over FR-GATE-010's hold range from 5 ms up. At 0 and 1 ms the same decaying low-E note produces 62 and 61 close events with the shipped 3 dB gap: the 1 ms detector ripples about 9 dB peak-to-peak on an 82 Hz carrier and the gap is narrower than the ripple. That is a gate defect rather than a test gap — 12 dB of hysteresis, or a detector whose release is slow relative to the lowest program frequency, produces exactly one at every hold — so the two settings are left unasserted rather than pinned to today's numbers; closes M8 | +| FR-GATE-020 | U | `namir-dsp` | | FR-GATE-030 | U | `namir-dsp` | | FR-IN-010 | U | `namir-engine` | | FR-IN-020 | U | **PARTIAL** — `namir-dsp`: FR-IN-020 — the "M for the display" half of the Verify line has no artifact: there is no docs/manual-tests/fr-in-020-*.md, and namir_ui::MeterReading carries only peak_db and rms_db, so the peak-hold value TrimStage publishes reaches no UI field for any script to observe; closes M8 | From 84875af481e8113ab2438a94201404a10390fa56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:35:42 +0000 Subject: [PATCH 18/44] Record where five decisions had drifted from the built behaviour Consequence notes, appended in place per the house convention, for what this session's fixes changed underneath them: D-12.1 (removal suppression split from cancellation; settling window re-anchored to scan start; symlinks now followed, with loop safety made explicit rather than implicit), D-12.3 (favourites exempted from the corruption policy; staging file made per-process), D-13.2 (SCHED_FIFO target lowered off the policy maximum; Darwin's real mechanism recorded; the "not yet called from any audio thread" line retired). AGENTS.md's unsafe census said five blocks in thread_priority.rs; it is six, one sched_get_priority_min call added with the SCHED_FIFO fix. AGENTS.md's FR-NAM-030 illustration is corrected rather than deleted. M14 Phase 4b landed a real NeuralAmpModelerCore comparison, so "the only such comparison in the tree is S-1's" is false -- but the lesson the passage teaches still holds, because no trainer-produced A2 export has ever been loaded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- AGENTS.md | 11 +++++-- docs/02-architecture.md | 68 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd71e18..5dc0b0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,7 +201,7 @@ Cargo.toml comments), `rtrb` for both SPSC rings, and — decided at M9's P0 pas `clack-host` as a `namir-clap` **dev**-dependency for the in-process CLAP host harness, adopted precisely because `clack-extensions`' own `__doc_utils.rs` instantiates a plugin through `PluginEntry::load_from_clack` with no `unsafe` at all. Checked this pass: the only `unsafe` blocks -anywhere under `crates/` are one in `gui.rs`, five in `denormal.rs` and five in +anywhere under `crates/` are one in `gui.rs`, five in `denormal.rs` and six in `thread_priority.rs` — plus that file's `unsafe extern "system"` declaration block, which edition 2024 requires of any `extern` block — and none at all in any bench or integration test, where there should be none. Any new `unsafe` block outside those three files is a bug, not a style choice — @@ -303,7 +303,14 @@ in the roadmap for the full investigation). Before trusting a benchmark number: method asks for. The only `NeuralAmpModelerCore` comparison in the tree is S-1's, under `spikes/`, excluded from the workspace and not runnable under `cargo test`. The tool reported the requirement plainly covered from M3 until M9a's sweep demoted both sites to - `trace-partial:`. Read D-23.1 (`docs/02-architecture.md` §23) for the full rule before tagging + `trace-partial:`. **That illustration is itself now out of date, and is kept because the reasoning + it teaches is the point.** M14 Phase 4b (changelog 0.34) rendered two generated A2 fixtures through + the pinned reference build and asserted the comparison in-process, so FR-NAM-030's tags live in + `crates/namir-nam/tests/golden_reference.rs` and are plain — closed by building the missing + evidence, not by promoting a tag. So the sentence above about S-1 being the only + `NeuralAmpModelerCore` comparison in the tree no longer holds. What has *not* changed is the shape + of the lesson: no trainer-produced A2 export has ever been loaded, so a misreading of the schema + shared between generator, parser and reference target is still invisible to every test here. Read D-23.1 (`docs/02-architecture.md` §23) for the full rule before tagging anything non-obvious. - **Expect `trace-partial:` to be the common case, not a rarity.** M9a swept every Must against D-23.1's two questions and demoted 54 tags from plain `trace:` in one comment-only pass — no test diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 93561e9..c5f3177 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -1334,6 +1334,33 @@ FR-LIB-070's "never crash Namir or the host" spirit (an emptied library is a dat not a crash, but the requirement's intent is the same: a missing file must degrade gracefully, not propagate as false information). +*Consequence (added M15, 2026-08-28, from issues #65-#73).* This decision's suppression rule was +built as one flag: a scan either saw the whole tree or it did not. That is right for cancellation +and wrong for a directory the scanner reached and could not read, because clearing `complete` for +the whole tree suppresses removals everywhere — one ACL-restricted folder would leave a genuinely +deleted file elsewhere in the index forever. The rule is now two mechanisms: `complete` keeps its +cancellation meaning, and a separate list of unreadable prefixes filters removals *under those +prefixes only*, so the index degrades exactly where sight failed and nowhere else. A directory +listing that fails partway, and a child whose `file_type`/`metadata` cannot be read, both feed the +same list rather than discarding their siblings. + +*Consequence (added M15, 2026-08-28, from issue #67).* The settling window above is described +against the scan's completion time. That protected only files examined in the final seconds of a +scan: a file edited during a long scan, after its own examination, could be invisible until some +later edit moved its mtime again. The index now records the scan's **start**, and the test is +one-sided (`mtime >= prior_start - 2 s`), which covers every examination time in that scan since +`start <= t` for all of them. The on-disk key was renamed with a serde alias, so an index written +by an earlier build still loads. + +*Consequence (added M15, 2026-08-28, from issue #73).* The scanner did not traverse directory +symlinks, and got loop safety for free as a result. That was never a decision — it fell out of +asking `file_type()` and nothing else — and it left a user whose library is a symlinked collection, +an ordinary arrangement on Linux and macOS, with an empty library and no warning. Symlinks are now +followed, and the loop safety that shape supplied implicitly is an explicit guard: each canonical +directory target is followed at most once, and the skipped second spelling is reported rather than +dropped, and protected from removal, since those files are still on disk. + + **Decision D-12.3 (AQ-3 resolved — added M5)** — The index is stored as a single pretty-printed JSON document, written whole and replaced atomically (temp file, `sync_all`, `std::fs::rename` over the destination — which replaces an existing file on both Unix and Windows, so no @@ -1370,6 +1397,22 @@ an embedded B-tree store's build-script and cross-compilation risk (both new cra is a weaker case than that one was, and D-17.1 already set the precedent for how this project weighs that trade. +*Consequence (added M15, 2026-08-28, from issues #68 and #69).* "Degrades to a full rescan" is true +of entries and false of favourites: a rescan repopulates what it finds on disk, and a favourite mark +exists nowhere on disk. The corruption policy was therefore destroying hand-curated, unrecoverable +data under a warning that promised a rebuild. Favourites still ride inside the index document, and +are additionally mirrored to a sidecar written with the same stage-and-rename discipline. Precedence +is one-directional and deliberate: **the index document is authoritative whenever it loads**, so a +stale sidecar can never resurrect a mark the user removed; the sidecar is read only when the +document could not be. A sidecar write failure is not a save failure — the marks are also in the +index that just landed, and reporting a failure for a successful save is the opposite of P8. + +Separately, the atomic replacement above staged through a fixed, predictable file name beside the +destination. Two Namir processes — an ordinary arrangement, the standalone app and a plugin +instance in a DAW — therefore staged onto each other. The staging name now carries the process id +and a per-process counter, still beside the destination so the rename stays within one filesystem. + + **Decision D-12.4 (for RD-1)** — A library entry carries an `origin` field from the outset — `Local` in 1.0, extensible to a remote source later. Tone3000 integration then adds a variant rather than a schema migration across every user's index. @@ -1454,6 +1497,30 @@ draws for the denormal guard. See that module's own doc comment for exactly when caller should invoke it, and §17's dependency register for the one new dependency it takes (`libc`, Linux/macOS only) and why. +*Consequence (added M15, 2026-08-28, from issues #75, #76 and #81).* Two corrections to what this +crate's thread-priority half actually promises. First, "elevation" was implemented as the policy's +**maximum** priority, which on Linux is `SCHED_FIFO` 99 — where `watchdog/N` and `migration/N` live, +and above the 50 that threaded IRQ handlers take. A runaway audio thread at 99 outranks everything +capable of preempting it. The target is now `min + 10` (11 on Linux), expressed relative to the +policy minimum because the two Unix ranges (1..=99 on Linux, 15..=47 on Darwin) do not overlap in +meaning. Second, `SCHED_FIFO` is not how Darwin schedules audio: CoreAudio-grade threads are +promoted with `thread_policy_set(..., THREAD_TIME_CONSTRAINT_POLICY, ...)`, a period/computation/ +constraint deadline contract no POSIX priority number expresses. What this module does on macOS +raises the thread within the timeshare band and reports `Elevated`, a success materially weaker +than the Windows and Linux paths. That is recorded rather than implemented — macOS is a secondary, +non-1.0 target and no CI machine available to this project can exercise a Mach binding — and when +macOS becomes supported the outcome enum will need a way to say "raised, but without a deadline +guarantee". + +The "not yet called from any audio thread" note above is also out of date: both shells now call it. +The outcome is `#[must_use]` and carries `diagnostic() -> Option` returning a catalogued +code with no allocation, because the call happens *inside* the audio callback — a thread can only +raise its own priority — where `namir-app`'s `stream.rs` and `namir-clap`'s `audio.rs` are both on +`xtask rt-logging`'s audio-thread module list and may not name the logger, and a `format!` would +trip D-7.5's allocation harness. The `Copy` outcome is carried off the audio thread and recorded +from the UI thread. + + **Decision D-13.3** — The CLAP plugin installs to the **CLAP-specified search paths only**, and the per-user path is the default. @@ -3787,3 +3854,4 @@ drift was findable. | 0.33 | 2026-08-12 | **M14 Phase 0: two risk rows answered in place, no decision rewritten, no code.** **R-13's stated test has fired and its reinterpretation is now written down at the row itself**, which is the point of the exercise — the partial count went **56 -> 68** against a row that said a count not falling by M12 means the mechanism is being used as a bypass. The arithmetic is recorded there: the twenty *uncovered* Musts M9a left are now zero, M9b converted twelve of them and then demoted two of its own plain tags on finding they over-claimed, and M14's A2 pass demoted two more. Every movement made the ledger weaker and truer, which is the opposite of the failure the row predicted — so the count is **demoted from a test to an indicator**, the uncovered count and the per-milestone re-reading of `uncovered:` fields are named as the replacement tests, and **the row stays open at Medium** rather than being declared mitigated, because no cheap mechanical discriminator between the two kinds of partial has been designed. **R-11 (signing) is recorded as still open and explicitly not decided** — issue #23 turns on whether 1.0 is a public release, which is an owner's question, and the signed CI path is unbuilt either way. **R-9 is unchanged and stays reopened.** Eighteen `*Consequence*` notes land in the FRS rather than here (see `01-functional-requirements.md` change log 0.8): the sub-40 kHz clause at FR-NAM-060/FR-IR-030, FR-CHAIN-070's Should dropped, NFR-PORT-030's method kept as a door-open check, FR-STATE-040's compound method, seven accepted limitations and five items recorded as still open. **No decision in this document is amended and no new D-number is added** — Phase 0 was a disposition pass over existing decisions, and where one is affected the note sits at the requirement it governs. | | 0.34 | 2026-08-12 | **M14 Phase 4b: A2 is compared against `NeuralAmpModelerCore` for the first time, and the comparison holds.** Two generated A2 fixtures (`a2_full.nam`, `a2_lite.nam`, seed 30, D-19.1) rendered through the pinned reference build (`3cde95c`, `-DNAM_USE_INLINE_GEMM -DNAM_ENABLE_A2_FAST=OFF`, built outside the repository) over the same `input_10s.wav` the two existing goldens use, asserted in-process: **A2-Full -132.58 dB, A2-Lite -126.46 dB**. `FR-NAM-030` and `FR-NAM-150` are promoted from `trace-partial:` to plain `trace:` **by closing their `uncovered:` fields, not by promoting the tags** — the golden set now spans all three configurations this crate runs, and FR-NAM-150's probe clause is met by the 10-second signal rather than by `a2_fixtures.rs`'s 4 000-sample probe, which was *shorter* than A2's 6 346-sample receptive field and is raised to 20 000 in the same pass. The golden bar tightens from -85 dB to FR-NAM-030's own **-90 dB**, because a plain tag cannot be carried by an assertion looser than the requirement it claims to verify; all four fixtures clear it by ≥36 dB, and the headroom that spends is recorded at the constant (M10's `Standard`-shape cross-check sat at -90.3 to -90.9 dB). **FR-NAM-110's method is performed for the first time**: `crates/namir-nam/tests/latency.rs` drives an impulse through every architecture, differences it against the model's own zero-input response, and cross-correlates — the previous evidence was two tests reading an accessor whose body is the literal `0` and asserting it equalled `0`, which would have passed unchanged had inference introduced delay. Its tag stays `trace-partial:`, narrowed to the residue in `namir-engine` (`NamStage`'s `SlotResampler` latency, asserted only as `> 0`) and re-booked M8 → M14. **R-9 is narrowed, not retired**, severity High → Medium: the silent-wrong-weight-order failure it was raised about is now excluded by a real-reference comparison, and this pass also resolves the contradiction in its own reopening text — M10's recorded "A2 Full and A2 Lite at -90.31 dB each" cannot have been an A2 measurement, since the two shapes measure -132.58 and -126.46. What stays open is stated rather than absorbed: no genuine trainer-produced A2 export has ever been loaded, so a *shared* misreading of the schema between generator, parser and reference target is invisible to every test in the tree; and upstream's default `NAM_ENABLE_A2_FAST=ON` path is not what these renders exercise — a rationale for excluding it is now recorded at `golden_reference.rs`'s header where before there was none, which is not the same as a measurement, and none was taken. Partial count 68 → 66. | | 0.35 | 2026-08-28 | **A manual-test document now has to say whether it was run, and the traceability gate reads that instead of the file name (issue #34).** D-18.6 gains a `*Consequence (added M15, 2026-08-28)*` note holding the verdict convention: every file under `docs/manual-tests/` carries a line beginning `**Result:` opening with one of `PASS`, `FAIL`, `PARTIAL` or `NOT EXECUTED`; only `PASS` credits a requirement; the worst line in a document wins; and a missing, tokenless or self-contradicting verdict is a **hard error** that aborts the run upstream of `--write`, `--allow-uncovered` and every exit-status term, on D-23.1's malformed-annotation footing — a bad input, not a coverage gap. `docs/manual-tests/README.md` is added as the authors' copy of the rule and is the one file exempt from it. Eight live documents carried no verdict line and were given one recording what their own prose already said; two carried a verdict line no token opened (`fr-ui-010`'s self-contradicting `PASS`, corrected to `PARTIAL`, and `fr-io-070`'s second line). No verdict was promoted and no requirement became more met: the six Musts left uncovered — FR-IO-030, FR-IO-050, FR-UI-030, FR-UI-040, FR-UI-050 and FR-UI-070 — are the same six their documents already recorded as NOT EXECUTED, PARTIAL or FAIL. | +| 0.36 | 2026-08-28 | **A pass over the open issue tracker; five decisions gain consequence notes recording where their stated behaviour had drifted from the built one.** **D-12.1** twice: its removal-suppression rule was one flag, right for cancellation and wrong for an unreadable directory, since clearing `complete` tree-wide would leave a genuinely deleted file elsewhere in the index forever — unreadable prefixes now suppress removals under themselves only; and its settling window, described against scan *completion*, protected only files examined in a scan's final seconds and is re-anchored to the scan's start. A third note records that the scanner now follows directory symlinks: not following them was never a decision, it fell out of asking `file_type()`, and it left a user with a symlinked collection holding an empty library — the loop safety that shape supplied implicitly is now an explicit canonical-target guard. **D-12.3**: "degrades to a full rescan" is true of entries and false of favourites, which exist nowhere on disk, so the corruption policy was destroying hand-curated data under a warning promising a rebuild; favourites gain a sidecar with the index document authoritative whenever it loads, so a stale sidecar cannot resurrect a removed mark. Its staging file also carried a fixed name, so two Namir processes staged onto each other. **D-13.2**: elevation was implemented at the policy maximum, which on Linux is `SCHED_FIFO` 99 — where `watchdog/N` and `migration/N` live, above the 50 threaded IRQ handlers take — so a runaway audio thread outranked everything able to preempt it; now `min + 10`. The same note records that `SCHED_FIFO` is not Darwin's mechanism at all (CoreAudio-grade threads take `THREAD_TIME_CONSTRAINT_POLICY`, a deadline contract no priority number expresses), recorded rather than implemented, and retires that decision's "not yet called from any audio thread" line. **D-16.1** gains its own note, written by the notice work: it still described three catalogue fields while the tree has carried a fourth (`remedy`) and a one-token substitution vocabulary since M14 W10. | From dab593ca9ac2d8258bc1ed5f0ce4e79409c0a819 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:45:33 +0000 Subject: [PATCH 19/44] namir-core contracts, and a duplicate params.lock key (#128-#131, #118) #129: the never-format-NaN contract rested on f32::max's NaN semantics. The non-finite cases are now handled before any arithmetic, so it holds by construction. The NaN assertions passed before this change too -- which is the issue's point: they were passing by accident, and are now pinned to something. Deviation from the prescribed fix, deliberately: mapping +inf to MIN_DB would make a blown-up signal read as dead silence, the same silently-wrong reading the issue objects to in Meter::peak. A symmetric MAX_DB ceiling keeps the reading monotonic in the magnitude and always finite. #128 was a doc error, not a code one: negative input reads as its magnitude, about +14 dB for -5.0, and only sub-normal magnitudes and NaN floor. The regression guard passes before and after by nature -- it exists so the doc's old claim is not implemented later. #130's premise is half stale: all three crates do call assert_unique_ids and add a namespace assertion on top, so the empty-id check is dropped nowhere. The true half is the namespace divergence, now checked centrally by deriving it from the first id, which gives all ten catalogues the check without editing any of them. #118: a duplicate key silently overwrote, and check_manifest returned Ok(()) -- reporting nothing at all. It is now a violation, and the tombstoned line wins the slot regardless of file order, so a regeneration cannot drop the record. A new code rather than DUPLICATE_KEY, whose remedy is about the registry and is the wrong advice for a duplicated file line. Still open from #129: Meter::peak's own poisoning, in namir-dsp and outside this change's scope. One non-finite sample still leaves the meter reading silence forever; the gain fix does not mask it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-core/src/content_hash.rs | 21 ++++- crates/namir-core/src/error.rs | 67 +++++++++++++- crates/namir-core/src/gain.rs | 115 +++++++++++++++++++++--- crates/namir-params/src/error_codes.rs | 14 +++ crates/namir-params/src/manifest.rs | 117 ++++++++++++++++++++++--- 5 files changed, 309 insertions(+), 25 deletions(-) diff --git a/crates/namir-core/src/content_hash.rs b/crates/namir-core/src/content_hash.rs index 24996a2..28c31e4 100644 --- a/crates/namir-core/src/content_hash.rs +++ b/crates/namir-core/src/content_hash.rs @@ -118,9 +118,13 @@ impl ContentHasher { } /// Feeds more bytes in. Callable any number of times, in any chunk size. - pub fn update(&mut self, bytes: &[u8]) -> &mut Self { + /// + /// Returns nothing rather than `&mut Self` (issue #131): the builder-chaining signature + /// advertises `hasher.update(bytes).finish()`, which cannot compile here — [`Self::finish`] + /// takes `self` by value and this type is not `Copy`, so that expression tries to move out of + /// a borrow. Every call site had to discard the value anyway. + pub fn update(&mut self, bytes: &[u8]) { self.0.update(bytes); - self } /// Finalises the hash. Consumes `self` because BLAKE3's finalisation, unlike its update step, @@ -239,4 +243,17 @@ mod tests { fn content_hasher_of_no_input_matches_of_empty_slice() { assert_eq!(ContentHasher::new().finish(), ContentHash::of(b"")); } + + /// Issue #131: `update` returns nothing, and the signature says so. It used to return + /// `&mut Self` — the builder-chaining shape — while `finish` takes `self` by value and + /// `ContentHasher` is not `Copy`, so `hasher.update(bytes).finish()`, the one expression that + /// return value existed to enable, could never compile. The `let ()` binding below is the + /// assertion: it type-checks against a unit return and against nothing else. + #[test] + fn update_returns_nothing_since_the_type_cannot_be_chained() { + let mut hasher = ContentHasher::new(); + let () = hasher.update(b"na"); + let () = hasher.update(b"mir"); + assert_eq!(hasher.finish(), ContentHash::of(b"namir")); + } } diff --git a/crates/namir-core/src/error.rs b/crates/namir-core/src/error.rs index b87c4c9..f681429 100644 --- a/crates/namir-core/src/error.rs +++ b/crates/namir-core/src/error.rs @@ -157,17 +157,46 @@ impl ErrorCode { } } -/// A crate-time check that a catalogue slice has no duplicate or empty identifiers, and that every -/// entry offers the user a remedy (FR-UI-070's third clause). Each crate that defines `ErrorCode` -/// consts calls this from its own tests over its own catalogue. +/// A crate-time check over one crate's error catalogue: no empty or duplicate identifiers, one +/// shared namespace across the slice, and a remedy on every entry (FR-UI-070's third clause). +/// Every crate that ships a catalogue calls this from its own tests over it — all ten +/// `error_codes.rs` modules in the tree, which is what makes FR-ERR-020's first conjunct one +/// invariant rather than a per-crate approximation of one. (`namir-ui`'s `ui.*` codes are test +/// fixtures, not a catalogue; `namir-core`'s own are the fixtures for this function.) /// /// The remedy assertion duplicates `xtask error-catalogue`'s deliberately: that check is /// line-based and sees only what a source line spells out, while this one runs against the real /// `const` values a crate actually enumerates. +/// +/// # The namespace check, and what a caller still has to assert itself (issue #130) +/// +/// Every id in the tree is `..`, and this checks the part it +/// can see: that each id *has* a leading namespace segment, and that all of them agree on it. What +/// it cannot check is which namespace — a catalogue that is uniformly wrong (`libary.` throughout) +/// is internally consistent. `namir-library`, `namir-state` and `namir-worker` each additionally +/// assert the literal string in their own tests, and that assertion is theirs to make: only the +/// calling crate knows its own name. The two checks are complementary, not duplicates, which is +/// the shape issue #130 asked for — this helper used to check neither, and the doc above it +/// claimed a uniformity three crates were quietly hand-rolling around. pub fn assert_unique_ids(codes: &[ErrorCode]) { let mut seen = std::collections::HashSet::new(); + let mut namespace: Option<&str> = None; for code in codes { assert!(!code.id.is_empty(), "ErrorCode with an empty id"); + let found = namespace_of(code.id).unwrap_or_else(|| { + panic!( + "the error code {:?} has no namespace segment -- an id is \ + ..", + code.id + ) + }); + let expected = *namespace.get_or_insert(found); + assert_eq!( + found, expected, + "the error code {:?} is in namespace {found:?}, but this catalogue's namespace is \ + {expected:?} -- one catalogue is one crate's", + code.id + ); assert!( seen.insert(code.id), "duplicate ErrorCode id: {:?}", @@ -192,6 +221,13 @@ pub fn assert_unique_ids(codes: &[ErrorCode]) { } } +/// The leading namespace segment of an id — everything before its first `.` — or `None` if the id +/// has no `.`, or nothing before or after it. See [`assert_unique_ids`]. +fn namespace_of(id: &str) -> Option<&str> { + let (namespace, rest) = id.split_once('.')?; + (!namespace.is_empty() && !rest.is_empty()).then_some(namespace) +} + /// Whether `template` contains a `{...}` token that is not [`DETAIL_PLACEHOLDER`]. fn mentions_unknown_placeholder(template: &str) -> bool { let mut rest = template; @@ -262,6 +298,31 @@ mod tests { assert_unique_ids(&[NO_REMEDY]); } + /// Issue #130: the helper's own uniformity claim, checked. Every catalogue in the tree is one + /// crate's, and every id in it opens with that crate's namespace segment; a slice mixing two + /// namespaces is either two catalogues passed as one or a typo in an id, and both are defects + /// the crate's own `catalogue_ids_are_unique` test is the last place to catch. + #[test] + #[should_panic(expected = "namespace")] + fn a_catalogue_mixing_two_namespaces_fails() { + const OTHER: ErrorCode = ErrorCode::new( + "elsewhere.example.other", + Severity::Info, + "Something.", + "Nothing.", + ); + assert_unique_ids(&[A, OTHER]); + } + + /// The same check's other half: an id with no namespace segment at all cannot be attributed to + /// a crate by anything reading the catalogue. + #[test] + #[should_panic(expected = "namespace")] + fn an_id_with_no_namespace_segment_fails() { + const BARE: ErrorCode = ErrorCode::new("oops", Severity::Info, "Something.", "Nothing."); + assert_unique_ids(&[BARE]); + } + /// Issue #15: the eight literal tokens a human transcribed off a real screen were all of this /// shape. One placeholder spelling is allowed and every other one is a catalogue defect. #[test] diff --git a/crates/namir-core/src/gain.rs b/crates/namir-core/src/gain.rs index 32a8289..d13d2a2 100644 --- a/crates/namir-core/src/gain.rs +++ b/crates/namir-core/src/gain.rs @@ -1,24 +1,55 @@ -//! dB/linear conversion. `linear_to_db` of a non-positive value is mathematically undefined -//! (silence has no dB figure) — see the test below for the floor this picks instead of NaN/-inf, -//! which matters because meters (FR-IN-020 etc.) read this every UI frame and must never format -//! a NaN. +//! dB/linear conversion. `linear_to_db` of zero is mathematically undefined (silence has no dB +//! figure) — see the test below for the floor this picks instead of `-inf`, which matters because +//! meters (FR-IN-020 etc.) read this every UI frame and must never format a NaN or an infinity. +//! +//! # The never-format-a-NaN contract is by construction, not by arithmetic (issue #129) +//! +//! That contract used to hold only as a side effect of `f32::max`: `NaN.abs()` is NaN, so a NaN +//! amplitude reached `(20.0 * NaN.log10()).max(MIN_DB)`, and `max` returning *the other operand* +//! when one is NaN rescued it to `MIN_DB`. Nothing said so and no test covered it, so rewriting +//! that line as the equivalent-looking `if x < MIN_DB { MIN_DB } else { x }` would have leaked a +//! NaN to the UI while passing every test in the file. The non-finite cases are now handled +//! before any arithmetic runs, and pinned by their own tests. +//! +//! The floor is not the answer for every non-finite input, though. A NaN amplitude is a broken +//! signal with no level to report, so it reads as silence; an *infinite* one has blown up, and +//! reporting that as -600 dB would be a wrong reading the user cannot see — the same +//! silently-wrong reading issue #129 objects to in `namir-dsp`'s `Meter::peak`. So an infinity +//! clamps to `MAX_DB` at the top instead, keeping the reading monotonic in the magnitude. /// Converts a decibel value to a linear amplitude multiplier (0 dB -> 1.0). pub fn db_to_linear(db: f32) -> f32 { 10f32.powf(db / 20.0) } -/// A silent or negative `linear` has no dB figure; both are floored to `MIN_DB` rather than -/// producing `-inf`/`NaN`, since this feeds meters that must always format to something readable. +/// The reading for an amplitude with no dB figure of its own: a magnitude at or below +/// `f32::MIN_POSITIVE` (silence, either sign) and a `NaN`. Chosen over `-inf`/`NaN` because this +/// feeds meters that must always format to something readable. const MIN_DB: f32 = -600.0; -/// Converts a linear amplitude to decibels, floored at `MIN_DB` for silent or negative input -/// instead of returning `-inf`/`NaN` (see this module's doc comment). +/// The ceiling, reported for an infinite magnitude. The counterpart to `MIN_DB` at the top end: +/// see this module's doc comment for why an infinity is not folded into the floor. +const MAX_DB: f32 = 600.0; + +/// Converts a linear amplitude to decibels, taking its **magnitude** — `linear_to_db(-5.0)` is +/// `linear_to_db(5.0)`, about +14 dB, not the floor. A single negative sample is a signal at that +/// level, not silence (issue #128). +/// +/// The result is always finite and always within `MIN_DB..=MAX_DB`: a magnitude at or below +/// `f32::MIN_POSITIVE`, and a `NaN`, read as `MIN_DB`; an infinite magnitude reads as `MAX_DB`. +/// See this module's doc comment for why that is a construction rather than a coincidence. pub fn linear_to_db(linear: f32) -> f32 { - if linear.abs() <= f32::MIN_POSITIVE { + let magnitude = linear.abs(); + // Ordered so that no non-finite value reaches the arithmetic below, and so that neither + // branch relies on a NaN-propagation rule to be correct. `is_nan` is checked first because a + // NaN compares false against every bound, and would otherwise fall through to `log10`. + if magnitude.is_nan() || magnitude <= f32::MIN_POSITIVE { return MIN_DB; } - (20.0 * linear.abs().log10()).max(MIN_DB) + if magnitude.is_infinite() { + return MAX_DB; + } + (20.0 * magnitude.log10()).clamp(MIN_DB, MAX_DB) } #[cfg(test)] @@ -60,6 +91,70 @@ mod tests { assert!(linear_to_db(-5.0).is_finite()); // negative linear input: still must not NaN } + /// Issue #128: the doc comment used to claim a negative `linear` floors to `MIN_DB`. It does + /// not and should not — the magnitude is taken, so a lone negative sample reads at its own + /// level rather than as silence. Pinned by equality, not by `is_finite`, which passed either + /// way and is what let the documentation drift from the code unnoticed. + #[test] + fn a_negative_linear_reads_as_its_magnitude_not_as_silence() { + assert_eq!(linear_to_db(-5.0), linear_to_db(5.0)); + assert!( + linear_to_db(-5.0) > 0.0, + "-5.0 is a magnitude of 5, which is a positive dB figure: {}", + linear_to_db(-5.0) + ); + } + + /// Issue #129: the module's "must never format a NaN" contract, asserted directly rather than + /// left to `f32::max`'s NaN-returns-the-other-operand semantics. Every one of these inputs + /// reaches a meter's `format` in the UI thread if a stage ever emits one. + #[test] + fn no_amplitude_at_all_produces_a_non_finite_reading() { + for linear in [ + f32::NAN, + -f32::NAN, + f32::INFINITY, + f32::NEG_INFINITY, + 0.0, + -0.0, + f32::MIN_POSITIVE, + -f32::MIN_POSITIVE, + 1e-45, // sub-normal + f32::MAX, + f32::MIN, + -5.0, + ] { + let db = linear_to_db(linear); + assert!(db.is_finite(), "linear_to_db({linear}) = {db}"); + assert!( + (MIN_DB..=MAX_DB).contains(&db), + "linear_to_db({linear}) = {db}" + ); + } + } + + /// The half of issue #129 that held only by accident: a NaN amplitude is a broken signal, and + /// the reading a meter shows for it is the silence floor rather than a NaN the UI would have + /// to special-case. + #[test] + fn a_nan_amplitude_reads_as_the_silence_floor() { + assert_eq!(linear_to_db(f32::NAN), MIN_DB); + assert_eq!(linear_to_db(-f32::NAN), MIN_DB); + } + + /// The other half: an infinite amplitude is clamped at the *top*, not folded into the silence + /// floor. A meter reading -600 dB for a signal that has blown up would be a wrong reading a + /// user cannot see, which is the failure mode issue #129 objects to downstream in `Meter`. + /// The ceiling is a clamp, so a large finite magnitude reaches it too — what is pinned here is + /// that "louder than anything a meter will ever show" and "silent" stay on opposite ends. + #[test] + fn an_infinite_amplitude_reads_at_the_ceiling_not_at_the_floor() { + assert_eq!(linear_to_db(f32::INFINITY), MAX_DB); + assert_eq!(linear_to_db(f32::NEG_INFINITY), MAX_DB); + assert!(linear_to_db(f32::INFINITY) > linear_to_db(1000.0)); + assert!(linear_to_db(f32::INFINITY) > linear_to_db(0.0)); + } + #[test] fn silence_floor_is_very_low() { // Not a specific number by contract, just "clearly silent" for a meter to display. diff --git a/crates/namir-params/src/error_codes.rs b/crates/namir-params/src/error_codes.rs index b9229ea..d714066 100644 --- a/crates/namir-params/src/error_codes.rs +++ b/crates/namir-params/src/error_codes.rs @@ -60,6 +60,19 @@ pub const DUPLICATE_KEY: ErrorCode = ErrorCode::new( "Remove the duplicate descriptor from the registry; a key names one parameter.", ); +/// The same key appears on more than one line of the old manifest *text* — distinct from +/// [`DUPLICATE_KEY`], which is about the in-source descriptor set. The two lines used to overwrite +/// each other silently, last-line-wins (issue #118), and the line that loses can be the tombstone: +/// retiring a parameter by *adding* a `tombstoned` line rather than flipping the existing one is +/// the natural misreading of this file's header, and sorted output puts the two lines adjacent. +pub const DUPLICATE_LINE: ErrorCode = ErrorCode::new( + "params.manifest.duplicate_line", + Severity::Error, + "A key is declared on more than one line of the manifest file: {detail}.", + "Delete the extra line, keeping one line per key. A parameter is retired by editing its \ + existing line's \"live\" to \"tombstoned\", never by adding a second line for the same key.", +); + /// A key was live in the old manifest and is absent from the new descriptor set without ever /// being tombstoned — a silent drop, which FR-PARAM-020 forbids ("never reassigned" presumes the /// old identifier is still accounted for, not simply gone). @@ -137,6 +150,7 @@ const ALL: &[ErrorCode] = &[ KIND_CHANGED, DUPLICATE_ID, DUPLICATE_KEY, + DUPLICATE_LINE, DROPPED, MALFORMED_LINE, FORMAT_VERSION_UNSUPPORTED, diff --git a/crates/namir-params/src/manifest.rs b/crates/namir-params/src/manifest.rs index 428cc5c..0d8df24 100644 --- a/crates/namir-params/src/manifest.rs +++ b/crates/namir-params/src/manifest.rs @@ -67,7 +67,7 @@ use std::collections::BTreeMap; use crate::descriptor::{ParamDescriptor, ParamKind}; use crate::error_codes::{ - DROPPED, DUPLICATE_ID, DUPLICATE_KEY, FORMAT_VERSION_UNSUPPORTED, ID_CHANGED, + DROPPED, DUPLICATE_ID, DUPLICATE_KEY, DUPLICATE_LINE, FORMAT_VERSION_UNSUPPORTED, ID_CHANGED, INVALID_DESCRIPTOR, KIND_CHANGED, MALFORMED_LINE, ManifestViolation, TOMBSTONE_REUSED, }; @@ -227,6 +227,9 @@ pub fn merge_manifest(old: &str, new: &[ParamDescriptor]) -> String { out } +/// One parsed data line. Keyed by key in [`ParsedManifest::entries`], one entry per key: a key +/// written on two lines is a `DUPLICATE_LINE` violation, and the entry kept is the tombstoned one +/// (see [`parse_manifest`]). struct OldEntry { id: u32, kind: String, @@ -318,15 +321,33 @@ fn parse_manifest(text: &str) -> ParsedManifest { match parsed { Some((key, id, kind, tombstoned, shape)) => { - entries.insert( - key.to_string(), - OldEntry { - id, - kind: kind.to_string(), - tombstoned, - shape, - }, - ); + let entry = OldEntry { + id, + kind: kind.to_string(), + tombstoned, + shape, + }; + match entries.entry(key.to_string()) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(entry); + } + // Issue #118: this used to be a plain `insert`, so a second line for a key + // overwrote the first, last-line-wins, in silence. Two things happen instead. + std::collections::btree_map::Entry::Occupied(mut slot) => { + violations.push(ManifestViolation { + code: DUPLICATE_LINE, + detail: format!("key '{key}'"), + }); + // And, for the paths that read the entries anyway -- `merge_manifest` + // takes no notice of violations -- the *tombstone* is the line that wins, + // whichever order the two were written in. A tombstone is the record + // D-10.1 keeps forever; a live line for a key that also has one is the + // half a regeneration can safely re-derive from `REGISTRY`. + if entry.tombstoned && !slot.get().tombstoned { + slot.insert(entry); + } + } + } } None => violations.push(ManifestViolation { code: MALFORMED_LINE, @@ -350,6 +371,9 @@ fn parse_manifest(text: &str) -> ParsedManifest { /// - a key that stayed live across `old` and `new` but changed kind shape (continuous/stepped) in /// place, instead of being tombstoned and replaced under a new key; /// - duplicate ids or duplicate keys within `new` itself; +/// - the same key on more than one line of `old` — a `DUPLICATE_LINE`, distinct from the above: +/// the lines used to overwrite each other silently, and the loser can be the tombstone (issue +/// #118); /// - a key that was `live` in `old` and is simply absent from `new` without a tombstone; /// - a descriptor in `new` that contradicts itself — a default outside its own range, a stepped /// default index past the end of its values ([`ParamDescriptor::validate`], issue #119); @@ -652,6 +676,79 @@ mod tests { assert!(!violations.iter().any(|v| v.code.id == DUPLICATE_KEY.id)); } + /// `old` with a second line for `key`, in the given state, inserted *before* the one already + /// there — the shape issue #118 describes: a retirement done by adding a `tombstoned` line + /// rather than flipping the existing one, which sorted output leaves adjacent to it. + fn with_a_second_line_for(old: &str, key: &str, state: &str) -> String { + let mut out = String::new(); + for line in old.lines() { + if line.starts_with(&format!("{key} ")) { + let duplicate = line.replace(" live ", &format!(" {state} ")); + out.push_str(&duplicate); + out.push('\n'); + } + out.push_str(line); + out.push('\n'); + } + out + } + + /// Issue #118: two lines sharing a key used to overwrite each other in the parser's map, + /// last-line-wins, with nothing raised. The tombstone — the one record D-10.1 says is + /// "retained forever" — is the half that loses, and `check_manifest` then sees a plain live + /// key and reports nothing at all. + #[test] + fn a_key_declared_on_two_lines_of_the_old_manifest_is_rejected() { + let old = render_manifest(&[TRIM]); + let duplicated = with_a_second_line_for(&old, "trim.gain_db", "tombstoned"); + let result = check_manifest(&duplicated, &[TRIM]); + let violations = result.expect_err("a key on two lines must be rejected"); + assert!( + violations.iter().any(|v| v.code.id == DUPLICATE_LINE.id), + "{violations:?}" + ); + } + + /// The same defect with both lines in the same state — no tombstone involved, just a key + /// written twice. Still a file whose own re-parse discards one of its lines. + #[test] + fn a_key_declared_twice_as_live_is_also_rejected() { + let old = render_manifest(&[TRIM, GATE_THRESHOLD]); + let duplicated = with_a_second_line_for(&old, "gate.threshold", "live"); + let result = check_manifest(&duplicated, &[TRIM, GATE_THRESHOLD]); + let violations = result.expect_err("a key on two lines must be rejected"); + assert!( + violations.iter().any(|v| v.code.id == DUPLICATE_LINE.id), + "{violations:?}" + ); + } + + /// The consequence the violation exists to prevent, pinned separately: whichever line wins the + /// parse, the tombstone survives a regeneration. Last-line-wins used to drop it here, which is + /// how a retired identifier gets quietly handed back to a new parameter. + #[test] + fn a_duplicated_key_keeps_its_tombstone_through_a_regeneration() { + let old = render_manifest(&[TRIM, GATE_THRESHOLD]); + let duplicated = with_a_second_line_for(&old, "gate.threshold", "tombstoned"); + let regenerated = merge_manifest(&duplicated, &[TRIM]); + assert!( + regenerated + .lines() + .any(|line| line.starts_with("gate.threshold ") && line.contains(" tombstoned ")), + "the tombstone must survive the merge:\n{regenerated}" + ); + // And exactly once -- a file whose own re-parse disagrees with itself is what the + // violation is for. + assert_eq!( + regenerated + .lines() + .filter(|line| line.starts_with("gate.threshold ")) + .count(), + 1, + "{regenerated}" + ); + } + #[test] fn dropping_a_live_key_without_tombstoning_is_rejected() { let old = render_manifest(&[TRIM, GATE_THRESHOLD]); From ef531f0990273822508bcac9e6b9aae7d6d2dd0b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:46:50 +0000 Subject: [PATCH 20/44] namir-state: clear a reference, resolve an embed, refuse an oversized write #112: write_onto merged but could never remove, so a user who unloaded a model, saved and reloaded got it back -- the saved section still carried the old display_name and hash. Section 7's "absent means nothing of that kind is loaded" is the only way this format expresses an empty slot, so the two slots are removed when their field is None. Per-key, so D-11.2's promise to preserve an unknown key inside a section we own still holds. Checked the CLAP path: the clearing setter is reachable only from adopt_state, so a failed recall cannot erase a still-loaded reference. #113: resolve() ran the spec's three external steps and stopped, reporting Missing for a reference whose embedded copy was right there. Step 4 now runs, strictly last, borrowing rather than cloning -- an embed can be tens of MB. Candidate deliberately gains no Embedded variant: a Candidate is a question for a FileResolver, and an embedded copy needs no resolver, no I/O and no roots. namir-worker's post-loop fallback is already the right shape and its exhaustive match stays valid. #115: the ceiling was checked when reading and not when writing, so a 192 MiB embed produced 268 MB that this crate's own reader then refused -- a document we can save and cannot open. try_write and try_to_pretty_bytes check it, over a seam that makes the rule testable in microseconds rather than a gigabyte. The read-side check stays: section 7.2 requires a conforming reader to check the encoded length, and the redundancy holds only while the two constants are equal. MAX_EMBEDDED_BYTES's doc said "decoded"; the code has always checked the encoded length, matching the spec. The doc was wrong and is corrected. Still owed: namir-clap's state_ext and xtask's preset still call the unchecked writer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-state/src/document.rs | 109 ++++++++++++++++++ crates/namir-state/src/lib.rs | 15 ++- crates/namir-state/src/reference.rs | 44 ++++--- crates/namir-state/src/resolve.rs | 172 +++++++++++++++++++++++----- crates/namir-state/src/state.rs | 138 ++++++++++++++++++++++ crates/namir-state/tests/corpus.rs | 73 ++++++++++++ 6 files changed, 506 insertions(+), 45 deletions(-) diff --git a/crates/namir-state/src/document.rs b/crates/namir-state/src/document.rs index 98a379d..a093b87 100644 --- a/crates/namir-state/src/document.rs +++ b/crates/namir-state/src/document.rs @@ -111,6 +111,14 @@ impl Document { /// no-trailing-newline diff under most editors/VCS configurations — FR-STATE-040's /// diffability, extended past "the JSON is sorted" to "the file itself behaves like a normal /// text file"). + /// + /// **Unchecked against [`MAX_DOCUMENT_BYTES`]** — a document carrying a large enough + /// FR-STATE-080 embedded copy serialises to bytes [`Self::parse`] would then refuse with + /// `DOCUMENT_TOO_LARGE`. Use [`Self::try_to_pretty_bytes`] (or + /// [`State::try_write`](crate::State::try_write)) anywhere the bytes are destined for a file + /// or a host's state blob, i.e. anywhere they will be read back; this infallible form is for + /// a caller that has already established the document is small (a test, a freshly-defaulted + /// state) and does not want a `Result` it cannot act on. pub fn to_pretty_bytes(&self) -> Vec { let mut bytes = serde_json::to_vec_pretty(&Value::Object(self.root.clone())).expect( "a Document's root is always a valid serde_json::Map and cannot fail to serialise", @@ -119,6 +127,37 @@ impl Document { bytes } + /// As [`Self::to_pretty_bytes`], but refuses to hand back bytes [`Self::parse`] would reject: + /// over [`MAX_DOCUMENT_BYTES`] is `error_codes::DOCUMENT_TOO_LARGE`, the same code the read + /// side uses for the same condition. + /// + /// This is the write half of NFR-SEC-020's ceiling, which the read half alone cannot keep: + /// nothing else in this crate bounds how many bytes an [`EmbeddedRef`](crate::EmbeddedRef) + /// contributes, so without this a save could produce a document that only fails on the *next* + /// load — the point at which the user's own settings are already the thing being lost, and + /// the least useful moment to discover it. + pub fn try_to_pretty_bytes(&self) -> Result, StateError> { + self.to_pretty_bytes_within(MAX_DOCUMENT_BYTES) + } + + /// [`Self::try_to_pretty_bytes`] with the ceiling as a parameter — the seam its tests drive, + /// so the enforcement is checked at a byte count a test can build in microseconds rather than + /// only at the real 256 MiB figure. The public method is this one at [`MAX_DOCUMENT_BYTES`]. + pub(crate) fn to_pretty_bytes_within(&self, limit: usize) -> Result, StateError> { + let bytes = self.to_pretty_bytes(); + if bytes.len() > limit { + return Err(StateError::new( + error_codes::DOCUMENT_TOO_LARGE, + format!( + "{} bytes written, limit {} MB", + bytes.len(), + limit / (1024 * 1024) + ), + )); + } + Ok(bytes) + } + /// A named top-level section as an object, if present and shaped as one. `None` covers both /// "absent" and "present but not an object" — callers that need to tell those apart (none do, /// today: an absent section and a malformed one both mean "nothing usable is here") can add a @@ -150,6 +189,22 @@ impl Document { merged.extend(additions); self.set_section(key, merged); } + + /// Deletes one key from a named section, leaving every other key in that section — and the + /// section itself, even if this empties it — alone. A no-op if either the section or the key + /// is absent, or if the section isn't an object. + /// + /// [`Self::merge_section`] can only ever *add* a key, which makes it unable to express "this + /// slot is now empty" for a section whose keys are themselves the state (`references`, whose + /// two keys are optional by §7 of `docs/04-state-and-preset-format.md`: "absent means nothing + /// of that kind is loaded"). This is the counterpart that can. It is deliberately per-key + /// rather than per-section, so D-11.2's promise still holds around it: an unrecognised key + /// sitting alongside `nam`/`ir` inside `references` survives a save that clears one of them. + pub(crate) fn remove_from_section(&mut self, key: &str, field: &str) { + if let Some(Value::Object(section)) = self.root.get_mut(key) { + section.remove(field); + } + } } fn json_type_name(value: &Value) -> &'static str { @@ -307,6 +362,60 @@ mod tests { assert_eq!(merged.get("overwritten"), Some(&Value::from("new"))); } + #[test] + fn remove_from_section_deletes_only_the_named_key() { + let mut doc = Document::empty(); + let mut references = Map::new(); + references.insert("nam".to_string(), Value::from("the model")); + references.insert("ir".to_string(), Value::from("the cab")); + doc.set_section("references", references); + + doc.remove_from_section("references", "nam"); + + let section = doc.section("references").unwrap(); + assert!(!section.contains_key("nam")); + assert_eq!(section.get("ir"), Some(&Value::from("the cab"))); + } + + #[test] + fn remove_from_section_is_a_no_op_for_an_absent_section_or_key() { + let mut doc = Document::empty(); + doc.remove_from_section("references", "nam"); // no such section + assert!(doc.section("references").is_none()); + + doc.set_section("references", Map::new()); + doc.remove_from_section("references", "nam"); // no such key + assert_eq!(doc.section("references"), Some(&Map::new())); + } + + /// Issue #115's write half: the byte ceiling NFR-SEC-020 states is enforced on the way *out* + /// too, not only on the way in — otherwise a save can produce a document whose only failure + /// mode is the next load. Driven through the limit-taking seam so the property is checked at + /// a size a test can build instantly; `try_to_pretty_bytes` is this same code path at + /// `MAX_DOCUMENT_BYTES`. + #[test] + fn to_pretty_bytes_within_refuses_a_document_over_the_limit() { + let mut doc = Document::empty(); + let mut params = Map::new(); + params.insert("trim.gain_db".to_string(), Value::from(1.0)); + doc.set_section("parameters", params); + + let unlimited = doc.to_pretty_bytes(); + assert!(doc.to_pretty_bytes_within(unlimited.len()).is_ok()); + + let err = doc.to_pretty_bytes_within(unlimited.len() - 1).unwrap_err(); + assert_eq!(err.code.id, error_codes::DOCUMENT_TOO_LARGE.id); + } + + /// The ceiling the public method actually applies is the one `parse` enforces, so a document + /// that survives `try_to_pretty_bytes` is by construction one `parse` accepts. + #[test] + fn try_to_pretty_bytes_returns_the_same_bytes_for_a_document_within_the_ceiling() { + let doc = Document::empty(); + assert_eq!(doc.try_to_pretty_bytes().unwrap(), doc.to_pretty_bytes()); + assert!(Document::parse(&doc.try_to_pretty_bytes().unwrap()).is_ok()); + } + // ----------------------------------------------------------------------------------- // NFR-PORT-050: "byte order, path separators, line endings and text encoding shall be // handled such that preset and state files written on one platform load identically on diff --git a/crates/namir-state/src/lib.rs b/crates/namir-state/src/lib.rs index c5b3558..186f16d 100644 --- a/crates/namir-state/src/lib.rs +++ b/crates/namir-state/src/lib.rs @@ -22,11 +22,16 @@ //! - FR-STATE-040 — the JSON format itself: pretty-printed, sorted, and, via [`reference::RelPath`], //! free of platform-specific path syntax in what it stores. //! - FR-STATE-070 — [`resolve::candidates`]/[`resolve::FileResolver`]/[`resolve::resolve`]: the -//! three-step resolution order as data plus the port a resolving crate implements. This crate -//! never resolves a reference against a real filesystem itself — see [`resolve`]'s module doc -//! comment for why the algorithm and the filesystem access it needs are deliberately kept apart. -//! - FR-STATE-080 — [`reference::FileRef::embedded`], read and written (M5's Should-scope -//! decision). +//! three-step resolution order as data plus the port a resolving crate implements, and — since +//! issue #113 — §7.4 of `docs/04-state-and-preset-format.md`'s fourth step, the embedded copy, +//! as [`resolve`]'s terminal fallback. This crate never resolves a reference against a real +//! filesystem itself — see [`resolve`]'s module doc comment for why the algorithm and the +//! filesystem access it needs are deliberately kept apart, and why the fourth step is not a +//! [`resolve::Candidate`]. +//! - FR-STATE-080 — [`reference::FileRef::embedded`], read, written and resolved through (M5's +//! Should-scope decision). Writing it is bounded on the way out by [`State::try_write`] / +//! [`Document::try_to_pretty_bytes`], so an embed too large for a document is refused at the +//! save rather than at the next load (issue #115). //! - D-11.2 — tolerant, versioned deserialisation; see [`document`], [`params`] and [`migrate`]. //! //! Out of scope, deliberately, for this crate: diff --git a/crates/namir-state/src/reference.rs b/crates/namir-state/src/reference.rs index 0b3f95c..d689510 100644 --- a/crates/namir-state/src/reference.rs +++ b/crates/namir-state/src/reference.rs @@ -134,23 +134,39 @@ impl std::fmt::Display for RelPath { } } -/// NFR-SEC-020's bound on an [`EmbeddedRef`]'s decoded byte size. In practice this is already -/// implied by [`crate::MAX_DOCUMENT_BYTES`] — the base64 text an embedded resource's bytes are -/// stored as is itself part of the same document, so it can never exceed roughly -/// `MAX_DOCUMENT_BYTES` to begin with, and base64 decoding allocates in proportion to the input -/// actually present (not a separately-declared length field the way a WAV header's `data` chunk -/// length is — there is no forgeable "claims more than it delivers" vector here the way -/// `namir_ir::wav`'s module doc warns about for WAV). This constant is kept anyway, set to -/// exactly [`crate::MAX_DOCUMENT_BYTES`], as the single place that bound is *stated* for this -/// specific case rather than left to be re-derived from a different module's constant — the -/// NFR's own wording asks for a *documented* bound, not merely an accidentally-true one. +/// NFR-SEC-020's bound on an [`EmbeddedRef`]'s **encoded** byte size — §7.2 of +/// `docs/04-state-and-preset-format.md`'s rule, verbatim: "the encoded text is subject to the +/// same 256 MiB ceiling as the whole document, checked against the encoded string's own length, +/// before any base64 decoding happens". `EmbeddedRef::from_value` enforces exactly that. +/// (This doc comment said "decoded" until issue #115; the code has always checked the encoded +/// length, which is the one §7.2 specifies and the one that bounds the allocation.) +/// +/// **Reachability, stated rather than implied.** Set to exactly [`crate::MAX_DOCUMENT_BYTES`], +/// this check cannot currently fire on anything [`crate::Document::parse`] produced: a `data` +/// string longer than the ceiling cannot fit inside a document that is itself under the ceiling, +/// and `from_value` is reachable no other way. It is kept for two reasons that are not +/// "belt-and-braces for its own sake". §7.2 states the rule as a requirement on *a conforming +/// reader*, whose own document ceiling a third party is free to set higher than this build's; +/// and the redundancy here is an artefact of the two constants being equal today, not a +/// structural guarantee — raise `MAX_DOCUMENT_BYTES` and this becomes the only thing standing +/// between a hostile `data` string and a decode allocation proportional to it. Base64 decoding +/// does at least allocate in proportion to the input actually present, not to a +/// separately-declared length field the way a WAV header's `data` chunk length does, so there is +/// no forgeable "claims more than it delivers" vector here of the kind `namir_ir::wav`'s module +/// doc warns about. +/// +/// **This bounds the read side only.** The write side is [`crate::Document::try_to_pretty_bytes`] +/// / [`crate::State::try_write`], which check the produced document against +/// [`crate::MAX_DOCUMENT_BYTES`]: base64 costs 4/3, and `namir_core::MAX_FILE_BYTES` admits a +/// source file whose encoded form alone overflows a document, so embedding one used to yield +/// bytes only the *next* load would reject (issue #115). pub const MAX_EMBEDDED_BYTES: usize = crate::document::MAX_DOCUMENT_BYTES; /// FR-STATE-080's optional embedded copy of a model or IR's raw bytes, carried directly in the /// state document. A `format_version` bump was the alternative to reserving this from the start /// — see `docs/02-architecture.md`'s M5 note on D-11.1 — so the shape exists from this crate's /// first version rather than being retrofitted. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EmbeddedRef { /// The embedded resource's media type (`"application/vnd.namir.nam+json"` for a model, /// `"audio/wav"` for an IR) — informational, not consulted by this crate; a caller decides @@ -200,9 +216,9 @@ impl EmbeddedRef { "embedded.data must be a string", ) })?; - // NFR-SEC-020: reject on the *encoded* length before decoding -- see MAX_EMBEDDED_BYTES's - // doc comment for why this is already implied by the whole-document ceiling, and kept - // explicit anyway. + // NFR-SEC-020 / docs/04 section 7.2: reject on the *encoded* length before decoding -- see + // MAX_EMBEDDED_BYTES's doc comment for why this cannot fire through `Document::parse` + // while the two ceilings are equal, and is kept anyway. if data_text.len() > MAX_EMBEDDED_BYTES { return Err(StateError::new( error_codes::DOCUMENT_TOO_LARGE, diff --git a/crates/namir-state/src/resolve.rs b/crates/namir-state/src/resolve.rs index 4328237..138547e 100644 --- a/crates/namir-state/src/resolve.rs +++ b/crates/namir-state/src/resolve.rs @@ -1,6 +1,21 @@ -//! FR-STATE-070's three-step resolution order — "try the library-relative path, then the -//! absolute path, then a content-hash search of the library" — as data ([`candidates`]) and as a -//! driven algorithm ([`resolve`]) against an injected port ([`FileResolver`]). +//! §7.4 of `docs/04-state-and-preset-format.md`'s resolution order — FR-STATE-070's three +//! external steps ("try the library-relative path, then the absolute path, then a content-hash +//! search of the library") followed by FR-STATE-080's embedded copy as the terminal fallback — as +//! data ([`candidates`]) and as a driven algorithm ([`resolve`]) against an injected port +//! ([`FileResolver`]). +//! +//! # Three steps a resolver answers, and a fourth nobody has to +//! +//! [`Candidate`] enumerates the three steps that are *questions for a [`FileResolver`]*: each one +//! is a place the bytes might be, which only something with a filesystem and a library index can +//! answer. §7.4's fourth step is not one of those — an `embedded` copy needs no resolver, no I/O +//! and no root list, because the bytes are already in the reference. So it is not a `Candidate`; +//! it is what both drivers do once [`candidates`] is exhausted: [`resolve`] returns +//! [`Resolution::Embedded`], and `namir-worker`'s `recall::locate` hashes the embedded bytes and +//! uses them. Issue #113 recorded the state before this: [`candidates`] terminated at +//! `ContentHash` and so did [`resolve`], which meant this crate's public, documented-as-complete +//! algorithm reported `Missing` for a reference whose embedded copy was sitting right there — +//! three of the spec's four steps, with only `namir-worker` implementing the fourth. //! //! # Why the order lives here and the filesystem access doesn't //! @@ -20,8 +35,11 @@ //! those bytes is going to do so anyway once loading the resource (`ResourceCache::get_or_load_*` //! in `namir-worker`), so doing it a second time here would be wasted work. [`resolve`] is the //! simpler, complete-in-itself algorithm this crate can prove correct on its own — existence-only, -//! no content verification — useful to a caller that only needs "does this reference point at -//! something", and the vehicle for this crate's own tests of FR-STATE-070's four outcomes. +//! no content verification, and that applies to the embedded step too: [`Resolution::Embedded`] +//! says an embedded copy is *there*, not that its bytes hash to `expected`, exactly as +//! [`Resolution::Resolved`] says a path exists without vouching for what is at it — useful to a +//! caller that only needs "does this reference point at something", and the vehicle for this +//! crate's own tests of FR-STATE-070's four outcomes. use std::path::PathBuf; @@ -29,10 +47,13 @@ use namir_core::ContentHash; use crate::error::StateWarning; use crate::error_codes; -use crate::reference::{FileRef, RelPath}; +use crate::reference::{EmbeddedRef, FileRef, RelPath}; -/// One of a [`FileRef`]'s resolution candidates, in FR-STATE-070's order. Borrows from the -/// `FileRef` it came from rather than cloning, since a resolver only ever needs to read from it. +/// One of a [`FileRef`]'s three *externally resolved* candidates, in FR-STATE-070's order — the +/// steps a [`FileResolver`] is asked about. §7.4's fourth step, `embedded`, is deliberately not a +/// variant here: it asks a resolver nothing (see this module's doc comment), and every driver +/// applies it after this iterator runs out. Borrows from the `FileRef` it came from rather than +/// cloning, since a resolver only ever needs to read from it. #[derive(Debug, Clone, Copy)] pub enum Candidate<'a> { /// Step 1: the library-relative path, tried against every configured library root. @@ -44,10 +65,16 @@ pub enum Candidate<'a> { ContentHash(ContentHash), } -/// Yields `reference`'s candidates in FR-STATE-070's order: library-relative (if present), -/// absolute (if present), then content hash (always). This is the *only* place that order is -/// written down — [`resolve`] and any other driver (`namir-worker`'s `recall::locate`) both walk -/// this iterator rather than each re-encoding the sequence. +/// Yields `reference`'s externally-resolved candidates in FR-STATE-070's order: +/// library-relative (if present), absolute (if present), then content hash (always). This is the +/// *only* place that order is written down — [`resolve`] and any other driver (`namir-worker`'s +/// `recall::locate`) both walk this iterator rather than each re-encoding the sequence. +/// +/// **Does not yield §7.4's fourth step.** A caller that stops here has implemented three quarters +/// of the documented order: after this iterator is exhausted, `reference.embedded` is the final +/// fallback, and a driver that ignores it will report a shared preset's self-contained copy as +/// missing. [`resolve`] does this for you; a driver that reads bytes itself should do what +/// `namir-worker`'s `recall::locate` does and try the embedded copy last. pub fn candidates(reference: &FileRef) -> impl Iterator> { reference .library_relative @@ -126,18 +153,32 @@ impl MissingFile { /// The outcome of [`resolve`]. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Resolution { - /// A candidate existed. Not yet content-verified — see [`ResolvedFile`]'s doc comment. +pub enum Resolution<'a> { + /// One of [`candidates`]' three steps existed. Not yet content-verified — see + /// [`ResolvedFile`]'s doc comment. Resolved(ResolvedFile), - /// None of the three candidates existed. + /// §7.4's fourth step: no external candidate existed, but the reference carries its own + /// embedded copy of the resource — the case that whole field exists for (a preset shared + /// with someone whose library is configured differently, or who has no library at all). + /// Borrowed from the reference rather than cloned: an embedded model can be tens of + /// megabytes, and a caller that only wanted to know *whether* the reference resolves should + /// not pay for a copy of it. + /// + /// Existence-only like [`Self::Resolved`]: the bytes are here, but this says nothing about + /// whether they hash to the reference's own `hash`. A caller that will actually use them + /// verifies that itself, exactly as it must for a path candidate (`namir-worker`'s + /// `recall::locate` does, and treats a mismatched embed as a miss). + Embedded(&'a EmbeddedRef), + /// Neither the three external candidates nor an embedded copy produced anything. Missing(MissingFile), } -/// Drives [`candidates`] through `resolver` in FR-STATE-070's order and returns the first hit — -/// or [`Resolution::Missing`] if none of the three candidates resolved to anything. Existence-only -/// (see this module's doc comment); a caller needing content verification composes this crate's -/// [`candidates`] with its own byte-reading instead. -pub fn resolve(reference: &FileRef, resolver: &dyn FileResolver) -> Resolution { +/// Drives [`candidates`] through `resolver` in FR-STATE-070's order and returns the first hit; +/// failing all three, falls back to §7.4's fourth step, the reference's own `embedded` copy, and +/// only then reports [`Resolution::Missing`]. Existence-only (see this module's doc comment); a +/// caller needing content verification composes this crate's [`candidates`] with its own +/// byte-reading instead. +pub fn resolve<'a>(reference: &'a FileRef, resolver: &dyn FileResolver) -> Resolution<'a> { for candidate in candidates(reference) { let (found, via) = match candidate { Candidate::LibraryRelative(rel) => ( @@ -157,6 +198,13 @@ pub fn resolve(reference: &FileRef, resolver: &dyn FileResolver) -> Resolution { }); } } + // §7.4 step 4, and deliberately last: an embedded copy is what a reference falls back *to* + // when nothing external can be found, never what it prefers. A resolvable library or absolute + // path is what FR-STATE-070 is actually about, and preferring the embed would mean a preset + // silently ignoring the very file the user has on disk. + if let Some(embedded) = &reference.embedded { + return Resolution::Embedded(embedded); + } Resolution::Missing(MissingFile { display_name: reference.display_name.clone(), hash: reference.hash, @@ -247,7 +295,7 @@ mod tests { assert_eq!(found.path, PathBuf::from("/library/marshall/plexi.nam")); assert_eq!(found.expected, reference.hash); } - Resolution::Missing(m) => panic!("expected a resolution, got Missing: {m:?}"), + other => panic!("expected a resolution, got {other:?}"), } } @@ -264,7 +312,7 @@ mod tests { match resolve(&reference, &resolver) { Resolution::Resolved(found) => assert_eq!(found.via, ResolvedVia::Absolute), - Resolution::Missing(m) => panic!("expected a resolution, got Missing: {m:?}"), + other => panic!("expected a resolution, got {other:?}"), } } @@ -282,7 +330,7 @@ mod tests { match resolve(&reference, &resolver) { Resolution::Resolved(found) => assert_eq!(found.via, ResolvedVia::Absolute), - Resolution::Missing(m) => panic!("expected a fall-through resolution, got: {m:?}"), + other => panic!("expected a fall-through resolution, got {other:?}"), } } @@ -301,23 +349,95 @@ mod tests { assert_eq!(found.via, ResolvedVia::ContentHash); assert_eq!(found.path, PathBuf::from("/library/found-by-hash.nam")); } - Resolution::Missing(m) => panic!("expected a resolution, got Missing: {m:?}"), + other => panic!("expected a resolution, got {other:?}"), + } + } + + fn an_embedded_copy() -> EmbeddedRef { + EmbeddedRef { + media_type: "application/vnd.namir.nam+json".to_string(), + data: br#"{"fake":"the whole resource, carried in the document"}"#.to_vec(), + } + } + + /// Issue #113 / §7.4 step 4: a reference whose only usable payload is its embedded copy + /// resolves through it rather than being reported missing. Before this, `resolve` walked + /// `candidates` and stopped — three of the format spec's four steps — so the one case + /// `embedded` exists for (a preset opened by someone with a different library, or none) was + /// the one case this crate's own algorithm could not serve. + #[test] + fn resolves_via_the_embedded_copy_when_no_external_candidate_does() { + let mut reference = reference_with(Some("marshall/plexi.nam"), Some("/abs/plexi.nam")); + reference.embedded = Some(an_embedded_copy()); + let resolver = FakeResolver::default(); // nothing registered: all three steps miss + + match resolve(&reference, &resolver) { + Resolution::Embedded(embedded) => assert_eq!(embedded, &an_embedded_copy()), + other => panic!("expected the embedded fallback, got {other:?}"), + } + } + + /// And deliberately *last*: an external candidate that resolves wins over an embedded copy, + /// so a preset never ignores the file the user actually has on disk. + #[test] + fn an_embedded_copy_is_tried_only_after_every_external_candidate() { + let mut reference = reference_with(Some("marshall/plexi.nam"), None); + reference.embedded = Some(an_embedded_copy()); + let mut resolver = FakeResolver::default(); + resolver.by_relative.insert( + "marshall/plexi.nam".to_string(), + PathBuf::from("/library/marshall/plexi.nam"), + ); + + match resolve(&reference, &resolver) { + Resolution::Resolved(found) => assert_eq!(found.via, ResolvedVia::LibraryRelative), + other => panic!("the library-relative hit must win over the embed, got {other:?}"), + } + } + + /// The same, one step further down the order: the content-hash search is still an external + /// candidate and still precedes the embed. + #[test] + fn a_content_hash_hit_still_precedes_the_embedded_copy() { + let mut reference = reference_with(None, None); + reference.embedded = Some(an_embedded_copy()); + let mut resolver = FakeResolver::default(); + resolver + .by_hash + .insert(reference.hash, PathBuf::from("/library/found-by-hash.nam")); + + match resolve(&reference, &resolver) { + Resolution::Resolved(found) => assert_eq!(found.via, ResolvedVia::ContentHash), + other => panic!("the hash hit must win over the embed, got {other:?}"), } } - /// The fourth outcome: none of the three candidates resolve. + /// `candidates` covers the three steps a `FileResolver` can answer, and says so — `embedded` + /// is not among them, by design (see the module doc comment), which is why a driver that + /// walks this iterator has to apply the fourth step itself. + #[test] + fn candidates_yields_only_the_three_externally_resolved_steps() { + let mut reference = reference_with(Some("marshall/plexi.nam"), Some("/abs/plexi.nam")); + reference.embedded = Some(an_embedded_copy()); + assert_eq!(candidates(&reference).count(), 3); + } + + /// The fourth outcome: nothing resolves — no external candidate, and no embedded copy + /// either (`reference_with` builds one without, which is what makes this `Missing` rather + /// than the embedded fallback). // trace: FR-STATE-070 #[test] fn all_three_candidates_failing_yields_missing_with_name_and_hash() { let reference = reference_with(Some("marshall/plexi.nam"), Some("/abs/plexi.nam")); + assert!(reference.embedded.is_none()); let resolver = FakeResolver::default(); // nothing registered at all match resolve(&reference, &resolver) { - Resolution::Resolved(found) => panic!("expected Missing, got a resolution: {found:?}"), Resolution::Missing(missing) => { assert_eq!(missing.display_name, "plexi.nam"); assert_eq!(missing.hash, reference.hash); } + other => panic!("expected Missing, got {other:?}"), } } diff --git a/crates/namir-state/src/state.rs b/crates/namir-state/src/state.rs index 940a070..77c4ece 100644 --- a/crates/namir-state/src/state.rs +++ b/crates/namir-state/src/state.rs @@ -171,10 +171,25 @@ impl State { /// [`Self::into_document`] followed by [`Document::to_pretty_bytes`], for a caller with no /// reason to keep the intermediate `Document` around and nothing to preserve from an /// existing one — most usefully, creating a brand-new preset from scratch. + /// **Unchecked against [`crate::MAX_DOCUMENT_BYTES`]** — see + /// [`Document::to_pretty_bytes`]'s own doc comment, and [`Self::try_write`] for the form that + /// refuses to produce bytes [`Self::read`] would reject. pub fn write(&self) -> Vec { self.clone().into_document().to_pretty_bytes() } + /// As [`Self::write`], but fails with `DOCUMENT_TOO_LARGE` rather than returning a document + /// over NFR-SEC-020's ceiling — the write-side half of that bound (see + /// [`Document::try_to_pretty_bytes`]). The only thing in this format that can realistically + /// reach the ceiling is FR-STATE-080's `embedded` copy: base64 costs 4/3, and + /// `namir_core::MAX_FILE_BYTES` admits a source file large enough that its encoded form alone + /// exceeds what a document may hold. A caller writing to a file or to a host's state blob + /// should use this one; the infallible [`Self::write`] stays for callers with nothing to + /// embed and no `Result` to act on. + pub fn try_write(&self) -> Result, StateError> { + self.clone().into_document().try_to_pretty_bytes() + } + /// Builds a fresh [`Document`] from this state — every section this crate owns, sorted, /// nothing else. Used by [`Self::write`] directly. /// @@ -197,6 +212,17 @@ impl State { /// [`FileRef`]'s doc comment on why an unrecognised field *inside* a single reference object /// is not yet preserved. /// + /// **The one thing this method deletes:** a `references` slot this state does not carry. + /// `merge_section` can only add keys, so `State { nam: None, .. }.write_onto(a document that + /// had one)` used to write the old `references.nam` straight back — the user removes a model, + /// saves, reloads, and it is back (issue #112; the CLAP save path is literally + /// `save() -> write_onto(&last_document())`). §7 of `docs/04-state-and-preset-format.md` says + /// "absent means nothing of that kind is loaded", and merging alone has no way to say it. So + /// `nam`/`ir` are removed explicitly when this state's own field is `None`. This is not a + /// D-11.2 exception: both keys are ones this build fully owns and rewrites on every save, and + /// the removal is per-key (`Document::remove_from_section`), so an unrecognised key + /// alongside them inside `references` still survives untouched. + /// /// **D-10.4:** if `onto` carries a legacy `global` section (D-11.2 tolerance: this build can /// still have read one, via [`Self::from_document`]), it is left exactly as it is here — the /// same treatment any other section this build no longer owns gets. It becomes inert rather @@ -210,6 +236,12 @@ impl State { let mut document = onto.clone(); document.merge_section("parameters", self.params.to_document_section()); document.merge_section("references", references_section(&self.nam, &self.ir)); + if self.nam.is_none() { + document.remove_from_section("references", "nam"); + } + if self.ir.is_none() { + document.remove_from_section("references", "ir"); + } document } } @@ -353,6 +385,112 @@ mod tests { assert_eq!(restored.ir, None); } + /// Issue #112: the user removes the loaded model and saves. Before the fix, `write_onto` + /// could only ever *add* to `references`, so the old `nam` object was written straight back + /// and the removed model returned on the next load — the CLAP save path is exactly + /// `save() -> write_onto(&last_document())`, so this was a silent resurrection of state the + /// user had deliberately cleared, not merely a stale key. + #[test] + fn write_onto_clears_a_reference_the_state_no_longer_carries() { + let mut state = State::defaults(); + state.nam = Some(a_reference("plexi.nam")); + state.ir = Some(a_reference("1960a.wav")); + let original = state.clone().into_document(); + + state.nam = None; // the user unloads the model + let saved = state.write_onto(&original); + + let references = saved.section("references").unwrap(); + assert!( + !references.contains_key("nam"), + "the cleared model must not be written back: {:?}", + references.get("nam") + ); + assert!( + references.contains_key("ir"), + "the IR the user did not touch must stay" + ); + + // The whole point: the next load agrees. + let (restored, warnings) = State::from_document(saved); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(restored.nam, None); + assert_eq!(restored.ir, state.ir); + } + + /// The same clearing, all the way through bytes rather than through `Document`s only — the + /// shape the CLAP host actually saves and reloads. + #[test] + fn a_cleared_reference_stays_cleared_across_a_save_and_reload() { + let mut state = State::defaults(); + state.nam = Some(a_reference("plexi.nam")); + let original = Document::parse(&state.write()).unwrap(); + + state.nam = None; + let bytes = state.write_onto(&original).to_pretty_bytes(); + + let (restored, warnings) = State::read(&bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(restored.nam, None); + } + + /// Clearing a slot must not become a licence to rewrite the `references` section wholesale: + /// D-11.2's promise about an unrecognised key inside a section this build owns still holds + /// for `references`, exactly as it does for `parameters`. + #[test] + fn write_onto_preserves_an_unknown_key_inside_references_while_clearing_a_slot() { + let mut original = Document::empty(); + let mut references = Map::new(); + references.insert("nam".to_string(), a_reference("plexi.nam").to_value()); + references.insert( + "cab_sim".to_string(), // a slot only a newer build knows about + Value::from("something this build has never heard of"), + ); + original.set_section("references", references); + + let state = State::defaults(); // no nam, no ir + let saved = state.write_onto(&original); + + let saved_references = saved.section("references").unwrap(); + assert!(!saved_references.contains_key("nam")); + assert_eq!( + saved_references.get("cab_sim"), + Some(&Value::from("something this build has never heard of")), + "an unrecognised key inside `references` must survive a save that clears a slot" + ); + } + + /// Issue #115's write half at the `State` level: the ceiling is enforced on the bytes this + /// crate hands out, not only on the bytes it is given. Driven through `Document`'s + /// limit-taking seam so the assertion costs microseconds; `try_write` is this exact path at + /// `MAX_DOCUMENT_BYTES`, which only an FR-STATE-080 embedded copy can realistically reach. + #[test] + fn a_state_whose_document_exceeds_the_ceiling_is_refused_at_write_time() { + let mut state = State::defaults(); + let payload = vec![b'x'; 4096]; + state.nam = Some(FileRef { + hash: ContentHash::of(&payload), + library_relative: None, + absolute: None, + display_name: "embedded.nam".to_string(), + embedded: Some(crate::EmbeddedRef { + media_type: "application/vnd.namir.nam+json".to_string(), + data: payload, + }), + }); + + let document = state.clone().into_document(); + let err = document.to_pretty_bytes_within(1024).unwrap_err(); + assert_eq!(err.code.id, crate::error_codes::DOCUMENT_TOO_LARGE.id); + + // Under the real ceiling the same state writes normally, and what it writes reads back. + let bytes = state.try_write().unwrap(); + assert_eq!(bytes, state.write()); + let (restored, warnings) = State::read(&bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(restored, state); + } + #[test] fn a_malformed_reference_degrades_to_absent_with_a_warning() { let mut document = Document::empty(); diff --git a/crates/namir-state/tests/corpus.rs b/crates/namir-state/tests/corpus.rs index 3920ddf..c8a0f6c 100644 --- a/crates/namir-state/tests/corpus.rs +++ b/crates/namir-state/tests/corpus.rs @@ -228,6 +228,79 @@ fn writing_the_references_fixture_back_reproduces_its_documented_section() { assert_eq!(written["references"], on_disk["references"]); } +/// Issue #113 / §7.4's four-step resolution order, driven end to end against the hand-authored +/// fixture rather than against a `FileRef` a test built in memory. The fixture's `nam` slot +/// carries all three external hints *and* an embedded copy; resolved on a machine that has none +/// of the three (no such library root, no such absolute path, nothing in the index — which is +/// precisely §7.2's "a preset shared with someone whose library is configured differently, or no +/// library at all"), the embedded copy is what makes it resolvable. Its `ir` slot, identical but +/// for having no embed, is the control: it reports missing, with the name and hash FR-STATE-070 +/// says the user must be shown. +#[test] +fn the_embedded_copy_resolves_a_reference_no_configured_path_can_find() { + let bytes = read_corpus_file("unreleased-v1/references.namirpreset"); + let (state, _warnings) = namir_state::State::read(&bytes).unwrap(); + + let nam = state.nam.expect("references.nam is present in the fixture"); + match namir_state::resolve(&nam, &FindsNothing) { + namir_state::Resolution::Embedded(embedded) => { + // P7: what the fallback hands back really is the bytes the reference identifies. + assert_eq!(namir_core::ContentHash::of(&embedded.data), nam.hash); + } + other => panic!("expected the embedded fallback, got {other:?}"), + } + + let ir = state.ir.expect("references.ir is present in the fixture"); + match namir_state::resolve(&ir, &FindsNothing) { + namir_state::Resolution::Missing(missing) => { + assert_eq!(missing.display_name, "1960a.wav"); + assert_eq!(missing.hash, ir.hash); + } + other => panic!("the ir slot carries no embed and must be missing, got {other:?}"), + } +} + +/// A resolver on a machine that has none of the fixture's files — the UC-3 recipient. +struct FindsNothing; + +impl namir_state::FileResolver for FindsNothing { + fn resolve_library_relative(&self, _rel: &namir_state::RelPath) -> Option { + None + } + fn resolve_absolute(&self, _absolute: &str) -> Option { + None + } + fn resolve_by_hash(&self, _hash: namir_core::ContentHash) -> Option { + None + } +} + +/// Issue #112 against hand-authored bytes: unloading the model and saving over the document it +/// came from must actually remove `references.nam`. §7's "absent means nothing of that kind is +/// loaded" is the only way this format can express an empty slot, so a save that cannot write it +/// cannot express the user's own gesture — the model comes back on the next load. +#[test] +fn unloading_a_reference_and_saving_over_the_fixture_removes_its_slot() { + let bytes = read_corpus_file("unreleased-v1/references.namirpreset"); + let original = namir_state::Document::parse(&bytes).unwrap(); + let (mut state, _warnings) = namir_state::State::read(&bytes).unwrap(); + assert!(state.nam.is_some() && state.ir.is_some()); + + state.nam = None; // the user unloads the model + let saved = state.write_onto(&original).to_pretty_bytes(); + + let saved_json: serde_json::Value = serde_json::from_slice(&saved).unwrap(); + assert!( + saved_json["references"].get("nam").is_none(), + "the cleared slot must be gone from the written bytes: {}", + saved_json["references"] + ); + let (reloaded, warnings) = namir_state::State::read(&saved).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(reloaded.nam, None); + assert_eq!(reloaded.ir, state.ir, "the untouched slot survives intact"); +} + /// A document from a build newer than this one (`format_version: 2`, greater than /// `namir_state::FORMAT_VERSION`) must not be rejected outright -- D-11.2's stated purpose is /// exactly this case. `migrate.rs`, landing later in this milestone, will add the specific From 1e48edc41741e0d671f9b0e4fa634e3b22d1184d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:53:09 +0000 Subject: [PATCH 21/44] Bound activation storage before allocating it (#48, #51, #54, #127) #48: bottleneck was bounded after being used. resolve_layer_array handed the unbounded value to the activation resolver and then cloned the slope vector per layer, so a 188 KB file allocated 147 MB before anything rejected it. Measured with the crate's existing assert_no_alloc harness: 8219 allocator calls before, 21 after. A second shape -- bottleneck 8192 with 4096 layers, each legal on its own -- cloned 134 MB and only then failed on a weight-count mismatch; now 9 calls. The ceiling is on the product, since that is what gets allocated. #54 is fixed, but not by the mechanism proposed: hound 3.5.1 has no spec_ex().bytes_per_sample -- the accessor is private and the chunk reader is not re-exported. The block align is read back out of the fmt chunk instead, walking chunks as hound does, bounds-checked, returning None rather than panicking. The allowed layouts are exactly the pairs hound's Sample::read implements. That found a third divergence the issue did not name: a data chunk declaring more bytes than the file holds probes fine and fails decode. An existing test already exercised it; nothing had documented it. probe_wav's contract now lists three exceptions. User-visible consequence, intended but worth stating: a WAV with an unsupported container layout that the library previously indexed is now rejected at scan time. That is what making probe and decode agree means. #51: the error remedy still told users about "the four activations Namir implements" when it implements ten. #127's three call sites are converted, each behind a seam so prepare and its test cannot drift -- the defect is invisible at the shipped 0.0 dB defaults, so the tests drive the same constructor at -12 dB. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-engine/src/stages/ir.rs | 57 ++++++- crates/namir-engine/src/stages/out.rs | 61 ++++++- crates/namir-engine/src/stages/trim.rs | 61 ++++++- crates/namir-ir/src/wav.rs | 210 ++++++++++++++++++++++-- crates/namir-nam/src/error_codes.rs | 11 +- crates/namir-nam/src/lib.rs | 9 +- crates/namir-nam/src/wavenet.rs | 216 ++++++++++++++++++++++++- 7 files changed, 593 insertions(+), 32 deletions(-) diff --git a/crates/namir-engine/src/stages/ir.rs b/crates/namir-engine/src/stages/ir.rs index 2e4db3e..c268e19 100644 --- a/crates/namir-engine/src/stages/ir.rs +++ b/crates/namir-engine/src/stages/ir.rs @@ -179,9 +179,7 @@ impl StagePrep for IrPrep { let mut level = Vec::with_capacity(channel_count); for _ in 0..channel_count { - let mut ramp = GainRamp::new(sample_rate, LEVEL_RAMP_TIME_CONSTANT_MS); - ramp.set_target_db(level_db_default); - level.push(ramp); + level.push(level_ramp_at_default(sample_rate, level_db_default)); } let mut stage = IrStage { @@ -818,6 +816,16 @@ impl Stage for IrStage { } } +/// Issue #127's follow-up: the one place this stage's per-channel level ramp is constructed, so `prepare` and the +/// test that pins its start point cannot drift apart. `GainRamp::new_at_db` rather than +/// `GainRamp::new` followed by `set_target_db`: the latter leaves `current` at unity and `target` +/// at the default, so the first ~25 ms of audio after every prepare, sample-rate change or +/// re-prepare ramps from 0 dB to the parameter's real default. That is inaudible only because +/// `ir.level_db` happens to default to 0.0 dB today — see `GainRamp::new_at_db`'s own doc comment. +fn level_ramp_at_default(sample_rate: SampleRate, default_db: f32) -> GainRamp { + GainRamp::new_at_db(sample_rate, LEVEL_RAMP_TIME_CONSTANT_MS, default_db) +} + #[cfg(test)] mod tests { use super::*; @@ -834,6 +842,49 @@ mod tests { .unwrap() } + /// Issue #127's follow-up. The defect is invisible at the shipped 0.0 dB default — a ramp from + /// unity to unity has nowhere to travel — so this drives the same constructor `prepare` uses + /// with a default that is *not* unity, which is the shape the trap is set for. Red against + /// `GainRamp::new` + `set_target_db`: that starts `current` at 1.0 and the first block fades. + #[test] + fn the_level_ramp_is_built_settled_at_a_non_unity_default() { + let sample_rate = SampleRate::new(48_000).unwrap(); + let mut ramp = level_ramp_at_default(sample_rate, -12.0); + assert!( + (ramp.current_db() - (-12.0)).abs() < 1e-4, + "the ramp starts at {} dB, not the -12.0 dB default it was built with", + ramp.current_db() + ); + + let expected = db_to_linear(-12.0); + let mut buf = [1.0f32; 64]; + ramp.process(&mut buf); + for (i, x) in buf.iter().enumerate() { + assert!( + (x - expected).abs() < 1e-6, + "sample {i} of the first block is {x}, not {expected} -- the ramp is \ + travelling to its default instead of starting there" + ); + } + } + + /// The other half: `prepare` really does route through level_ramp_at_default, so the assertion above is + /// about this stage and not just about `namir-dsp`. At the shipped default this is a + /// tripwire rather than a live check -- it starts failing the day the default moves and the + /// construction has drifted back. + #[test] + fn a_prepared_stage_starts_settled_at_the_level_default() { + let stage = stage(48_000, ChannelConfig::Stereo); + let default_db = continuous_default(LEVEL_DB); + for (index, ramp) in stage.level.iter().enumerate() { + assert!( + (ramp.current_db() - default_db).abs() < 1e-4, + "channel {index}'s level ramp sits at {} dB, not its {default_db} dB default", + ramp.current_db() + ); + } + } + /// Writes a small in-memory mono WAV via `hound::WavWriter`, the same pattern /// `namir-ir`'s own `convolver.rs` test module uses (not reusable directly -- that helper is /// private to that crate). diff --git a/crates/namir-engine/src/stages/out.rs b/crates/namir-engine/src/stages/out.rs index 5ff0a9a..bb26c13 100644 --- a/crates/namir-engine/src/stages/out.rs +++ b/crates/namir-engine/src/stages/out.rs @@ -14,6 +14,7 @@ //! independently — its own per-channel `GainRamp`/`Meter` state, one shared parameter target — so //! there is no cross-channel mixing here and no `StageIo::channel` reborrow gotcha to work around. +use namir_core::SampleRate; use namir_dsp::{GainRamp, Meter}; use namir_params::ParamKind; use namir_params::stages::out::{GAIN_DB, SILENCE_FLOOR_DB}; @@ -106,9 +107,7 @@ impl StagePrep for OutPrep { let mut meters = Vec::with_capacity(channel_count); let mut telemetry_ids = Vec::with_capacity(channel_count); for index in 0..channel_count { - let mut ramp = GainRamp::new(sample_rate, GAIN_RAMP_TIME_CONSTANT_MS); - ramp.set_target_db(gain_default_db); - ramps.push(ramp); + ramps.push(gain_ramp_at_default(sample_rate, gain_default_db)); meters.push(Meter::new(sample_rate)); telemetry_ids.push(ChannelTelemetryIds::new(index)); } @@ -226,6 +225,16 @@ impl Stage for OutStage { } } +/// Issue #127's follow-up: the one place this stage's per-channel gain ramp is constructed, so `prepare` and the +/// test that pins its start point cannot drift apart. `GainRamp::new_at_db` rather than +/// `GainRamp::new` followed by `set_target_db`: the latter leaves `current` at unity and `target` +/// at the default, so the first ~25 ms of audio after every prepare, sample-rate change or +/// re-prepare ramps from 0 dB to the parameter's real default. That is inaudible only because +/// `out.gain_db` happens to default to 0.0 dB today — see `GainRamp::new_at_db`'s own doc comment. +fn gain_ramp_at_default(sample_rate: SampleRate, default_db: f32) -> GainRamp { + GainRamp::new_at_db(sample_rate, GAIN_RAMP_TIME_CONSTANT_MS, default_db) +} + #[cfg(test)] mod tests { use super::*; @@ -252,6 +261,52 @@ mod tests { } } + /// Issue #127's follow-up. The defect is invisible at the shipped 0.0 dB default — a ramp from + /// unity to unity has nowhere to travel — so this drives the same constructor `prepare` uses + /// with a default that is *not* unity, which is the shape the trap is set for. Red against + /// `GainRamp::new` + `set_target_db`: that starts `current` at 1.0 and the first block fades. + #[test] + fn the_gain_ramp_is_built_settled_at_a_non_unity_default() { + let sample_rate = SampleRate::new(48_000).unwrap(); + let mut ramp = gain_ramp_at_default(sample_rate, -12.0); + assert!( + (ramp.current_db() - (-12.0)).abs() < 1e-4, + "the ramp starts at {} dB, not the -12.0 dB default it was built with", + ramp.current_db() + ); + + let expected = db_to_linear(-12.0); + let mut buf = [1.0f32; 64]; + ramp.process(&mut buf); + for (i, x) in buf.iter().enumerate() { + assert!( + (x - expected).abs() < 1e-6, + "sample {i} of the first block is {x}, not {expected} -- the ramp is \ + travelling to its default instead of starting there" + ); + } + } + + /// The other half: `prepare` really does route through gain_ramp_at_default, so the assertion above is + /// about this stage and not just about `namir-dsp`. At the shipped default this is a + /// tripwire rather than a live check -- it starts failing the day the default moves and the + /// construction has drifted back. + #[test] + fn a_prepared_stage_starts_settled_at_the_gain_default() { + let stage = stage(ChannelConfig::Stereo); + let default_db = match GAIN_DB.kind { + ParamKind::Continuous { default, .. } => default, + ParamKind::Stepped { .. } => unreachable!("out.gain_db is declared Continuous"), + }; + for (index, ramp) in stage.ramps.iter().enumerate() { + assert!( + (ramp.current_db() - default_db).abs() < 1e-4, + "channel {index}'s ramp sits at {} dB, not its {default_db} dB default", + ramp.current_db() + ); + } + } + #[test] fn gain_is_applied_once_settled() { let mut stage = stage(ChannelConfig::Mono); diff --git a/crates/namir-engine/src/stages/trim.rs b/crates/namir-engine/src/stages/trim.rs index 50afae0..99dbe90 100644 --- a/crates/namir-engine/src/stages/trim.rs +++ b/crates/namir-engine/src/stages/trim.rs @@ -23,7 +23,7 @@ //! Trim is not in FR-CHAIN-020's bypassable list, so unlike Gate/Nam/Ir/Eq this stage has no //! dry/wet crossfade machinery. -use namir_core::db_to_linear; +use namir_core::{SampleRate, db_to_linear}; use namir_dsp::{DcBlocker, GainRamp, Meter}; use namir_params::ParamKind; use namir_params::stages::trim::{DC_BLOCKER_ENABLED, GAIN_DB}; @@ -99,11 +99,8 @@ impl StagePrep for TrimPrep { } }; - let mut gain_ramp = GainRamp::new(sample_rate, GAIN_RAMP_TIME_CONSTANT_MS); - gain_ramp.set_target_db(gain_default_db); - Ok(TrimStage { - gain_ramp, + gain_ramp: gain_ramp_at_default(sample_rate, gain_default_db), dc_blocker: DcBlocker::new(sample_rate, DC_BLOCKER_CORNER_HZ), dc_blocker_enabled: dc_blocker_default_on, meter: Meter::new(sample_rate), @@ -219,6 +216,16 @@ impl Stage for TrimStage { } } +/// Issue #127's follow-up: the one place this stage's gain ramp is constructed, so `prepare` and the +/// test that pins its start point cannot drift apart. `GainRamp::new_at_db` rather than +/// `GainRamp::new` followed by `set_target_db`: the latter leaves `current` at unity and `target` +/// at the default, so the first ~25 ms of audio after every prepare, sample-rate change or +/// re-prepare ramps from 0 dB to the parameter's real default. That is inaudible only because +/// `trim.gain_db` happens to default to 0.0 dB today — see `GainRamp::new_at_db`'s own doc comment. +fn gain_ramp_at_default(sample_rate: SampleRate, default_db: f32) -> GainRamp { + GainRamp::new_at_db(sample_rate, GAIN_RAMP_TIME_CONSTANT_MS, default_db) +} + #[cfg(test)] mod tests { use super::*; @@ -261,6 +268,50 @@ mod tests { buf[buf.len() - 1] } + /// Issue #127's follow-up. The defect is invisible at the shipped 0.0 dB default — a ramp from + /// unity to unity has nowhere to travel — so this drives the same constructor `prepare` uses + /// with a default that is *not* unity, which is the shape the trap is set for. Red against + /// `GainRamp::new` + `set_target_db`: that starts `current` at 1.0 and the first block fades. + #[test] + fn the_gain_ramp_is_built_settled_at_a_non_unity_default() { + let sample_rate = SampleRate::new(48_000).unwrap(); + let mut ramp = gain_ramp_at_default(sample_rate, -12.0); + assert!( + (ramp.current_db() - (-12.0)).abs() < 1e-4, + "the ramp starts at {} dB, not the -12.0 dB default it was built with", + ramp.current_db() + ); + + let expected = db_to_linear(-12.0); + let mut buf = [1.0f32; 64]; + ramp.process(&mut buf); + for (i, x) in buf.iter().enumerate() { + assert!( + (x - expected).abs() < 1e-6, + "sample {i} of the first block is {x}, not {expected} -- the ramp is \ + travelling to its default instead of starting there" + ); + } + } + + /// The other half: `prepare` really does route through gain_ramp_at_default, so the assertion above is + /// about this stage and not just about `namir-dsp`. At the shipped default this is a + /// tripwire rather than a live check -- it starts failing the day the default moves and the + /// construction has drifted back. + #[test] + fn a_prepared_stage_starts_settled_at_the_gain_default() { + let stage = stage(ChannelConfig::Mono); + let default_db = match GAIN_DB.kind { + ParamKind::Continuous { default, .. } => default, + ParamKind::Stepped { .. } => unreachable!("trim.gain_db is declared Continuous"), + }; + assert!( + (stage.gain_ramp.current_db() - default_db).abs() < 1e-4, + "a freshly prepared stage's ramp sits at {} dB, not its {default_db} dB default", + stage.gain_ramp.current_db() + ); + } + // trace: FR-IN-010 #[test] fn pure_gain_is_applied_once_settled() { diff --git a/crates/namir-ir/src/wav.rs b/crates/namir-ir/src/wav.rs index db37ec8..faf0bcd 100644 --- a/crates/namir-ir/src/wav.rs +++ b/crates/namir-ir/src/wav.rs @@ -4,7 +4,9 @@ //! [`IrLoadError`](crate::error_codes::IrLoadError), never a panic. //! //! Supports exactly FR-IR-010's matrix: mono or stereo, 16-bit int / 24-bit int / 32-bit int / -//! 32-bit float, `8_000..=192_000` Hz — and, in the float case only, requires every sample to be +//! 32-bit float, `8_000..=192_000` Hz, each stored in one of the container widths hound can +//! actually read it from (16-in-2, 24-in-3, 24-in-4, 32-in-4 — issue #54, see +//! [`open_and_validate_header`]) — and, in the float case only, requires every sample to be //! a finite number (`error_codes::NON_FINITE_SAMPLE`; see that entry for why a NaN or infinite tap //! is refused here rather than handled downstream). Every sample is converted to `f32` in //! (approximately) `[-1.0, 1.0]` — see [`decode`]'s doc comment for the exact conversion and the @@ -56,12 +58,47 @@ pub(crate) struct DecodedWav { pub was_truncated: bool, } +/// Reads `nBlockAlign` back out of the `fmt ` chunk. hound derives a sample's *container* width +/// from it (`WavSpecEx::bytes_per_sample = block_align / channels`) and deliberately allows that +/// width to exceed `bits_per_sample` — "so that we can support things such as 24 bit samples in 4 +/// byte containers", `read_fmt_chunk`'s own comment — but in 3.5.1 it surfaces the figure only +/// through `read::read_until_data`, which the crate does not re-export, and `WavReader` has no +/// accessor for it. Hence this second, minimal read of one field hound has already parsed. +/// +/// Walks chunks exactly as `read_until_data` does, skipping each unknown chunk by precisely its +/// declared length with no RIFF word-alignment padding, so the two agree on where `fmt ` starts. +/// Every index is bounds-checked: this runs on the same untrusted bytes as everything else here, +/// and returns `None` rather than panicking on any shape it cannot walk. +fn declared_block_align(bytes: &[u8]) -> Option { + if bytes.get(0..4)? != b"RIFF" || bytes.get(8..12)? != b"WAVE" { + return None; + } + let mut pos = 12usize; + loop { + let header = bytes.get(pos..pos.checked_add(8)?)?; + let len = u32::from_le_bytes(header[4..8].try_into().ok()?) as usize; + let body = pos.checked_add(8)?; + if &header[0..4] == b"fmt " { + // `nBlockAlign` is the WAVEFORMAT struct's fifth field, at byte 12 of the chunk body; + // `read_fmt_chunk` refuses a body shorter than 16, so a shorter one never reaches here. + if len < 16 { + return None; + } + let field = bytes.get(body.checked_add(12)?..body.checked_add(14)?)?; + return Some(u16::from_le_bytes(field.try_into().ok()?)); + } + pos = body.checked_add(len)?; + } +} + /// The header validation `decode` and `probe` both need, factored out so the two never drift: /// a header that `probe_wav` accepts must be one `decode` would go on to accept too (modulo the -/// two judgments that are not about the header at all: `EMPTY_IR`, which needs the declared-frame -/// check `probe_wav` also performs, and `NON_FINITE_SAMPLE`, which needs the sample data only -/// `decode` reads — see both callers). Returns the parsed `hound::WavReader` so `decode` can go on -/// to read samples from it without re-parsing the header a second time. +/// judgments that are not about the header at all: `EMPTY_IR`, which needs the declared-frame +/// check `probe_wav` also performs, `NON_FINITE_SAMPLE`, which needs the sample data only +/// `decode` reads, and a `data` chunk whose declared length outruns the file's real bytes, which +/// only shows up once `decode` tries to read them — see both callers). Returns the parsed +/// `hound::WavReader` so `decode` can go on to read samples from it without re-parsing the header +/// a second time. fn open_and_validate_header(bytes: &[u8]) -> Result>, IrLoadError> { let reader = hound::WavReader::new(Cursor::new(bytes)).map_err(|e| IrLoadError { code: error_codes::MALFORMED_WAV, @@ -92,6 +129,41 @@ fn open_and_validate_header(bytes: &[u8]) -> Result Result { let reader = open_and_validate_header(bytes)?; let spec = reader.spec(); @@ -660,6 +741,111 @@ mod tests { buf } + /// Hand-assembles minimal WAV bytes whose `fmt ` chunk states a **container width** — the + /// per-sample byte count hound derives as `block_align / channels` — independent of + /// `bits_per_sample`. That combination is legal WAV and hound accepts it deliberately + /// (`read_fmt_chunk`: "We allow bits_per_sample to be less than bytes_per_sample so that we + /// can support things such as 24 bit samples in 4 byte containers"), but `hound::WavWriter` + /// cannot write one, so this is built by hand. `sample_bytes` is the raw, already-encoded + /// sample payload, `bytes_per_sample` bytes per sample. + fn wav_with_container_width( + sample_rate: u32, + channels: u16, + bits_per_sample: u16, + bytes_per_sample: u16, + sample_bytes: &[u8], + ) -> Vec { + let block_align = channels * bytes_per_sample; + let byte_rate = sample_rate * block_align as u32; + let mut buf = Vec::new(); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&(36 + sample_bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(b"WAVE"); + buf.extend_from_slice(b"fmt "); + buf.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size (PCM) + buf.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM + buf.extend_from_slice(&channels.to_le_bytes()); + buf.extend_from_slice(&sample_rate.to_le_bytes()); + buf.extend_from_slice(&byte_rate.to_le_bytes()); + buf.extend_from_slice(&block_align.to_le_bytes()); + buf.extend_from_slice(&bits_per_sample.to_le_bytes()); + buf.extend_from_slice(b"data"); + buf.extend_from_slice(&(sample_bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(sample_bytes); + buf + } + + /// Issue #54. `bits_per_sample` alone does not say how wide a sample's container is, and + /// hound's `Sample::read` for `i32` implements only four `(bytes, bits)` pairs — `(2, 16)`, + /// `(3, 24)`, `(4, 24)`, `(4, 32)`. A 16-bit-in-4-byte file is well-formed WAV that hound + /// parses happily, so it passed `open_and_validate_header` and probed fine, and then `decode` + /// failed on the first sample with hound's `Unsupported`, mapped to `MALFORMED_WAV`. Two + /// defects in one: the header contract says a header `probe_wav` accepts is one `decode` + /// accepts, and `MALFORMED_WAV` is the wrong verdict for a file that is not malformed at all — + /// it is well-formed and carries a container layout this build does not read. + // trace: FR-IR-010 + #[test] + fn rejects_a_bit_depth_narrower_than_its_container_as_unsupported_not_malformed() { + let samples: Vec = (0..4i32).flat_map(|v| (v * 1000).to_le_bytes()).collect(); + let bytes = wav_with_container_width(48_000, 1, 16, 4, &samples); + + let probe_err = probe_wav(&bytes).unwrap_err(); + let decode_err = decode(&bytes).unwrap_err(); + assert_eq!( + probe_err.code.id, + error_codes::UNSUPPORTED_FORMAT.id, + "probe_wav accepted a container layout decode cannot read: {}", + probe_err.detail + ); + assert_eq!(decode_err.code.id, error_codes::UNSUPPORTED_FORMAT.id); + } + + /// The other side of that check: a 24-bit sample in a 4-byte container is the layout hound's + /// comment names as the reason it permits the mismatch at all, and `Sample::read` does + /// implement `(4, 24)`. It must keep loading -- the container check rejects what hound cannot + /// read, not every padded file. + // trace: FR-IR-010 + #[test] + fn accepts_a_24_bit_sample_in_a_4_byte_container() { + // `read_le_i24_4` reads four little-endian bytes and sign-extends bit 23, so an i24 + // value is written as its low three bytes plus a zero (or 0xff, for a negative) pad. + let values: [i32; 3] = [0, 1000, -1000]; + let samples: Vec = values + .iter() + .flat_map(|v| ((*v as u32) & 0x00ff_ffff).to_le_bytes()) + .collect(); + let bytes = wav_with_container_width(48_000, 1, 24, 4, &samples); + + let info = probe_wav(&bytes).expect("a 24-in-4 file is one hound reads"); + assert_eq!(info.bits_per_sample, 24); + assert_eq!(info.declared_frames, 3); + + let decoded = decode(&bytes).expect("a 24-in-4 file is one hound reads"); + let divisor = 2f32.powi(23); + assert_eq!( + decoded.channel_data[0], + vec![0.0, 1000.0 / divisor, -1000.0 / divisor] + ); + } + + /// A 32-bit-int sample in an 8-byte container: the same class of defect from the other side — + /// hound's `Sample::read` answers `TooWide` rather than `Unsupported` for a container over + /// four bytes, and both mapped to `MALFORMED_WAV` before issue #54. + // trace: FR-IR-010 + #[test] + fn rejects_a_container_wider_than_four_bytes_as_unsupported() { + let samples = vec![0u8; 8 * 3]; + let bytes = wav_with_container_width(48_000, 1, 32, 8, &samples); + assert_eq!( + probe_wav(&bytes).unwrap_err().code.id, + error_codes::UNSUPPORTED_FORMAT.id + ); + assert_eq!( + decode(&bytes).unwrap_err().code.id, + error_codes::UNSUPPORTED_FORMAT.id + ); + } + #[test] fn probe_wav_reads_header_fields_matching_decode() { let bytes = write_int_wav(44_100, 2, 24, &[0, 0, 100, -100]); diff --git a/crates/namir-nam/src/error_codes.rs b/crates/namir-nam/src/error_codes.rs index 3b8b420..0b910e2 100644 --- a/crates/namir-nam/src/error_codes.rs +++ b/crates/namir-nam/src/error_codes.rs @@ -47,13 +47,18 @@ pub const UNSUPPORTED_HEAD_CONFIG: ErrorCode = ErrorCode::new( default settings produces one.", ); -/// A layer array's `activation` string is not one of `Tanh`, `ReLU`, `Sigmoid`, `Identity`. +/// A layer array's `activation` names something outside the vocabulary `wavenet.rs`'s +/// `Activation` implements: `Tanh`, `ReLU`, `Sigmoid`, `Identity`, `LeakyReLU`, `SiLU`, +/// `Hardswish`, `Softsign`, `LeakyHardtanh` (either casing, matching the reference's own +/// `type_map`) and `PReLU`. M10 grew that set from A1's first four to these ten; this doc comment +/// and the remedy below said four until issue #51. pub const UNSUPPORTED_ACTIVATION: ErrorCode = ErrorCode::new( "nam.load.unsupported_activation", Severity::Error, "This model uses an activation function that is not supported.", - "Load a model whose layers use Tanh, ReLU, Sigmoid or Identity -- the four Namir implements. \ - Re-export from the trainer with a standard activation.", + "Load a model whose layers use Tanh, ReLU, Sigmoid, Identity, LeakyReLU, SiLU, Hardswish, \ + Softsign, LeakyHardtanh or PReLU -- the ten Namir implements. Re-export from the trainer \ + with a standard activation.", ); /// `config.layers` is empty — there is no WaveNet stack to build at all. diff --git a/crates/namir-nam/src/lib.rs b/crates/namir-nam/src/lib.rs index c653a46..143912d 100644 --- a/crates/namir-nam/src/lib.rs +++ b/crates/namir-nam/src/lib.rs @@ -72,7 +72,14 @@ //! - FR-NAM-100 (dBu-calibrated operating levels, user-stated interface sensitivity) — a distinct, //! Should-priority requirement from FR-NAM-090 above; this crate reads neither //! `input_level_dbu` nor `output_level_dbu`, and no calibrated-mode UI exists anywhere. -//! - FR-NAM-120 (computational cost reporting) — needs a benchmark harness. +//! - FR-NAM-120 (computational cost reporting) — but not for the reason this line gave until +//! issue #51. The *measurement* half is here and has been since M10: `benches/ +//! wavenet_inner_loops.rs` and `benches/lstm_inner_loops.rs` measure per-block inference cost +//! across the real shape grid rather than estimating it, which is the harness this note said was +//! missing. What is out of scope is the requirement's other half — *exposing* that cost to the +//! user as a real-time factor, before they commit to a model — which needs a live per-block +//! measurement in `namir-engine` and somewhere in `namir-ui` to show it, and this crate may +//! depend on neither (D-5.1). //! - Parametric/conditioning inputs for either architecture (WaveNet's `condition_size == 1` //! restriction, `wavenet.rs`; LSTM's `input_size == in_channels == out_channels == 1` //! restriction, `lstm.rs`) — both only ever feed the raw mono signal as input in 1.0 scope. diff --git a/crates/namir-nam/src/wavenet.rs b/crates/namir-nam/src/wavenet.rs index 2517586..ce4d397 100644 --- a/crates/namir-nam/src/wavenet.rs +++ b/crates/namir-nam/src/wavenet.rs @@ -102,6 +102,21 @@ const MAX_DILATION: usize = 8_192; /// saturation only ever affects a value this check was going to reject anyway. const MAX_CONV_HISTORY_ELEMENTS: usize = 16_777_216; +/// Issue #48's product ceiling, the activation-parameter counterpart of +/// `MAX_CONV_HISTORY_ELEMENTS`: bounds the floats one layer array's *resolved* activations hold in +/// total, `activation parameter elements * dilations.len()`. An `activation` stated once for the +/// whole array (`ActivationSpec::One`) is resolved once and then cloned per layer, so a +/// per-channel `PReLU`'s `negative_slopes` — the only activation parameter that is a vector — is +/// stored `dilations.len()` times while the file carries it once. `bottleneck` and +/// `dilations.len()` are each individually bounded above, but at their own ceilings their product +/// is still 8192 * 4096 f32 = 134 MB grown out of the 8192 the file actually contains, and no +/// single-factor ceiling bounds that product. 1 Mi elements (4 MB) is ~800x above any plausible +/// export — the S-1-verified "standard" shape's widest array is 16 channels over 10 layers, 160 +/// elements — while ruling out the amplification. A per-layer `activation` array is not bounded +/// here and needs no bound: it carries one entry per layer in the file itself, so its storage is +/// linear in file size rather than a multiple of it. +const MAX_ACTIVATION_PARAMETER_ELEMENTS: usize = 1_048_576; + /// FRS §2's definitions: model sample rate is "typically 48 kHz" — the fallback when a `.nam` /// file omits `sample_rate` entirely (real exported files sometimes do). const DEFAULT_SAMPLE_RATE_HZ: u32 = 48_000; @@ -399,6 +414,16 @@ fn check_activation_parameters_finite( } } +/// How many floats a resolved activation holds — i.e. how much this array's per-layer clone +/// multiplies. Every variant but a per-channel `PReLU` carries only inline scalars, so only that +/// one can grow; see [`MAX_ACTIVATION_PARAMETER_ELEMENTS`]. +fn activation_parameter_elements(activation: &Activation) -> usize { + match activation { + Activation::PReLU(PReluSlopes::PerChannel(slopes)) => slopes.len(), + _ => 0, + } +} + /// Resolves one `.nam` layer's `activation` entry (bare name, or an object naming `type` plus /// parameters — [`file::ActivationEntry`]) to this file's `Activation`. `bottleneck` is the /// layer's internal width, needed only to validate a per-channel `PReLU`'s `negative_slopes` @@ -1167,6 +1192,22 @@ fn resolve_layer_array( let num_layers = cfg.dilations.len(); + // Issue #48, the same NFR-SEC-020 ordering argument one paragraph up, for the other dimension + // this function uses before `validate_layer_array_dims` gets to bound it. `bottleneck` is the + // width a per-channel `PReLU`'s `negative_slopes` must match, and `ActivationSpec::One` then + // clones that vector once per layer — so an unbounded `bottleneck` bought an unbounded clone: + // a 188 KB file declaring `dilations: [1; 4096]`, `bottleneck: 9000` and a 9000-entry + // `negative_slopes` allocated 4096 * 9000 f32 = 147 MB and only then returned + // `DIMENSION_LIMIT_EXCEEDED` from the *next* call, scaling linearly with file size from there. + // `validate_layer_array_dims` still checks the same bound afterwards, for the same reason the + // `dilations.len()` copy above is left in place. + let bottleneck = cfg.bottleneck.unwrap_or(cfg.channels); + check_max( + bottleneck, + MAX_CHANNELS, + &format!("layer array {index}: bottleneck"), + )?; + let kernel_sizes = match (cfg.kernel_size, &cfg.kernel_sizes) { (Some(_), Some(_)) => { return Err(inconsistent( @@ -1195,8 +1236,6 @@ fn resolve_layer_array( } }; - let bottleneck = cfg.bottleneck.unwrap_or(cfg.channels); - let (head_out_channels, head_kernel_size, head_dilation, head_bias) = match (cfg.head_size, &cfg.head) { (Some(_), Some(_)) => { @@ -1222,6 +1261,14 @@ fn resolve_layer_array( let activations = match &cfg.activation { file::ActivationSpec::One(entry) => { let activation = resolve_activation_entry(entry, bottleneck, index, 0)?; + // Issue #48's second half: this is the clone that multiplies. See + // `MAX_ACTIVATION_PARAMETER_ELEMENTS` for why the two factors' own ceilings don't + // bound their product. + check_max( + activation_parameter_elements(&activation).saturating_mul(num_layers), + MAX_ACTIVATION_PARAMETER_ELEMENTS, + &format!("layer array {index}: activation parameter elements * dilations.len()"), + )?; vec![activation; num_layers] } file::ActivationSpec::PerLayer(entries) => { @@ -1420,9 +1467,11 @@ impl PreparedWaveNet { /// permanently out-of-scope feature the array uses is rejected by name /// (`UNSUPPORTED_CONFIGURATION`), a self-contradictory shape (both-or-neither of an A1/A2 /// field pair present, or an array length disagreeing with `dilations.len()`) is rejected as - /// such (`INCONSISTENT_CONFIGURATION`, including `dilations.len()` itself against its own - /// ceiling — see that function's own doc comment for why that one check can't wait for step - /// 8's next part), then its dimensions (now including A2's per-layer `kernel_sizes`, + /// such (`INCONSISTENT_CONFIGURATION`, including `dilations.len()` and `bottleneck` against + /// their own ceilings, and the floats a per-array `activation` clone stores in total against + /// `MAX_ACTIVATION_PARAMETER_ELEMENTS` — see that function's own doc comment for why those + /// checks can't wait for step 8's next part; issue #48), then its dimensions (now including + /// A2's per-layer `kernel_sizes`, /// `bottleneck`, and the nested head's `out_channels`/`kernel_size`/`head_dilation`) are /// checked against their ceilings, at least 1, and `condition_size == 1` /// (`DIMENSION_LIMIT_EXCEEDED` / `UNSUPPORTED_CONDITION_SIZE`), including the per-layer and @@ -2121,6 +2170,44 @@ mod tests { assert_eq!(err.code.id, error_codes::EMPTY_LAYER_ARRAYS.id); } + /// Issue #51. The supported activation vocabulary is stated in three places that have to + /// agree: `Activation`'s own `TryFrom<&str>`, `resolve_activation_kind`'s object form, and + /// `UNSUPPORTED_ACTIVATION`'s user-facing remedy — which is the only one of the three a user + /// ever reads, and which still named A1's four after M10 grew the set to ten. Listing the + /// names here rather than deriving them keeps this an assertion about what is *documented*, + /// which is the thing that drifted. + const SUPPORTED_ACTIVATION_NAMES: [&str; 10] = [ + "Tanh", + "ReLU", + "Sigmoid", + "Identity", + "LeakyReLU", + "SiLU", + "Hardswish", + "Softsign", + "LeakyHardtanh", + "PReLU", + ]; + + #[test] + fn every_supported_activation_name_is_named_by_the_error_remedy() { + for name in SUPPORTED_ACTIVATION_NAMES { + assert!( + Activation::try_from(name).is_ok(), + "{name} is listed as supported but does not resolve" + ); + assert!( + error_codes::UNSUPPORTED_ACTIVATION.remedy.contains(name), + "UNSUPPORTED_ACTIVATION's remedy does not name {name}, so a user who exported one \ + is told to re-export with something Namir already plays" + ); + } + assert!( + Activation::try_from("GELU").is_err(), + "the vocabulary should still be closed" + ); + } + #[test] fn rejects_unsupported_activation() { let mut file = minimal_valid_file(); @@ -2177,6 +2264,125 @@ mod tests { assert_eq!(err.code.id, error_codes::DIMENSION_LIMIT_EXCEEDED.id); } + /// Issue #48's fixture shape: a layer array whose per-channel `PReLU` names `slopes` slopes, + /// repeated over `layers` dilations. `bottleneck` is stated explicitly so the activation's + /// declared width and the array's internal width can be made to agree (or not) on purpose. + fn prelu_per_channel_file(bottleneck: usize, slopes: usize, layers: usize) -> NamFile { + let mut file = minimal_valid_file(); + file.config.layers[0].bottleneck = Some(bottleneck); + file.config.layers[0].dilations = vec![1; layers]; + file.config.layers[0].activation = + file::ActivationSpec::One(file::ActivationEntry::Params(file::ActivationParams { + kind: "PReLU".to_string(), + negative_slope: None, + negative_slopes: Some(vec![0.01; slopes]), + min_val: None, + max_val: None, + min_slope: None, + max_slope: None, + })); + file + } + + /// Counts allocator calls made inside `f`, using the same `assert_no_alloc` global allocator + /// `rt_harness` registers for this test binary — in its counting (`warn_debug`/`warn_release`) + /// mode, so an allocation is tallied rather than fatal. Deallocations count too, so a clone + /// made and then dropped inside `f` shows up as two. + fn count_allocator_calls(f: impl FnOnce() -> T) -> (T, u32) { + assert_no_alloc::reset_violation_count(); + let out = assert_no_alloc::assert_no_alloc(f); + (out, assert_no_alloc::violation_count()) + } + + /// Issue #48, the ordering half. `bottleneck` was bounded only by `validate_layer_array_dims`, + /// which runs *after* `resolve_layer_array` has already used the unbounded value — here as the + /// length a per-channel `PReLU`'s `negative_slopes` must match. So a file declaring an + /// over-ceiling `bottleneck` was diagnosed by whatever `resolve_layer_array` tripped over + /// first: with a short slopes array, `INCONSISTENT_CONFIGURATION`, which is the wrong answer — + /// the file's primary defect is the dimension, and NFR-SEC-020 wants it caught before the + /// dimension is used for anything. + #[test] + fn bounds_bottleneck_before_resolving_an_activation_against_it() { + let file = prelu_per_channel_file(MAX_CHANNELS + 808, 4, 1); + let err = expect_err(PreparedWaveNet::from_file(&file)); + assert_eq!(err.code.id, error_codes::DIMENSION_LIMIT_EXCEEDED.id); + assert!( + err.detail.contains("bottleneck"), + "the ceiling that rejected the file should name bottleneck, got: {}", + err.detail + ); + } + + /// Issue #48, the allocation half — the reason the ordering above matters. `ActivationSpec::One` + /// clones its resolved activation once per dilation, so an unbounded `bottleneck` bought an + /// unbounded clone: the issue measured a 188 KB file (`dilations: [1; 4096]`, + /// `bottleneck: 9000`, a 9000-entry `negative_slopes`) allocating 4096 * 9000 f32 = 147 MB and + /// only *then* returning `DIMENSION_LIMIT_EXCEEDED`, scaling linearly with file size from + /// there. With the ceiling moved ahead of the resolution, the file is refused before the first + /// of those clones. + #[test] + fn rejects_an_over_ceiling_bottleneck_without_cloning_its_slopes_per_layer() { + let over = MAX_CHANNELS + 808; // 9000, the figure the issue measured + let file = prelu_per_channel_file(over, over, MAX_DILATIONS_PER_LAYER_ARRAY); + let (err, allocator_calls) = + count_allocator_calls(|| expect_err(PreparedWaveNet::from_file(&file))); + assert_eq!(err.code.id, error_codes::DIMENSION_LIMIT_EXCEEDED.id); + // Pre-fix this ran 8219 allocator calls — 4096 slope-vector clones plus their frees, 147 MB + // in flight. The rejection now costs the error string and nothing else; 64 is slack for + // formatting internals, not room for a per-layer clone. + assert!( + allocator_calls <= 64, + "rejecting an over-ceiling bottleneck made {allocator_calls} allocator calls; \ + the per-layer slope clone is back" + ); + } + + /// The product ceiling, the same shape of gap `MAX_CONV_HISTORY_ELEMENTS` closes for the + /// causal-conv history: `bottleneck` and `dilations.len()` are each individually within their + /// own ceilings here, and their product — the slopes actually stored, once per layer — is + /// still 8192 * 4096 f32 = 134 MB from a file carrying only 8192 of them. No single-factor + /// ceiling bounds that product, so `MAX_ACTIVATION_PARAMETER_ELEMENTS` does. + #[test] + fn rejects_activation_parameter_storage_whose_product_exceeds_its_own_ceiling() { + let file = + prelu_per_channel_file(MAX_CHANNELS, MAX_CHANNELS, MAX_DILATIONS_PER_LAYER_ARRAY); + let (err, allocator_calls) = + count_allocator_calls(|| expect_err(PreparedWaveNet::from_file(&file))); + assert_eq!(err.code.id, error_codes::DIMENSION_LIMIT_EXCEEDED.id); + assert!( + allocator_calls <= 64, + "rejecting an over-ceiling slope-storage product made {allocator_calls} allocator \ + calls; the per-layer slope clone is back" + ); + } + + /// The other side of that ceiling: a per-channel `PReLU` of a plausible real width, repeated + /// over a plausible real layer count, must still load. + #[test] + fn a_realistically_sized_per_channel_prelu_still_loads() { + let mut cfg = minimal_layer_array(); + cfg.channels = 16; + cfg.bottleneck = Some(16); + cfg.dilations = (0..10).map(|i| 1usize << i).collect(); + cfg.activation = + file::ActivationSpec::One(file::ActivationEntry::Params(file::ActivationParams { + kind: "PReLU".to_string(), + negative_slope: None, + negative_slopes: Some(vec![0.01; 16]), + min_val: None, + max_val: None, + min_slope: None, + max_slope: None, + })); + let n = weight_count_for(&cfg); + let mut weights = vec![0.01f32; n]; + weights.push(0.5); // trailing head_scale + let mut file = minimal_valid_file(); + file.config.layers = vec![cfg]; + file.weights = weights; + PreparedWaveNet::from_file(&file).expect("a 16-channel, 10-layer PReLU model should load"); + } + // trace: FR-NAM-040 #[test] fn rejects_wrong_weight_count() { From e914e11516d108f106a4dc912536593a37b79a0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:54:58 +0000 Subject: [PATCH 22/44] namir-worker: three timing promises kept, one corrected (#106-#111) #106: try_submit is a GUI-thread contract both shells cite, and it waited out another thread's full deadline -- 2.14 s in the reproduction. It now tries the lock and hands the command back as Timeout, which is the answer callers already handle, so no new variant and no dead arm on the blocking path. #107 (security): recall::locate read an untrusted preset-supplied path with no bound. The reproduction read a 2 GB sparse candidate in 9.73 s; it is now a miss in microseconds. Same shape as namir-library's post-#70 fix rather than a second design: refuse non-regular paths before opening, keep the stat only as a cheap reject and capacity hint, and enforce the ceiling with take(max + 1). The same gap in LoadSource::read is closed with it. #108: the submit deadline was paid per parameter, so a backed-up ring cost 66.13 s for one recall. One budget for the whole pass now, each parameter waiting on what is left, the rest still attempted non-blocking and still counted. The two resource submits keep their own deadline -- they are a constant two, not a multiple of the registry. #110 is a doc fix, not a code one. The sweep runs only on a miss that has just parsed a whole file, so an O(n) retain over a few dozen Weaks is invisible, while the documented 2*live rule would need state across calls and would deliberately hold dead entries to twice the live set -- the residue NFR-PERF-070 exists to bound. Its tests were green on arrival, so their value was checked by implementing the documented rule instead: one then fails, 70 entries against 65, so they do discriminate the two candidate rules. #111: a second concurrent shutdown returned while threads were still running, and clap_plugin.destroy's caller may unload the library the instant it returns. Callers now wait, except one of the pool's own threads, where waiting would deadlock against the joiner. The residue no implementation can remove -- a re-entrant joiner skips its own handle -- is documented rather than papered over. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-worker/src/cache.rs | 69 ++++++++- crates/namir-worker/src/error_codes.rs | 17 ++ crates/namir-worker/src/lib.rs | 132 +++++++++++++--- crates/namir-worker/src/pool.rs | 110 ++++++++++++- crates/namir-worker/src/recall.rs | 206 +++++++++++++++++++++++-- crates/namir-worker/src/submit.rs | 93 ++++++++++- 6 files changed, 586 insertions(+), 41 deletions(-) diff --git a/crates/namir-worker/src/cache.rs b/crates/namir-worker/src/cache.rs index a3525f8..ccff35c 100644 --- a/crates/namir-worker/src/cache.rs +++ b/crates/namir-worker/src/cache.rs @@ -80,7 +80,18 @@ pub struct ResourceCache { static SHARED: OnceLock> = OnceLock::new(); -/// Reaping starts once a map exceeds this many entries; the threshold then tracks the live count. +/// Reaping starts once a map holds more than this many entries, and from then on runs on every +/// miss. **The floor is the whole rule** — there is no second, live-count term. +/// +/// Issue #110: this line used to end "the threshold then tracks the live count", describing a +/// `len > max(REAP_FLOOR, 2 * live)` rule the code has never implemented. The comment was the +/// wrong half, and this is the corrected text rather than the missing term, deliberately. A sweep +/// runs only on a *miss*, and a miss has just parsed a whole file — tens to hundreds of +/// milliseconds — so an O(n) `retain` over a few dozen `Weak`s is invisible beside what it is +/// amortised against. The live-count term would buy nothing measurable, would need the live count +/// carried across calls to avoid counting it every time, and would deliberately let dead entries +/// accumulate to twice the live set before reclaiming any — which is the residue NFR-PERF-070 +/// cares about, kept for longer, in exchange for a saving nothing can observe. const REAP_FLOOR: usize = 64; impl ResourceCache { @@ -250,7 +261,8 @@ fn lock(m: &Mutex) -> MutexGuard<'_, T> { } /// Amortised reaping: only on a miss (which has just paid for a whole file parse, so an O(n) sweep -/// is invisible), and only once the map has grown past both a floor and twice its live count. +/// is invisible), and only once the map has grown past [`REAP_FLOOR`] — see that constant for why +/// the floor is the whole condition. fn maybe_reap(map: &mut HashMap>) where K: std::hash::Hash + Eq, @@ -296,6 +308,59 @@ mod tests { SampleRate::new(hz).unwrap() } + /// **Issue #110, the rule as it actually is.** Past [`REAP_FLOOR`] the next sweep removes + /// every dead entry, whatever the live count is — which is the assertion that tells that rule + /// apart from the `len > max(REAP_FLOOR, 2 * live)` one [`REAP_FLOOR`]'s comment used to + /// describe. With 65 entries live, *that* rule would not sweep until the map reached 130, so + /// the five dead entries below would still be there afterwards. + /// + /// Driven against [`maybe_reap`] directly rather than through 71 cached models: the divergence + /// the issue reports is wholly inside this function, it is generic over its map's types, and + /// the `.nam` parses a cache-level version needs cost ten seconds to assert the same thing. + #[test] + fn a_sweep_past_the_floor_removes_dead_entries_whatever_the_live_count() { + let live: Vec> = (0..=REAP_FLOOR).map(Arc::new).collect(); + let mut map: HashMap> = + live.iter().map(|a| (**a, Arc::downgrade(a))).collect(); + for i in 0..5 { + let doomed = Arc::new(1_000 + i); + map.insert(*doomed, Arc::downgrade(&doomed)); + // `doomed` dies here, leaving a `Weak` that still occupies its slot. + } + assert_eq!(map.len(), REAP_FLOOR + 6); + + maybe_reap(&mut map); + + assert_eq!( + map.len(), + REAP_FLOOR + 1, + "every dead entry must go, leaving exactly the live ones" + ); + assert!(map.values().all(|w| w.strong_count() > 0)); + drop(live); + } + + /// The floor's other side: at or below it nothing is swept, even a map that is entirely dead. + /// Reaping is amortised against a file parse, and a handful of `Weak`s is not worth a sweep — + /// [`ResourceCache::reap`] is the explicit hook for a caller that wants one anyway. + #[test] + fn a_sweep_at_the_floor_leaves_the_map_alone() { + let mut map: HashMap> = HashMap::new(); + for i in 0..REAP_FLOOR { + let doomed = Arc::new(i); + map.insert(i, Arc::downgrade(&doomed)); + } + assert_eq!(map.len(), REAP_FLOOR); + + maybe_reap(&mut map); + + assert_eq!( + map.len(), + REAP_FLOOR, + "at the floor the sweep does not run, so even dead entries stay" + ); + } + /// **FR-CLAP-090's core mechanism:** two loads of the same content share one copy of the /// weights, rather than each getting its own. #[test] diff --git a/crates/namir-worker/src/error_codes.rs b/crates/namir-worker/src/error_codes.rs index 0c062c9..0aba087 100644 --- a/crates/namir-worker/src/error_codes.rs +++ b/crates/namir-worker/src/error_codes.rs @@ -45,6 +45,22 @@ pub const FILE_TOO_LARGE: ErrorCode = ErrorCode::new( mistake -- the wrong file extension, or a recording saved in place of an export.", ); +/// The path names something that is not a regular file — a directory, a device, a FIFO. +/// +/// Issue #107's other half, and the one a byte ceiling cannot cover: on Unix, opening a FIFO +/// blocks until a writer appears, and a character device reports `len() == 0` while streaming +/// forever. Neither is answered by bounding the read, only by not opening the thing — so the file +/// *type* is checked before anything is opened, exactly as `namir-library`'s `StdFs::read_file` +/// does after the same fix. +pub const FILE_NOT_REGULAR: ErrorCode = ErrorCode::new( + "worker.file.not_regular", + Severity::Error, + "That path is not a regular file, so Namir will not load it ({detail}).", + "Point Namir at the `.nam` or `.wav` file itself. If a preset names this path, the file it \ + was saved against has been replaced by a folder or a device -- locate the original and load \ + it again.", +); + /// D-9.7 truncates an impulse response at ten seconds at the engine rate, and says so should be /// reported — but no catalogue entry existed for it anywhere, and `PreparedIr::was_truncated()` /// returned a bare `bool` that nothing consumed. The worker is the first layer that can report @@ -86,6 +102,7 @@ mod tests { JOB_PANICKED, FILE_UNREADABLE, FILE_TOO_LARGE, + FILE_NOT_REGULAR, IR_TRUNCATED, NOT_DELIVERED, ]; diff --git a/crates/namir-worker/src/lib.rs b/crates/namir-worker/src/lib.rs index 97eb438..8589e04 100644 --- a/crates/namir-worker/src/lib.rs +++ b/crates/namir-worker/src/lib.rs @@ -40,6 +40,7 @@ pub mod pool; pub mod recall; pub mod submit; +use std::io::Read; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -117,30 +118,75 @@ pub enum LoadSource { File(std::path::PathBuf), } +/// NFR-SEC-020's ceiling applied to one path, for every read this crate performs off disk — +/// [`LoadSource::File`] and [`recall`]'s candidate reads alike (issue #107). +/// +/// **One shape for both, not two.** `namir-library`'s `StdFs::read_file` reached this same shape +/// through its own issue #70, and the argument is not crate-specific: the file *type* is checked +/// before anything is opened, because no byte bound rescues a FIFO (`File::open` blocks until a +/// writer appears) or a character device (`len() == 0`, streams forever); and the byte bound is +/// then enforced **on the read** rather than on the `metadata()` call taken beforehand, because +/// stat-then-read is a time-of-check/time-of-use gap a file that grows in between walks straight +/// through. +/// +/// The length from `metadata` is kept, but only as the two things it can honestly be: a cheap +/// early rejection, so a 4 GB WAV is refused without being read up to the ceiling first, and a +/// capacity hint, so an ordinary load makes one allocation instead of growing through a dozen. +/// Being wrong about either costs a reallocation; neither can let a byte past the bound. +pub(crate) fn read_file_bounded(path: &std::path::Path) -> Result, WorkerError> { + let display = path.display().to_string(); + let meta = std::fs::metadata(path) + .map_err(|e| WorkerError::new(error_codes::FILE_UNREADABLE, format!("{display}: {e}")))?; + if !meta.is_file() { + return Err(WorkerError::new( + error_codes::FILE_NOT_REGULAR, + format!("{display}: not a regular file"), + )); + } + if meta.len() as usize > MAX_FILE_BYTES { + return Err(too_large(&display, meta.len())); + } + let file = std::fs::File::open(path) + .map_err(|e| WorkerError::new(error_codes::FILE_UNREADABLE, format!("{display}: {e}")))?; + read_bounded(file, &display, MAX_FILE_BYTES, meta.len() as usize) +} + +/// [`read_file_bounded`]'s second half, over a plain reader so the bound itself is testable +/// against an input no filesystem has to produce. `max_bytes` is a parameter for the same reason. +fn read_bounded( + reader: impl std::io::Read, + display: &str, + max_bytes: usize, + capacity_hint: usize, +) -> Result, WorkerError> { + let mut bytes = Vec::with_capacity(capacity_hint.min(max_bytes) + 1); + let mut limited = reader.take(max_bytes as u64 + 1); + limited + .read_to_end(&mut bytes) + .map_err(|e| WorkerError::new(error_codes::FILE_UNREADABLE, format!("{display}: {e}")))?; + if bytes.len() > max_bytes { + // One byte past the limit is the most that was ever in memory, and its presence is the + // proof the input was over it. + return Err(too_large(display, bytes.len() as u64)); + } + Ok(bytes) +} + +fn too_large(display: &str, len: u64) -> WorkerError { + WorkerError::new( + error_codes::FILE_TOO_LARGE, + format!( + "{display}: {len} bytes, limit {} MB", + MAX_FILE_BYTES / (1024 * 1024) + ), + ) +} + impl LoadSource { fn read(&self) -> Result, WorkerError> { match self { Self::Bytes(bytes) => Ok(Arc::clone(bytes)), - Self::File(path) => { - let display = path.display().to_string(); - let meta = std::fs::metadata(path).map_err(|e| { - WorkerError::new(error_codes::FILE_UNREADABLE, format!("{display}: {e}")) - })?; - if meta.len() as usize > MAX_FILE_BYTES { - return Err(WorkerError::new( - error_codes::FILE_TOO_LARGE, - format!( - "{display}: {} bytes, limit {} MB", - meta.len(), - MAX_FILE_BYTES / (1024 * 1024) - ), - )); - } - let bytes = std::fs::read(path).map_err(|e| { - WorkerError::new(error_codes::FILE_UNREADABLE, format!("{display}: {e}")) - })?; - Ok(Arc::from(bytes.into_boxed_slice())) - } + Self::File(path) => Ok(Arc::from(read_file_bounded(path)?.into_boxed_slice())), } } @@ -733,10 +779,13 @@ mod tests { /// /// **The oversized file is sparse.** `File::set_len` past the ceiling sets the size without /// writing 256 MB of anything, on every filesystem this project targets, and the branch under - /// test reads `std::fs::metadata(..).len()` and returns *before* `std::fs::read` — so the - /// bytes never have to exist for the check to be the real one. That is not a shortcut around - /// the test: it is the property NFR-SEC-020 asks for, which is precisely that an oversized - /// file is refused without being loaded. + /// test reads `std::fs::metadata(..).len()` and returns *before the file is opened at all* — + /// so the bytes never have to exist for the check to be the real one. That is not a shortcut + /// around the test: it is the property NFR-SEC-020 asks for, which is precisely that an + /// oversized file is refused without being loaded. Since issue #107 that early rejection is + /// an optimisation rather than the check — [`read_bounded`] enforces the ceiling on the read + /// itself, and `a_reader_that_never_ends_is_stopped_one_byte_past_the_ceiling` is what + /// asserts *that* half. // The `.nam`/IR disk-load kind of the four `namir-core`'s limits doc comment enumerates; the // other three are annotated at `namir-state`'s `document.rs`, `namir-ir`'s `wav.rs` and // `namir-library`'s `scan.rs`. @@ -783,6 +832,41 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// **Issue #107, the bound itself.** `read_bounded` stops one byte past the ceiling whatever + /// the input claims about its own length — which is the whole point of enforcing the limit on + /// the read rather than on a `metadata()` call taken beforehand. `io::repeat` is the input no + /// filesystem has to produce: it never ends, so the pre-fix shape (`std::fs::read`, i.e. an + /// unbounded `read_to_end`) would never return at all. + /// + /// A small `max_bytes` rather than the real ceiling, so the test costs sixteen bytes instead + /// of 256 MB; the parameter exists for exactly this. + #[test] + fn a_reader_that_never_ends_is_stopped_one_byte_past_the_ceiling() { + let err = read_bounded(std::io::repeat(0u8), "endless", 16, 0).unwrap_err(); + assert_eq!(err.code.id, error_codes::FILE_TOO_LARGE.id); + assert!(err.detail.contains("17 bytes"), "{}", err.detail); + + // The boundary either side of it: exactly the limit is accepted, one more is not. + let at_limit = read_bounded(std::io::repeat(0u8).take(16), "at-limit", 16, 0).unwrap(); + assert_eq!(at_limit.len(), 16); + let over = read_bounded(std::io::repeat(0u8).take(17), "over", 16, 0).unwrap_err(); + assert_eq!(over.code.id, error_codes::FILE_TOO_LARGE.id); + } + + /// Issue #107's type check: a path that is not a regular file is refused before it is opened, + /// through its own catalogue entry rather than as a generic read failure. A directory is the + /// case every platform this project targets can produce; the FIFO the check exists for cannot + /// be created here without platform code D-5.2 reserves for `namir-platform`. + #[test] + fn a_path_that_is_not_a_regular_file_is_refused_before_it_is_opened() { + let dir = + std::env::temp_dir().join(format!("namir-worker-not-regular-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let err = LoadSource::File(dir.clone()).read().unwrap_err(); + assert_eq!(err.code.id, error_codes::FILE_NOT_REGULAR.id); + std::fs::remove_dir_all(&dir).ok(); + } + /// **M6's non-blocking parameter path** (`namir-clap`'s `ui_host.rs`): a plain param change /// reaches the audio thread's chain through the ordinary command ring, exactly as one /// submitted through `Command::Param` directly would. diff --git a/crates/namir-worker/src/pool.rs b/crates/namir-worker/src/pool.rs index 26028c2..91e6032 100644 --- a/crates/namir-worker/src/pool.rs +++ b/crates/namir-worker/src/pool.rs @@ -88,6 +88,24 @@ struct Shared { pub struct ThreadPool { shared: Arc, threads: Mutex>>, + /// This pool's own worker threads, by id, fixed at construction. Read by exactly one decision: + /// whether a [`Self::shutdown`] caller that found the handle list already drained may *wait* + /// for the join to finish or must return at once — see that method's doc comment (issue #111). + worker_ids: Vec, + /// Set once every handle taken by a [`Self::shutdown`] caller has been joined, so a concurrent + /// caller can wait for that rather than returning on an empty list. + joined: (Mutex, Condvar), +} + +/// Publishes "the threads are joined" however the join loop ends, so a concurrent waiter is +/// released even if the loop unwinds rather than returning. +struct PublishJoined<'a>(&'a (Mutex, Condvar)); + +impl Drop for PublishJoined<'_> { + fn drop(&mut self) { + *lock(&self.0.0) = true; + self.0.1.notify_all(); + } } impl ThreadPool { @@ -107,7 +125,7 @@ impl ThreadPool { ready: Condvar::new(), shutdown: AtomicBool::new(false), }); - let threads = (0..threads.max(1)) + let threads: Vec<_> = (0..threads.max(1)) .map(|_| { let shared = Arc::clone(&shared); // Incremented on *this* thread, before the spawn, so the count is already exact by @@ -122,7 +140,9 @@ impl ThreadPool { .collect(); Self { shared, + worker_ids: threads.iter().map(|t| t.thread().id()).collect(), threads: Mutex::new(threads), + joined: (Mutex::new(false), Condvar::new()), } } @@ -174,6 +194,22 @@ impl ThreadPool { /// meant. Skipping detaches that one thread — it still observes the shutdown flag and exits on /// its own — which is a leak of one handle in a path that should not be reachable at all, and /// strictly better than wedging the thread that asked for the shutdown. + /// + /// # Two callers at once, which is not the same as a re-entrant one (issue #111) + /// + /// Draining the list is what makes the re-entrant case terminate, but "found an empty list" + /// covers two quite different callers, and returning at once is right for only one of them. + /// A **re-entrant** caller is a pool thread of *this* pool: whoever is joining is joining that + /// very thread, so waiting is a guaranteed deadlock, and it returns immediately. Any **other** + /// caller — a genuinely independent thread that simply lost the race to the handle list — is + /// entitled to the contract in the first paragraph, so it waits for the caller that took the + /// handles to finish joining them. That distinction is why the pool records its own threads' + /// ids: it is the only way to tell the two apart from inside `&self`. + /// + /// What the wait cannot cover, and no implementation could: when the *joining* caller is + /// itself a pool thread, its own handle was skipped, so "joined" means "every thread but the + /// re-entrant one". A waiter released by such a caller is told about one thread that is still + /// running — the same thread that, by construction, cannot be waited for by anyone. pub fn shutdown(&self) { { // Published under the queue lock, so `spawn`'s check of it cannot straddle the store. @@ -183,6 +219,12 @@ impl ThreadPool { self.shared.ready.notify_all(); let handles = std::mem::take(&mut *lock(&self.threads)); + if handles.is_empty() { + self.await_join(); + return; + } + + let _publish = PublishJoined(&self.joined); let current = std::thread::current().id(); for handle in handles { if handle.thread().id() == current { @@ -192,6 +234,25 @@ impl ThreadPool { } } + /// Waits for whichever [`Self::shutdown`] caller took the handle list to finish joining it — + /// unless this thread is one of the pool's own workers, in which case that caller is waiting + /// for *this* thread and returning at once is the only non-deadlocking answer. Also the path a + /// second, later `shutdown` (or the `Drop` after an explicit one) takes, where the flag is + /// already set and this returns immediately. + fn await_join(&self) { + if self.worker_ids.contains(&std::thread::current().id()) { + return; + } + let mut joined = lock(&self.joined.0); + while !*joined { + joined = self + .joined + .1 + .wait(joined) + .unwrap_or_else(PoisonError::into_inner); + } + } + /// Queued-but-not-yet-started jobs. Test observability. pub fn queued(&self) -> usize { lock(&self.shared.queue).len() @@ -446,6 +507,53 @@ mod tests { assert_eq!(captured.load(Ordering::SeqCst), 0, "and never have run"); } + /// **Issue #111.** Two *independent* threads calling `shutdown` at the same time: the one that + /// loses the race to the handle list must still not return until the threads are gone, because + /// that return is what `clap_plugin.destroy`'s caller reads as permission to unload the + /// library. Before the fix it found an empty list and returned at once, so the contract held + /// for exactly one of the two callers. + /// + /// The assertion is symmetric on purpose — whichever caller takes the handles does the + /// joining, and the test does not care which — so it does not depend on the two threads + /// interleaving in any particular order. + #[test] + fn a_second_concurrent_shutdown_also_waits_for_the_threads() { + let pool = Arc::new(ThreadPool::with_threads(2)); + let finished = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = mpsc::channel(); + + let job_finished = Arc::clone(&finished); + pool.spawn(move || { + started_tx.send(()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(300)); + job_finished.store(true, Ordering::Release); + }); + started_rx.recv().expect("the job must start"); + + let callers: Vec<_> = (0..2) + .map(|i| { + let pool = Arc::clone(&pool); + let finished = Arc::clone(&finished); + std::thread::spawn(move || { + // Staggered so the second caller arrives while the first is inside its join + // loop, which is the condition being tested. Both orders are asserted the + // same way, so a stagger that misses only makes the test weaker, never flaky. + std::thread::sleep(std::time::Duration::from_millis(50 * i)); + pool.shutdown(); + finished.load(Ordering::Acquire) + }) + }) + .collect(); + + for (i, caller) in callers.into_iter().enumerate() { + assert!( + caller.join().unwrap(), + "shutdown caller {i} returned while a job was still running" + ); + } + assert_eq!(pool.threads(), 0); + } + /// The self-join guard. A job that happens to hold the last reference to whatever owns the pool /// runs the pool's own `Drop` **on a pool thread**, so the drained handle list contains that /// thread's own handle. Joining it blocks forever on Windows; this asserts the drop returns. diff --git a/crates/namir-worker/src/recall.rs b/crates/namir-worker/src/recall.rs index 2f0a4f2..a156cf6 100644 --- a/crates/namir-worker/src/recall.rs +++ b/crates/namir-worker/src/recall.rs @@ -38,11 +38,13 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; use namir_engine::{Command, ParamChange, ParamId}; use namir_state::{Candidate, FileRef, FileResolver, MissingFile, State}; use crate::cache::ResourceCache; +use crate::submit::DEFAULT_DEADLINE; use crate::{Instance, JobOutcome, LoadSource, Target}; /// One resource slot's outcome within a [`RecallOutcome`]. @@ -72,11 +74,12 @@ pub struct RecallOutcome { pub nam: ResourceRecall, /// What happened to the Ir stage. pub ir: ResourceRecall, - /// How many parameter/global commands did not reach the audio thread within - /// `CommandSubmitter::submit`'s deadline. Ordinarily `0` — a nonzero count means the ring was - /// backed up for the whole submit deadline, itself worth surfacing even though this method - /// has no further retry to offer: D-7.2 hands the command back to the caller that submitted - /// it, and that caller is this method, mid-recall, with nowhere better to put it. + /// How many parameter/global commands did not reach the audio thread within the parameter + /// pass's shared deadline (issue #108: one `CommandSubmitter::submit` deadline for the whole + /// pass, not one per parameter). Ordinarily `0` — a nonzero count means the ring was backed up + /// for that whole deadline, itself worth surfacing even though this method has no further + /// retry to offer: D-7.2 hands the command back to the caller that submitted it, and that + /// caller is this method, mid-recall, with nowhere better to put it. pub commands_not_delivered: usize, } @@ -104,9 +107,17 @@ fn locate(reference: &FileRef, resolver: &dyn FileResolver) -> Result, M Candidate::ContentHash(hash) => resolver.resolve_by_hash(hash), }; let Some(path) = path else { continue }; - let Ok(bytes) = std::fs::read(&path) else { - // Existed per the resolver but couldn't be read (permissions, vanished between the - // resolver's own exists() check and this read) -- falls through, same as a miss. + // Issue #107: **the path is untrusted** -- it came out of a `.namirpreset`'s + // `FileRef.absolute`, which is why `namir-state` has a fuzz target at all -- so this read + // is bounded exactly as every other read in the workspace is, through + // [`crate::read_file_bounded`]. A preset naming a multi-gigabyte file, a directory, or a + // device is a *miss*, not an allocation: over-limit, not-a-regular-file and plain + // unreadable all fall through to the next candidate, since none of them can be the file + // whose content hash this reference records. + let Ok(bytes) = crate::read_file_bounded(&path) else { + // Unreadable (permissions, vanished between the resolver's own exists() check and this + // read), not a regular file, or over NFR-SEC-020's ceiling -- falls through, same as a + // miss. continue; }; if namir_core::ContentHash::of(&bytes) == reference.hash { @@ -142,12 +153,30 @@ impl Instance { // D-10.4: `global.bypass`/`global.output_ceiling_db` are ordinary `REGISTRY` entries now, // so `state.params.iter()` below already carries them -- there is no longer a dedicated // `Command::SetGlobalBypass`/`SetOutputCeilingDb` to submit separately first. + // + // Issue #108: **one deadline for the whole pass, not one per parameter.** `submit`'s + // deadline is what stops a host that deactivated a plugin from wedging a pool thread + // (`submit.rs`'s own rationale), and calling it once per `REGISTRY` entry multiplied that + // bound by the registry's size -- roughly a minute for one recall, and with D-7.1's + // two-thread pool, two such recalls wedge the entire worker. So the pass carries a single + // budget: whatever is left of it bounds the next parameter's wait, and once it is spent + // the remaining parameters take one non-blocking attempt each. They are still *attempted*, + // and every miss is still counted -- a spent budget means the ring has not moved for two + // seconds, so a parameter that lands after it does so because the audio thread came back, + // not because this loop waited again. + let budget_ends = Instant::now() + DEFAULT_DEADLINE; for (descriptor, value) in state.params.iter() { - let change = ParamChange { + let command = Command::Param(ParamChange { id: ParamId(descriptor.id.0), value, + }); + let remaining = budget_ends.saturating_duration_since(Instant::now()); + let submitted = if remaining.is_zero() { + self.submitter.try_submit(command) + } else { + self.submitter.submit_with_deadline(command, remaining) }; - if self.submitter.submit(Command::Param(change)).is_err() { + if submitted.is_err() { commands_not_delivered += 1; } } @@ -394,6 +423,163 @@ mod tests { } } + /// **Issue #108.** The parameter pass calls the *blocking* `submit`, once per `REGISTRY` + /// entry. Each call bounds itself at [`crate::submit::DEFAULT_DEADLINE`] — but the bound was + /// per call, so against a ring nothing drains, one recall paid it thirty-odd times over and + /// held a pool thread for a minute. With D-7.1's two-thread pool, two such recalls wedge the + /// whole worker; the deadline exists precisely so that cannot happen. + /// + /// The ring here is deliberately filled and never drained — the "host deactivated the plugin" + /// condition `submit.rs`'s own rationale is written against. Every parameter therefore fails, + /// which is what makes the count assertion below exact: nothing is dropped silently, all of + /// them are reported. + /// + /// The bound asserted is *one* deadline for the whole parameter pass, plus the two resource + /// submits that follow it — those go through [`Instance::unload`] and keep their own deadline, + /// which is R4's rule and is a constant two, not a multiple of the registry's size. + #[test] + fn a_recall_against_a_backed_up_ring_pays_the_parameter_deadline_once() { + let c = ctx(); + // The engine is kept alive on purpose: a dropped consumer is an *abandoned* ring, which is + // reported at once and would hide the very wait this test measures. + let (_engine, endpoint) = build_default_engine(&c).unwrap(); + let cache = ResourceCache::new(); + let mut instance = Instance::new(EngineConfig { ctx: c }, endpoint); + let resolver = FakeResolver::default(); + + // Fill the command ring (default capacity 256) with nothing draining it. + for i in 0..300u32 { + let _ = instance.try_submit_param(namir_engine::ParamChange { + id: namir_engine::ParamId(i), + value: 0.0, + }); + } + + let state = namir_state::State::defaults(); + let params = state.params.iter().count(); + assert!( + params > 4, + "this test needs a registry big enough for the multiplication to show, got {params}" + ); + + let started = std::time::Instant::now(); + let outcome = instance.recall(&cache, &state, &resolver); + let elapsed = started.elapsed(); + + assert_eq!( + outcome.commands_not_delivered, params, + "every parameter missed a ring nothing drains, and each miss must still be counted" + ); + assert!( + elapsed < 4 * crate::submit::DEFAULT_DEADLINE, + "a recall against a backed-up ring took {elapsed:?} for {params} parameters -- the \ + submit deadline is being paid per parameter rather than once for the pass" + ); + } + + /// **Issue #107.** A preset is untrusted input, so the candidate path it names is too: before + /// the fix `locate` ran a bare `std::fs::read` on it, and a reference naming a multi-gigabyte + /// file made a worker thread allocate the whole thing before the hash even failed to match. + /// + /// **The oversized file is sparse** — `File::set_len` sets the size without writing the bytes, + /// the same device `lib.rs`'s `a_file_over_the_ceiling_is_refused_before_its_bytes_are_read` + /// uses, and for the same reason: the bytes never have to exist for the refusal to be the real + /// one. Which is also what makes the timing assertion below meaningful rather than a + /// benchmark. The pre-fix path really did read the whole 2 GB of zeros here, taking seconds + /// and the memory to match; the fixed path rejects it on its length and never opens it, in + /// microseconds. The outcome alone cannot tell the two apart — an oversized file whose hash + /// cannot match is `Missing` either way — so the assertion that distinguishes them is that the + /// miss was reached without reading the file. + #[test] + fn an_oversized_candidate_is_a_miss_without_reading_the_file() { + let c = ctx(); + let (_engine, endpoint) = build_default_engine(&c).unwrap(); + let cache = ResourceCache::new(); + let mut instance = Instance::new(EngineConfig { ctx: c }, endpoint); + + let dir = + std::env::temp_dir().join(format!("namir-worker-issue-107-{}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hostile.nam"); + let oversized = namir_core::MAX_FILE_BYTES as u64 * 8; + let file = std::fs::File::create(&path).unwrap(); + file.set_len(oversized).unwrap(); + drop(file); + assert_eq!( + std::fs::metadata(&path).unwrap().len(), + oversized, + "the scratch filesystem did not honour the requested length, so this test would \ + otherwise pass for the wrong reason" + ); + + let mut resolver = FakeResolver::default(); + resolver + .by_absolute + .insert(path.to_string_lossy().into_owned(), path.clone()); + + let mut state = namir_state::State::defaults(); + state.nam = Some(a_reference( + "hostile.nam", + ContentHash::of(b"whatever the preset claims"), + path, + )); + + let started = std::time::Instant::now(); + let outcome = instance.recall(&cache, &state, &resolver); + let elapsed = started.elapsed(); + + assert!( + matches!(outcome.nam, ResourceRecall::Missing { .. }), + "an over-ceiling candidate must be a miss, got {:?}", + outcome.nam + ); + assert!( + elapsed < Duration::from_secs(1), + "the recall took {elapsed:?} -- an over-ceiling candidate is being read into memory \ + rather than refused on its length" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// Issue #107's other half: a candidate that is not a regular file is refused before it is + /// opened. A directory is the case a test can create on every platform this project targets; + /// the case that motivates the check is a FIFO, whose `File::open` blocks until a writer + /// appears and which no byte bound can rescue. + #[test] + fn a_candidate_that_is_not_a_regular_file_is_a_miss() { + let c = ctx(); + let (_engine, endpoint) = build_default_engine(&c).unwrap(); + let cache = ResourceCache::new(); + let mut instance = Instance::new(EngineConfig { ctx: c }, endpoint); + + let dir = + std::env::temp_dir().join(format!("namir-worker-issue-107-dir-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let mut resolver = FakeResolver::default(); + resolver + .by_absolute + .insert(dir.to_string_lossy().into_owned(), dir.clone()); + + let mut state = namir_state::State::defaults(); + state.nam = Some(a_reference( + "a-directory.nam", + ContentHash::of(b"not what lives there"), + dir.clone(), + )); + + let outcome = instance.recall(&cache, &state, &resolver); + assert!( + matches!(outcome.nam, ResourceRecall::Missing { .. }), + "a directory candidate must be a miss, got {:?}", + outcome.nam + ); + + std::fs::remove_dir_all(&dir).ok(); + } + /// P7's "identity is the content hash, paths are hints", exercised through a real recall: a /// path that resolves but whose *content* has changed since the state was saved must not be /// treated as a hit. diff --git a/crates/namir-worker/src/submit.rs b/crates/namir-worker/src/submit.rs index 33210ff..44b6792 100644 --- a/crates/namir-worker/src/submit.rs +++ b/crates/namir-worker/src/submit.rs @@ -27,7 +27,7 @@ //! sub-block granularity is what a retry needs, and sleeping rather than spinning avoids burning a //! core the audio thread may want. -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::sync::{Mutex, MutexGuard, PoisonError, TryLockError}; use std::time::{Duration, Instant}; use namir_engine::{Command, RingProducer}; @@ -45,7 +45,11 @@ pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(2); /// Why a submission did not land. **Carries the command back** in every case — nothing is dropped /// inside `submit`. pub enum SubmitError { - /// The ring was not drained within the deadline. + /// The command did not land within the attempt's deadline. For [`CommandSubmitter::submit`] + /// that means the audio thread did not drain the ring in time; for + /// [`CommandSubmitter::try_submit`], whose deadline is zero, it also covers a producer another + /// submitter held at that instant (issue #106). Both are "not now, try again", which is why + /// they share one variant. Timeout(Command), /// The audio side is gone (its consumer was dropped), so nothing will ever drain. Abandoned(Command), @@ -78,8 +82,26 @@ impl CommandSubmitter { /// One attempt, never blocks. **This is what the UI thread uses** — D-15.3 says the UI never /// blocks on the worker, and a parameter change that misses one block is not worth stalling a /// frame for. + /// + /// # Why the mutex is *tried*, not taken (issue #106) + /// + /// There are two ways this can fail to land, and only one of them is the ring. The other is + /// [`Self::submit_with_deadline`], which holds this same mutex across its entire wait: a + /// worker mid-`Instance::load` against a host that has deactivated the plugin holds it for + /// [`DEFAULT_DEADLINE`]. A plain `lock()` here would therefore park the GUI thread for up to + /// two seconds inside a call documented never to block — the exact D-15.3 / FR-UI-060 + /// violation `namir-clap`'s `audio.rs` reasons about for the audio thread, arriving on the + /// thread that draws frames. + /// + /// So contention is treated as the momentary miss it is: the command comes back as + /// [`SubmitError::Timeout`], the same outcome and the same caller response (retry on a later + /// frame) as a ring that happened to be full. Nothing is dropped, and the promise the callers + /// in `namir-clap/src/ui_host.rs` and `namir-app/src/host.rs` cite is one this method keeps + /// against a contended producer as well as a full ring. pub fn try_submit(&self, command: Command) -> Result<(), SubmitError> { - let mut producer = self.lock(); + let Some(mut producer) = self.try_lock() else { + return Err(SubmitError::Timeout(command)); + }; if producer.is_abandoned() { return Err(SubmitError::Abandoned(command)); } @@ -98,7 +120,8 @@ impl CommandSubmitter { /// instant. Two worker threads submitting to a full ring therefore form a bounded convoy: one /// sleeps against the ring, the other against the mutex. That is acceptable because submitters /// are per-instance (unrelated instances never contend), the wait is deadline-bounded, and it - /// sleeps rather than spins. + /// sleeps rather than spins. [`Self::try_submit`] is deliberately **not** part of that convoy + /// — see its own doc comment for why the caller it serves may not join one. /// /// **The one hard rule for callers: never hold the resource cache's lock across this call.** /// A full ring on one instance would otherwise stall every other instance's cache lookup, which @@ -146,6 +169,17 @@ impl CommandSubmitter { fn lock(&self) -> MutexGuard<'_, RingProducer> { self.producer.lock().unwrap_or_else(PoisonError::into_inner) } + + /// [`Self::lock`]'s non-blocking form, for [`Self::try_submit`]. A poisoned-but-free mutex is + /// recovered exactly as `lock` recovers it, for the same reason; `WouldBlock` — another + /// submitter holding it — is the only case that yields `None`. + fn try_lock(&self) -> Option>> { + match self.producer.try_lock() { + Ok(guard) => Some(guard), + Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()), + Err(TryLockError::WouldBlock) => None, + } + } } #[cfg(test)] @@ -212,6 +246,57 @@ mod tests { assert!(started.elapsed() >= Duration::from_millis(50)); } + /// **Issue #106.** `try_submit`'s "one attempt, never blocks" is a promise made to the *GUI* + /// thread (D-15.3, FR-UI-060), and a full ring is only one of the two ways it can fail to + /// land: the other is a worker thread already inside [`CommandSubmitter::submit`], holding the + /// producer mutex across its whole deadline. Before the fix this call waited on that mutex for + /// up to `DEFAULT_DEADLINE`, so the one caller forbidden to block was the one that blocked + /// longest. + #[test] + fn try_submit_does_not_wait_for_a_worker_already_inside_the_deadline() { + let (tx, rx) = ring::(1); + let submitter = std::sync::Arc::new(CommandSubmitter::new(tx)); + submitter.try_submit(param(1)).expect("first fits"); // the ring is now full + + let entered = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker = { + let submitter = std::sync::Arc::clone(&submitter); + let entered = std::sync::Arc::clone(&entered); + std::thread::spawn(move || { + entered.store(true, std::sync::atomic::Ordering::Release); + // Deliberately the full default deadline: the pre-fix failure is that the call + // below waits out *this* thread's deadline, so a short one would hide it. + submitter.submit_with_deadline(param(2), DEFAULT_DEADLINE) + }) + }; + while !entered.load(std::sync::atomic::Ordering::Acquire) { + std::thread::yield_now(); + } + // The flag is set just before the lock is taken, so settle briefly to make the contended + // case the one actually measured. Overshooting only costs a false pass, never a false + // failure. + std::thread::sleep(Duration::from_millis(100)); + + let started = Instant::now(); + let result = submitter.try_submit(param(3)); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_millis(250), + "try_submit waited {elapsed:?} on a contended producer -- it is documented never to \ + block, and the UI thread calls it" + ); + assert!( + matches!(result, Err(SubmitError::Timeout(_))), + "a contended producer must hand the command back rather than dropping it" + ); + + // Release the worker rather than waiting out its deadline: an abandoned ring is reported + // at once, so this costs one retry interval instead of two seconds. + drop(rx); + let _ = worker.join().unwrap(); + } + /// If the audio side is gone entirely, say so distinctly rather than waiting out the deadline. #[test] fn an_abandoned_ring_is_reported_immediately() { From 63afed5bdacf879a48beb473eec9153696cf60f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:13:00 +0000 Subject: [PATCH 23/44] Save and recall a preset from the interface (#100, #84-#98) namir-ui gains SavePreset/RecallPreset and both shells implement them, so these land together -- the seam does not compile split. namir-ui hands the host a name to place and offers back only paths the host listed: D-5.1 forbids it depending on namir-platform, so it cannot know where presets live, and a file dialog belongs in the shell where NFR-PORT-030's no-blocking-dialog clause applies. FileRefs are constructed rather than deferred. With record_reference disabled the new test panics because the preset had forgotten its IR -- a save button that silently loses the user's model and IR is worse than no save button. #84 and #87 were already fixed by 608fdde, 28 minutes after being filed. But #87's detector was blind: its harness warm-up drove the oversized callback pair before arming assert_no_alloc, pre-growing mono_scratch, so re-planting the unchunked extend still passed. Warm-up cut to the exact-size pair; the planted defect now fails. #88 moves an inline 160-byte Copy payload through a pre-allocated ring and formats on the UI thread. #90 was 452 allocations in one telemetry drain. #91 failed 6 of 6 runs against a writer swapping state. #86 intersects both devices pairwise instead of applying the input's answer to the output. #93's existing test had to change: it asserted latency == 0 after reactivation, which encoded the bug. #94 reports GUI parameter changes as gesture-wrapped events. Residual, stated rather than hidden: the GUI cannot request a flush at the moment a knob moves -- open_parented needs H: 'static and every host handle is 'a-bound -- so while inactive the change waits in the mirror. Never lost. #98 reports the refusal rather than becoming resizable, but the refusal cannot reach a host: clack-extensions 0.1.1 wraps the plugin's whole Result as the closure's success value, so Err becomes true at the C ABI. Its three sibling functions do this correctly, and the version is pinned. A second clack defect found: CoreEventSpace::from_unknown has no arms for the two gesture ids, so it drops well-formed events its own enum has variants for. #97 is a refactor of two byte-for-byte equivalent implementations, so no test can be red before and green after. Saying so rather than inventing one. Both shells now carry an identical presets.rs by deliberate agreement, each warning it belongs in namir-platform. Hoisting it next. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-app/examples/list-devices.rs | 95 --- crates/namir-app/examples/list_devices.rs | 205 +++-- crates/namir-app/src/app.rs | 209 ++++- crates/namir-app/src/audio_io.rs | 268 +++++- crates/namir-app/src/audio_io/convert.rs | 3 +- crates/namir-app/src/device_state.rs | 339 ++++++++ crates/namir-app/src/host.rs | 775 +++++++++++++++++- crates/namir-app/src/lib.rs | 5 + crates/namir-app/src/presets.rs | 202 +++++ crates/namir-app/src/stream.rs | 397 ++++++++- crates/namir-app/src/worker.rs | 157 +++- crates/namir-clap/src/audio.rs | 79 +- crates/namir-clap/src/error_codes.rs | 26 + crates/namir-clap/src/gui.rs | 92 ++- crates/namir-clap/src/lib.rs | 3 + crates/namir-clap/src/main_thread.rs | 73 +- crates/namir-clap/src/param_mirror.rs | 151 +++- crates/namir-clap/src/params_ext.rs | 223 ++++- crates/namir-clap/src/presets.rs | 181 ++++ crates/namir-clap/src/shared.rs | 412 +++++++++- crates/namir-clap/src/state_ext.rs | 28 +- crates/namir-clap/src/ui_host.rs | 213 ++++- crates/namir-clap/src/worker_jobs.rs | 206 ++++- crates/namir-clap/tests/clap_host_gui.rs | 71 ++ crates/namir-clap/tests/clap_host_latency.rs | 142 +++- .../tests/fr_cfg_020_shell_parity.rs | 15 +- crates/namir-clap/tests/support/mod.rs | 11 +- crates/namir-ui/src/app.rs | 409 ++++++++- crates/namir-ui/src/host.rs | 50 +- crates/namir-ui/src/lib.rs | 14 +- crates/namir-ui/src/library_view.rs | 107 ++- crates/namir-ui/src/meter.rs | 104 ++- 32 files changed, 4827 insertions(+), 438 deletions(-) delete mode 100644 crates/namir-app/examples/list-devices.rs create mode 100644 crates/namir-app/src/presets.rs create mode 100644 crates/namir-clap/src/presets.rs diff --git a/crates/namir-app/examples/list-devices.rs b/crates/namir-app/examples/list-devices.rs deleted file mode 100644 index cbda194..0000000 --- a/crates/namir-app/examples/list-devices.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Prints every audio device this build can see, under the name `audio-settings.json` must use, -//! and asks each one whether it would grant exclusive mode. -//! -//! Written during FR-UI-070's manual run, where two steps need a device *name* and one needs a -//! device that **refuses** exclusive mode: -//! -//! - `docs/manual-tests/fr-ui-070-non-modal-error-notices.md` step 11 sets `input_device_name` to -//! a device that does not exist, and step 12 sets `"exclusive_mode": true` against one that -//! cannot grant it. -//! - `docs/manual-tests/fr-io-010-device-enumeration.md` and `fr-io-020-wasapi-exclusive-mode.md` -//! ask the same two questions of a person at a machine. -//! -//! The names matter more than they look: `audio-settings.json` is matched against the *endpoint* -//! name the backend reports, which on Windows is localised and is usually not the product name on -//! the box — `Ligne (AudioBox 22VSL)`, not `AudioBox 22VSL`. A near-miss is not a warning, it is -//! `app.audio_io.remembered_device_unavailable` and a silent fallback to another device. -//! -//! ```text -//! cargo run -p namir-app --example list-devices [-- ] -//! ``` -//! -//! The exclusive-mode probe is asked at `` (default 48000) with the backend's own -//! default buffer size and two channels, which is what `namir-app` itself would ask at those -//! settings. A device answering `shared-only` there is the one step 12 wants. - -use namir_app::audio_io::{ - AudioBackend, CpalBackend, DeviceInfo, ExclusiveModeOutcome, HostInfo, ShareMode, StreamParams, -}; - -fn main() { - let rate: u32 = std::env::args() - .nth(1) - .and_then(|a| a.parse().ok()) - .unwrap_or(48_000); - - let backend = CpalBackend; - let default_host = backend.default_host(); - println!("default host: {}\n", default_host.name); - - for host in backend.hosts() { - let marker = if host.name == default_host.name { - " (default)" - } else { - "" - }; - println!("host {}{marker}", host.name); - - match backend.input_devices(&host) { - Ok(devices) => report(&backend, &host, "input", &devices, rate), - Err(e) => println!(" input devices unavailable: {e}"), - } - match backend.output_devices(&host) { - Ok(devices) => report(&backend, &host, "output", &devices, rate), - Err(e) => println!(" output devices unavailable: {e}"), - } - println!(); - } - - println!( - "Copy a name verbatim, quotes excluded, into audio-settings.json's input_device_name or \ - output_device_name." - ); -} - -fn report( - backend: &CpalBackend, - host: &HostInfo, - direction: &str, - devices: &[DeviceInfo], - rate: u32, -) { - if devices.is_empty() { - println!(" no {direction} devices"); - return; - } - println!(" {direction}:"); - for device in devices { - let params = StreamParams { - sample_rate_hz: rate, - buffer_frames: None, - channels: 2, - // Ignored by the probe — the question *is* whether exclusive mode is possible. - share_mode: ShareMode::Exclusive, - }; - let exclusive = match backend.supports_exclusive(host, device, params) { - ExclusiveModeOutcome::Engaged => "exclusive ok", - ExclusiveModeOutcome::Unsupported => "shared-only", - }; - let default = if device.is_default { " [default]" } else { "" }; - println!( - " \"{}\"{default} -- {exclusive} at {rate} Hz", - device.name - ); - } -} diff --git a/crates/namir-app/examples/list_devices.rs b/crates/namir-app/examples/list_devices.rs index 23ec10a..eb6bc7a 100644 --- a/crates/namir-app/examples/list_devices.rs +++ b/crates/namir-app/examples/list_devices.rs @@ -1,89 +1,172 @@ //! FR-IO-010/040 manual-verification aid: enumerates every host, every input/output device under -//! it, and every f32 configuration each device reports, using the real `cpal`-backed -//! [`namir_app::audio_io::CpalBackend`] — not a fake. Run with `cargo run --example list_devices -p -//! namir-app`. No window, no audio processing; this only exercises device enumeration +//! it, and asks each device whether WASAPI **exclusive** mode is available — using the real +//! `cpal`-backed [`namir_app::audio_io::CpalBackend`], not a fake. No window, no audio processing; +//! this only exercises device enumeration //! ([`namir_app::audio_io::AudioBackend::hosts`]/`input_devices`/`output_devices`/`input_configs`/ -//! `output_configs`), which is real device I/O this crate's own automated tests cannot exercise -//! (see `docs/manual-tests/fr-io-010-device-enumeration.md`). +//! `output_configs`) and [`namir_app::audio_io::AudioBackend::supports_exclusive`], which is real +//! device I/O this crate's own automated tests cannot exercise (see +//! `docs/manual-tests/fr-io-010-device-enumeration.md`). //! -//! M11 added a second job: for each device it also asks -//! [`namir_app::audio_io::AudioBackend::supports_exclusive`] whether WASAPI **exclusive** mode is -//! available, at every channel count the device reported and a sweep of common sample rates. Run -//! this on the reference machine before `docs/manual-tests/fr-io-020-wasapi-exclusive-mode.md`: -//! it predicts what that script will find, without opening a stream, and distinguishes "this -//! device cannot do exclusive mode" from "not at the rate we picked". See -//! [`print_exclusive_support`] for how to read the output. +//! ```text +//! cargo run -p namir-app --example list_devices [-- [--verbose] []] +//! ``` +//! +//! * **Default** — one line per device: the endpoint name, whether it is the host default, and +//! whether it would grant exclusive mode at `` (48000 unless given). This is the +//! form the FR-UI-070 and FR-IO-020 scripts want, because both need a device *name* and one +//! needs a device that **refuses** exclusive mode. +//! * **`--verbose`** — additionally prints every configuration the device reports and sweeps +//! [`PROBE_RATES_HZ`] for exclusive-mode support at every channel count reported. Run this on +//! the reference machine before `docs/manual-tests/fr-io-020-wasapi-exclusive-mode.md`: it +//! predicts what that script will find without opening a stream, and distinguishes "this device +//! cannot do exclusive mode" from "not at the rate we picked". See [`print_exclusive_sweep`] for +//! how to read it. +//! +//! # Why the names matter more than they look +//! +//! `audio-settings.json` is matched against the *endpoint* name the backend reports, which on +//! Windows is localised and is usually not the product name on the box — `Ligne (AudioBox 22VSL)`, +//! not `AudioBox 22VSL`. A near-miss is not a warning, it is +//! `app.audio_io.remembered_device_unavailable` and a silent fallback to another device. So copy a +//! name from this output verbatim, quotes excluded, into `input_device_name`/`output_device_name`. +//! +//! # One example, not two (issue #92) +//! +//! There used to be a second, near-duplicate `list-devices.rs` beside this one: same backend, same +//! enumeration, same `supports_exclusive` probe, differing only in output detail and in which +//! manual-test script it happened to be written for. Cargo built both, and this crate has already +//! paid once for two copies of one computation drifting apart (`LibraryService`'s bootstrap — see +//! `namir_worker::library::LibraryService::open_default`). The two output shapes are now the two +//! settings of `--verbose`, and the hyphenated file is gone. use namir_app::audio_io::{ AudioBackend, BufferSizeRange, CpalBackend, DeviceInfo, ExclusiveModeOutcome, HostInfo, ShareMode, StreamParams, SupportedConfigRange, }; -/// Rates to ask each device about in exclusive mode. Exclusive mode negotiates against the +/// Rates to ask each device about under `--verbose`. Exclusive mode negotiates against the /// device's own native format rather than the engine's mix format, so a device can perfectly well /// support exclusive mode at one rate and refuse it at another -- and Namir settles its rate from /// the *shared*-mode config set before the share mode is negotiated. Sweeping tells you whether a /// refusal is "this device cannot do exclusive mode" or "not at the rate we happened to pick". const PROBE_RATES_HZ: [u32; 4] = [44_100, 48_000, 88_200, 96_000]; +/// The rate the concise listing probes at, unless one is given on the command line. What +/// `namir-app` itself would ask for on a device offering it. +const DEFAULT_PROBE_RATE_HZ: u32 = 48_000; + fn main() { + let mut verbose = false; + let mut rate = DEFAULT_PROBE_RATE_HZ; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--verbose" | "-v" => verbose = true, + other => match other.parse::() { + Ok(hz) => rate = hz, + Err(_) => { + eprintln!( + "usage: cargo run -p namir-app --example list_devices \ + [-- [--verbose] []]" + ); + return; + } + }, + } + } + let backend = CpalBackend::new(); let hosts = backend.hosts(); + let default_host = backend.default_host(); + println!("default host: {}", default_host.name); println!( - "hosts ({}): {:?}", + "hosts ({}): {:?}\n", hosts.len(), hosts.iter().map(|h| &h.name).collect::>() ); - println!("default host: {:?}", backend.default_host().name); for host in &hosts { - println!("\n== host: {} ==", host.name); + let marker = if host.name == default_host.name { + " (default)" + } else { + "" + }; + println!("== host: {}{marker} ==", host.name); + match backend.input_devices(host) { - Ok(devices) => { - println!(" input devices ({}):", devices.len()); - for device in &devices { - println!(" - {} (default: {})", device.name, device.is_default); - match backend.input_configs(host, device) { - Ok(configs) => { - for c in &configs { - print_config(c); - } - print_exclusive_support(&backend, host, device, &configs); - } - Err(e) => println!(" configs error: {e}"), - } - } - } - Err(e) => println!(" input_devices error: {e}"), + Ok(devices) => report(&backend, host, "input", &devices, rate, verbose), + Err(e) => println!(" input devices unavailable: {e}"), } - match backend.output_devices(host) { - Ok(devices) => { - println!(" output devices ({}):", devices.len()); - for device in &devices { - println!(" - {} (default: {})", device.name, device.is_default); - match backend.output_configs(host, device) { - Ok(configs) => { - for c in &configs { - print_config(c); - } - print_exclusive_support(&backend, host, device, &configs); - } - Err(e) => println!(" configs error: {e}"), - } + Ok(devices) => report(&backend, host, "output", &devices, rate, verbose), + Err(e) => println!(" output devices unavailable: {e}"), + } + println!(); + } + + println!( + "Copy a name verbatim, quotes excluded, into audio-settings.json's input_device_name or \ + output_device_name." + ); +} + +/// One direction of one host. +fn report( + backend: &CpalBackend, + host: &HostInfo, + direction: &str, + devices: &[DeviceInfo], + rate: u32, + verbose: bool, +) { + if devices.is_empty() { + println!(" no {direction} devices"); + return; + } + println!(" {direction} devices ({}):", devices.len()); + for device in devices { + let default = if device.is_default { " [default]" } else { "" }; + let exclusive = match backend.supports_exclusive(host, device, probe_params(rate, 2)) { + ExclusiveModeOutcome::Engaged => "exclusive ok", + ExclusiveModeOutcome::Unsupported => "shared-only", + }; + println!( + " \"{}\"{default} -- {exclusive} at {rate} Hz", + device.name + ); + + if !verbose { + continue; + } + let configs = match direction { + "input" => backend.input_configs(host, device), + _ => backend.output_configs(host, device), + }; + match configs { + Ok(configs) => { + for c in &configs { + print_config(c); } + print_exclusive_sweep(backend, host, device, &configs); } - Err(e) => println!(" output_devices error: {e}"), + Err(e) => println!(" configs error: {e}"), } } } -/// FR-IO-020: ask the device itself whether exclusive mode is available, at every channel count it -/// reported and a sweep of common rates. -/// -/// This is the check to run on the reference machine *before* executing -/// `docs/manual-tests/fr-io-020-wasapi-exclusive-mode.md`, because it predicts what that script -/// will find without opening a stream. Three outcomes are worth telling apart: +/// What the probe asks with: the backend's own default buffer size, and `ShareMode::Exclusive` +/// (ignored by the probe -- the question *is* whether exclusive mode is possible). +fn probe_params(sample_rate_hz: u32, channels: u16) -> StreamParams { + StreamParams { + sample_rate_hz, + buffer_frames: None, + channels, + share_mode: ShareMode::Exclusive, + } +} + +/// FR-IO-020, `--verbose` only: ask the device itself whether exclusive mode is available, at every +/// channel count it reported and a sweep of [`PROBE_RATES_HZ`]. Three outcomes are worth telling +/// apart: /// /// * `engaged` at the rate the app would pick -- exclusive mode will be used. /// * `unsupported` everywhere -- the endpoint offers no format Namir can feed it. Namir accepts @@ -93,7 +176,7 @@ fn main() { /// * `engaged` at some rates only -- the device does exclusive mode, but not at the rate the /// shared-mode negotiation settled on. That is a known limitation, recorded on /// `AudioBackend::supports_exclusive`. -fn print_exclusive_support( +fn print_exclusive_sweep( backend: &CpalBackend, host: &HostInfo, device: &DeviceInfo, @@ -112,16 +195,8 @@ fn print_exclusive_support( .iter() .copied() .filter(|&sample_rate_hz| { - backend.supports_exclusive( - host, - device, - StreamParams { - sample_rate_hz, - buffer_frames: None, - channels, - share_mode: ShareMode::Exclusive, - }, - ) == ExclusiveModeOutcome::Engaged + backend.supports_exclusive(host, device, probe_params(sample_rate_hz, channels)) + == ExclusiveModeOutcome::Engaged }) .collect(); if engaged.is_empty() { @@ -132,7 +207,7 @@ fn print_exclusive_support( } } -fn print_config(c: &namir_app::audio_io::SupportedConfigRange) { +fn print_config(c: &SupportedConfigRange) { let buf = match c.buffer_size { BufferSizeRange::Range { min, max } => format!("{min}..={max} frames"), BufferSizeRange::Unknown => "unknown".to_string(), diff --git a/crates/namir-app/src/app.rs b/crates/namir-app/src/app.rs index aab7a95..decb857 100644 --- a/crates/namir-app/src/app.rs +++ b/crates/namir-app/src/app.rs @@ -29,14 +29,14 @@ use namir_worker::{EngineConfig, Instance, ResourceCache}; use crate::audio_io::{ AudioBackend, AudioIoError, CpalBackend, DeviceInfo, ExclusiveModeOutcome, HostInfo, ShareMode, - StreamParams, + StreamFailure, StreamParams, }; use crate::host::AppHost; use crate::instance::SharedInstance; use crate::settings::{self, AppSettings}; use crate::startup_probe; use crate::stream::{self, StreamSetup}; -use crate::worker::{AppEvent, WorkerContext, WorkerHandle}; +use crate::worker::{WorkerContext, WorkerHandle}; use crate::xrun::XrunCounter; /// Falls back to a working default if [`namir_platform::config_dir`] returns `None` (an @@ -171,6 +171,50 @@ fn negotiate_share_mode( } } +/// How many stream failures each direction's ring holds before it starts dropping them. +/// +/// Sized for "several reports arriving between two GUI frames", not for a backlog: a stream that +/// is failing repeatedly needs one notice, not sixteen, and [`crate::host::AppHost`] drains this +/// every frame. Small enough that both rings together are a few kilobytes allocated once, at +/// stream open, and never again. +const STREAM_FAILURE_RING_SLOTS: usize = 16; + +/// Builds one direction's `cpal` error callback (FR-IO-070), and the reason it is a function with +/// its own tests rather than a closure inlined into [`run`]. +/// +/// # What it must not do, and used to (issue #88) +/// +/// `cpal` invokes an error callback on the stream's **own** thread — `crate::worker`'s +/// `AppEvent::StreamFailure` doc says so in as many words — so NFR-RT-010 and FR-ERR-030 apply to +/// it exactly as they apply to the data callback beside it. The closure this replaces did three +/// allocating things there: `format!` to build the notice detail, an +/// `mpsc::Sender::send` (which allocates a queue node), and — one layer down, in +/// `crate::audio_io`'s `to_stream_failure` — `cpal::Error::to_string()`. +/// +/// What it does instead is what D-7.3's telemetry path already does in the other direction: push a +/// pre-allocated, `Copy`, heap-free value into a bounded ring sized at stream open, and let the UI +/// thread do the formatting. A full ring drops the report rather than blocking or growing, which +/// is the only RT-legal answer and costs nothing real: [`crate::host::AppHost`] deduplicates +/// identical notices anyway. +/// +/// `Xrun` is counted rather than pushed, exactly as before — [`crate::xrun::XrunCounter::record`] +/// is a single relaxed atomic increment and belongs on the callback thread, and routing it through +/// the ring would let a burst of dropouts evict the device-loss report behind it. +fn stream_failure_sink( + xruns: Arc, + mut failures: rtrb::Producer, +) -> impl FnMut(StreamFailure) + Send + 'static { + move |failure| { + if matches!(failure, StreamFailure::Xrun) { + xruns.record(); + return; + } + // `StreamFailure` is `Copy` and owns no heap, so the value handed back by a full ring is + // dropped without a deallocation -- which is why the payload had to stop being a `String`. + let _ = failures.push(failure); + } +} + /// `main`'s real body. Blocks until the window is closed. pub fn run() { startup_probe::entered(); @@ -245,8 +289,12 @@ pub fn run() { settings.sample_rate_hz, ) .unwrap_or(48_000); - let buffer_frames = crate::device_state::negotiate_buffer_size( + // Both sides, not the input's ranges alone (issue #86): one buffer size is applied to both + // `StreamParams` below, so it has to be a size both devices actually accept — the same + // intersect-then-choose shape `negotiate_shared_sample_rate` above already has. + let buffer_frames = crate::device_state::negotiate_shared_buffer_size( &input.configs, + &output.configs, sample_rate_hz, settings.buffer_size_frames, ); @@ -357,7 +405,6 @@ pub fn run() { state: Arc::clone(&state), }; let worker = WorkerHandle::spawn(worker_ctx); - let stream_event_sender = worker.event_sender(); // FR-IO-020's mode indicator: the mode actually granted, never the one requested. The output // device names it -- see `namir_ui::AudioModeStatus::device_name` for why one name is enough @@ -367,6 +414,13 @@ pub fn run() { device_name: output.device.name.clone(), }); let mut host = AppHost::new(instance, worker, telemetry, library, state, audio_mode); + // FR-STATE-030: `/Presets`, the one directory `namir-clap` must also resolve -- + // see `crate::presets`' module doc comment for why that rule is written twice today and where + // it belongs. `resolve_config_dir`'s answer, not `namir_platform::config_dir`'s directly, so a + // NFR-PERF-030 measurement run stays inside the directory its harness owns. + if let Some(dir) = &config_dir { + host.watch_presets(crate::presets::preset_dir_under(dir)); + } if let Some(w) = settings_warning { host.report(w.code, w.detail); } @@ -405,56 +459,59 @@ pub fn run() { max_block_size, }; - let xruns_for_failure = Arc::clone(&xruns); - // The two device names the failure callback needs, captured before `stream_setup` is consumed. + // The two device names the failure notice needs, captured before `stream_setup` is consumed. // Issue #44's smallest half: the app knew which device and which direction had failed and - // dropped both, so the notice a human read on 2026-08-27 named neither. + // dropped both, so the notice a human read on 2026-08-27 named neither. They are handed to + // `AppHost` rather than into the callbacks (issue #88), because that is where the notice is + // now built -- on the UI thread, where formatting a string is allowed. let failed_input_name = input.device.name.clone(); let failed_output_name = output.device.name.clone(); + let (input_failure_tx, input_failure_rx) = rtrb::RingBuffer::new(STREAM_FAILURE_RING_SLOTS); + let (output_failure_tx, output_failure_rx) = rtrb::RingBuffer::new(STREAM_FAILURE_RING_SLOTS); let running = stream::open( stream_setup, engine, Arc::clone(&xruns), - move |direction, failure| match failure { - crate::audio_io::StreamFailure::Xrun => xruns_for_failure.record(), - other => { - let (side, device) = match direction { - crate::stream::Direction::Input => ("input", &failed_input_name), - crate::stream::Direction::Output => ("output", &failed_output_name), - }; - let _ = stream_event_sender.send(AppEvent::StreamFailure { - direction, - // `{other}`, not `{other:?}` -- `StreamFailure`'s `Display` was added at M14 - // precisely so no `Debug` rendering reaches a user-facing string. - detail: format!("{side} device \"{device}\": {other}"), - failure: other, - }); - } - }, + stream_failure_sink(Arc::clone(&xruns), input_failure_tx), + stream_failure_sink(Arc::clone(&xruns), output_failure_tx), ); + host.watch_stream_failures(crate::host::StreamFailureWatch::new( + input_failure_rx, + output_failure_rx, + failed_input_name, + failed_output_name, + )); let _running = match running { - Ok(running) => match running.play() { - Ok(()) => { - // NFR-PERF-030's marking event, emitted before the log line below so the measured - // interval ends where the requirement says it does: `RunningStreams::play` - // returning `Ok(())` is, in its own doc comment's words, "the one call that - // actually makes audio flow". A no-op outside a measurement run. - startup_probe::audible(library_index_entries, default_state_params); - eprintln!("namir: audio stream started"); - Some(running) + Ok(running) => { + // Issue #76: D-13.2's elevation outcome is produced inside the first output callback + // and cannot be reported from there (see `stream::ThreadPriorityReport`), so the + // report is handed to the host, which polls it and writes the record from the UI + // thread. Before `play()`, because that is what makes the first callback run. + host.watch_thread_priority(running.thread_priority()); + match running.play() { + Ok(()) => { + // NFR-PERF-030's marking event, emitted before the log line below so the + // measured interval ends where the requirement says it does: + // `RunningStreams::play` returning `Ok(())` is, in its own doc comment's + // words, "the one call that actually makes audio flow". A no-op outside a + // measurement run. + startup_probe::audible(library_index_entries, default_state_params); + eprintln!("namir: audio stream started"); + Some(running) + } + Err(e) => { + // The detail is carried on the marker, not left to the notice alone: a probed + // launch opens no window, so `host.report` below has no reader. + startup_probe::not_audible( + startup_probe::REASON_STREAM_NOT_STARTED, + &e.to_string(), + ); + host.report(crate::error_codes::DEVICE_OPEN_FAILED, e.to_string()); + None + } } - Err(e) => { - // The detail is carried on the marker, not left to the notice alone: a probed - // launch opens no window, so `host.report` below has no reader. - startup_probe::not_audible( - startup_probe::REASON_STREAM_NOT_STARTED, - &e.to_string(), - ); - host.report(crate::error_codes::DEVICE_OPEN_FAILED, e.to_string()); - None - } - }, + } Err(e) => { startup_probe::not_audible(startup_probe::REASON_STREAM_NOT_STARTED, &e.to_string()); host.report(crate::error_codes::DEVICE_OPEN_FAILED, e.to_string()); @@ -535,6 +592,7 @@ fn open_window_without_audio(config_dir: Option) { let telemetry = endpoint.telemetry.clone(); let instance = SharedInstance::new(Instance::new(EngineConfig { ctx: c }, endpoint)); + let preset_dir = config_dir.as_deref().map(crate::presets::preset_dir_under); let library_dir = config_dir.unwrap_or_else(|| std::env::temp_dir().join("namir-session-only")); let (library, _warnings) = namir_worker::library::LibraryService::open_at(&library_dir); let library_roots = library.roots().to_vec(); @@ -553,6 +611,12 @@ fn open_window_without_audio(config_dir: Option) { // No device was opened at all on this path, so there is no share mode to indicate -- `None` // rather than a truthful-looking "Shared", which would claim a device this window does not have. let mut host = AppHost::new(instance, worker, telemetry, library, state, None); + // FR-STATE-030 still works on this path: a window with no device can still list, save and + // recall presets, and refusing to would be a second degradation the missing device does not + // imply. + if let Some(dir) = preset_dir { + host.watch_presets(dir); + } // `NO_AUDIO_DEVICE`, not `NO_SUPPORTED_CONFIG` (issue #40): FR-IO-040's entry says none of the // rates *a device* reports could be negotiated, and on this path there is no device to be the // subject of that sentence. The same judgement two lines up passes `None` for the share-mode @@ -651,6 +715,65 @@ mod tests { ) } + /// **Issue #88: the `cpal` error callback allocates nothing.** This is the closure a real + /// stream invokes on its own thread when a device is lost or a driver faults, and it used to + /// `format!` a notice detail and `mpsc::Sender::send` it — two heap allocations on an audio + /// thread, which NFR-RT-010 and FR-ERR-030 both forbid. + /// + /// Driven under D-7.5's `assert_no_alloc` harness with both shapes it has to handle: an `Xrun` + /// (counted on the spot) and an `Other` carrying a real backend message (pushed to the ring). + /// The failure is built *outside* the section, because building it is `crate::audio_io`'s job + /// and has its own test above. + #[test] + fn the_stream_failure_sink_allocates_nothing_on_the_callback_thread() { + let xruns = Arc::new(XrunCounter::new()); + let (producer, mut consumer) = rtrb::RingBuffer::new(STREAM_FAILURE_RING_SLOTS); + let mut sink = stream_failure_sink(Arc::clone(&xruns), producer); + + let lost = StreamFailure::Other(crate::audio_io::InlineDetail::from( + "OS Error -2004287450 (FormatMessageW() returned error 317)", + )); + crate::rt_harness::audio_section(|| { + sink(StreamFailure::Xrun); + sink(lost); + }); + + assert_eq!(xruns.count(), 1, "an Xrun is counted, not queued"); + assert_eq!( + consumer.pop().ok(), + Some(lost), + "a non-xrun failure reaches the ring intact" + ); + assert!(consumer.pop().is_err(), "the Xrun must not also be queued"); + } + + /// A ring that has filled up must drop the report, not block, grow, or free anything: the + /// value `rtrb` hands back on a full push is dropped right there on the callback thread, which + /// is only legal because `StreamFailure` owns no heap. Deliberately pushed well past capacity + /// inside the harness. + #[test] + fn a_full_stream_failure_ring_drops_reports_rather_than_allocating() { + let xruns = Arc::new(XrunCounter::new()); + let (producer, mut consumer) = rtrb::RingBuffer::new(STREAM_FAILURE_RING_SLOTS); + let mut sink = stream_failure_sink(Arc::clone(&xruns), producer); + + let failure = StreamFailure::DeviceLost; + crate::rt_harness::audio_section(|| { + for _ in 0..(STREAM_FAILURE_RING_SLOTS * 4) { + sink(failure); + } + }); + + let mut drained = 0; + while consumer.pop().is_ok() { + drained += 1; + } + assert_eq!( + drained, STREAM_FAILURE_RING_SLOTS, + "the ring holds its capacity and drops the rest" + ); + } + /// The untouched-settings case: `AppSettings::default().exclusive_mode` is `false`, so a first /// run — or any run by a user who never asked for exclusive mode — settles on shared with /// nothing to report, even on a backend that would have granted exclusive mode. diff --git a/crates/namir-app/src/audio_io.rs b/crates/namir-app/src/audio_io.rs index e0c001f..f4246e4 100644 --- a/crates/namir-app/src/audio_io.rs +++ b/crates/namir-app/src/audio_io.rs @@ -200,11 +200,149 @@ pub enum ExclusiveModeOutcome { Unsupported, } +/// How many bytes of a backend's own error message [`InlineDetail`] keeps. +/// +/// Comfortably longer than the longest real message this project has transcribed off a screen — +/// the 2026-08-27 WASAPI unplug arrived as +/// `OS Error -2004287450 (FormatMessageW() returned error 317)`, 57 bytes — with room to spare for +/// a more verbose driver, and deliberately under `clippy::large_enum_variant`'s 200-byte +/// variant-size threshold, so [`StreamFailure`] carries it inline without an `allow` and without +/// the `Box` that lint would otherwise want (a `Box` here would be a heap allocation on the error +/// callback thread, which is the entire thing this type exists to avoid). +pub const STREAM_FAILURE_DETAIL_BYTES: usize = 160; + +/// A fixed-capacity, heap-free string: [`StreamFailure::Other`]'s message, and the reason +/// [`StreamFailure`] is `Copy` (issue #88). +/// +/// # Why this exists rather than a `String` +/// +/// `StreamFailure` is built inside `cpal`'s **error callback**, which every backend invokes on the +/// stream's own thread — `crate::worker`'s own `AppEvent::StreamFailure` doc says so in as many +/// words — so building one is audio-thread work under NFR-RT-010/FR-ERR-030, and +/// `cpal::Error::to_string()` was a heap allocation there. Writing the same characters into an +/// inline buffer allocates nothing, and it makes the value `Copy`, which is what lets a failure +/// report cross to the UI thread through a pre-allocated `rtrb` ring: pushing a `String` into a +/// **full** ring hands the value back, and dropping it would be a *de*allocation on the same +/// thread. +/// +/// # What it gives up +/// +/// A message longer than [`STREAM_FAILURE_DETAIL_BYTES`] is truncated at a character boundary +/// rather than reallocated. Diagnostics lose the tail of an unusually long driver message; they +/// would otherwise lose nothing at all, because the callback is not allowed to allocate one. +/// +/// This bounds *Namir's* work, not the backend's: a `Display` impl that itself allocates while +/// rendering (Windows's `FormatMessageW` path inside `cpal` is the known one) still does so. That +/// is upstream of this boundary and outside this crate's reach — what changed is that this crate +/// no longer adds an allocation of its own on top of it. +#[derive(Clone, Copy)] +pub struct InlineDetail { + bytes: [u8; STREAM_FAILURE_DETAIL_BYTES], + len: usize, +} + +impl InlineDetail { + /// An empty detail. + #[must_use] + pub const fn new() -> Self { + Self { + bytes: [0; STREAM_FAILURE_DETAIL_BYTES], + len: 0, + } + } + + /// Renders `value` into a fresh detail, truncating at a character boundary if it does not fit. + /// Allocation-free on this crate's own account — see the type's doc comment for the one thing + /// that qualifies. + #[must_use] + pub fn from_display(value: &impl std::fmt::Display) -> Self { + use std::fmt::Write as _; + let mut this = Self::new(); + // `write_str` below never reports failure (it truncates instead), so this cannot error. + let _ = write!(&mut this, "{value}"); + this + } + + /// The bytes written so far, as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + // Only ever written whole `char`s at a time, so this is always valid UTF-8; `unwrap_or` + // rather than `expect` because a panic here would be inside a stream error report, which + // is the last place that helps anyone. + std::str::from_utf8(&self.bytes[..self.len]).unwrap_or("") + } + + /// Whether the capacity is exhausted, i.e. a further write would be dropped. `true` is the + /// signal that what [`Self::as_str`] returns may be a prefix of the backend's real message. + #[must_use] + pub fn is_full(&self) -> bool { + self.len + 4 > STREAM_FAILURE_DETAIL_BYTES + } +} + +impl Default for InlineDetail { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Write for InlineDetail { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + for ch in s.chars() { + let mut encoded = [0u8; 4]; + let encoded = ch.encode_utf8(&mut encoded).as_bytes(); + if self.len + encoded.len() > STREAM_FAILURE_DETAIL_BYTES { + // Truncate rather than error: a `fmt::Error` would abort the whole render and + // leave a *partial* message with no indication why, which is worse than a + // deliberate prefix. `is_full` is how a caller can tell. + return Ok(()); + } + self.bytes[self.len..self.len + encoded.len()].copy_from_slice(encoded); + self.len += encoded.len(); + } + Ok(()) + } +} + +impl std::fmt::Display for InlineDetail { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Renders as the string it holds, not as a byte array — a 256-element `[u8; N]` in a `Debug` log +/// line would bury every other field of whatever contains it. +impl std::fmt::Debug for InlineDetail { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self.as_str(), f) + } +} + +/// Compared by content, not by the unwritten tail of the buffer. +impl PartialEq for InlineDetail { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + +impl Eq for InlineDetail {} + +impl From<&str> for InlineDetail { + fn from(value: &str) -> Self { + Self::from_display(&value) + } +} + /// A Namir-owned classification of a stream failure, replacing `cpal::ErrorKind` at this crate's /// boundary (D-13.1). `Xrun` is `cpal`'s own detected dropout (not every backend reports it — see /// [`crate::xrun`] for the ring-underrun-based detector this crate also runs, which does not /// depend on backend support). `DeviceLost` is FR-IO-070's device-removal case. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// **`Copy`, and every byte of it inline (issue #88).** This value is constructed on `cpal`'s +/// error-callback thread and travels to the UI thread through a pre-allocated ring; both ends of +/// that are audio-thread constraints, and a `String` payload would have broken them at both. See +/// [`InlineDetail`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StreamFailure { /// The device was disconnected or otherwise stopped being reachable (`cpal`'s /// `ErrorKind::DeviceNotAvailable`/`HostUnavailable`). @@ -212,7 +350,7 @@ pub enum StreamFailure { /// `cpal` itself detected a buffer underrun/overrun (`ErrorKind::Xrun`). Xrun, /// Anything else, carrying `cpal`'s own message for diagnostics (FR-ERR-050). - Other(String), + Other(InlineDetail), } /// `Display`, added M14 (issue #44), because the `Debug` rendering was reaching a user's screen: @@ -225,7 +363,7 @@ impl std::fmt::Display for StreamFailure { match self { Self::DeviceLost => f.write_str("the device is no longer available"), Self::Xrun => f.write_str("the audio buffer under- or overran"), - Self::Other(message) => f.write_str(message), + Self::Other(message) => f.write_str(message.as_str()), } } } @@ -280,8 +418,31 @@ const DEVICE_LOSS_MARKERS: &[&str] = &[ /// [`DEVICE_LOSS_MARKERS`] for the observed case that made this necessary. #[must_use] pub fn classifies_as_device_loss(message: &str) -> bool { - let lowered = message.to_ascii_lowercase(); - DEVICE_LOSS_MARKERS.iter().any(|m| lowered.contains(m)) + // Case-folded per byte rather than through `to_ascii_lowercase()` (issue #88): this predicate + // runs inside `to_stream_failure`, which runs inside `cpal`'s error callback on the stream's + // own thread, and lowering the whole message first was a heap allocation there. Every marker + // above is already lowercase, so an ASCII-insensitive substring search over the original bytes + // answers the same question with no buffer at all. + DEVICE_LOSS_MARKERS + .iter() + .any(|m| contains_ignore_ascii_case(message, m)) +} + +/// Whether `haystack` contains `needle`, comparing ASCII letters case-insensitively and +/// allocating nothing. Non-ASCII bytes compare exactly, which is what every marker in +/// [`DEVICE_LOSS_MARKERS`] needs (they are all ASCII) and is the conservative answer for anything +/// else. +fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool { + let haystack = haystack.as_bytes(); + let needle = needle.as_bytes(); + if needle.is_empty() { + return true; + } + if haystack.len() < needle.len() { + return false; + } + (0..=haystack.len() - needle.len()) + .any(|start| haystack[start..start + needle.len()].eq_ignore_ascii_case(needle)) } /// Why an [`AudioBackend`] operation failed. @@ -441,7 +602,7 @@ mod cpal_impl { use super::convert; use super::{ AudioBackend, AudioIoError, AudioStream, BufferSizeRange, CpalBackend, DeviceInfo, - ExclusiveModeOutcome, HostInfo, ShareMode, StreamFailure, StreamParams, + ExclusiveModeOutcome, HostInfo, InlineDetail, ShareMode, StreamFailure, StreamParams, SupportedConfigRange, }; @@ -607,8 +768,11 @@ mod cpal_impl { } cpal::ErrorKind::Xrun => StreamFailure::Xrun, _ => { - let message = error.to_string(); - if super::classifies_as_device_loss(&message) { + // `InlineDetail::from_display`, not `error.to_string()` (issue #88): this runs on + // the stream's own error-callback thread, where NFR-RT-010 forbids a heap + // allocation. + let message = InlineDetail::from_display(&error); + if super::classifies_as_device_loss(message.as_str()) { StreamFailure::DeviceLost } else { StreamFailure::Other(message) @@ -1163,11 +1327,97 @@ mod tests { } } + /// **Issue #88: building a failure detail allocates nothing.** `to_stream_failure` runs inside + /// `cpal`'s error callback, on the stream's own thread, and used to call + /// `cpal::Error::to_string()` there. `cpal::Error` cannot be constructed from outside `cpal`, + /// so what is driven here is the same [`InlineDetail::from_display`] call it now makes, over a + /// `Display` impl of this crate's own. + #[test] + fn building_a_stream_failure_detail_allocates_nothing() { + struct Formatted(i64); + impl std::fmt::Display for Formatted { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "OS Error {} (FormatMessageW() returned error 317)", + self.0 + ) + } + } + + let mut built = InlineDetail::new(); + crate::rt_harness::audio_section(|| { + built = InlineDetail::from_display(&Formatted(-2_004_287_450)); + }); + assert_eq!( + built.as_str(), + "OS Error -2004287450 (FormatMessageW() returned error 317)" + ); + assert!(!built.is_full()); + } + + /// The other half of the same callback: classification. `classifies_as_device_loss` used to + /// lower-case the whole message into a fresh `String` before searching it. + #[test] + fn classifying_a_device_loss_allocates_nothing() { + let observed = "OS Error -2004287450 (FormatMessageW() returned error 317)"; + let mut classified = false; + crate::rt_harness::audio_section(|| { + classified = classifies_as_device_loss(observed); + }); + assert!(classified); + } + + /// The allocation-free search must still be case-insensitive, which is the property the + /// removed `to_ascii_lowercase()` was providing: every marker is written lowercase, and real + /// backend messages are not. + #[test] + fn the_device_loss_markers_still_match_regardless_of_case() { + for message in [ + "The Device Was Disconnected", + "AUDIO ENDPOINT UNPLUGGED", + "DeviceNotAvailable", + "0X88890004", + ] { + assert!(classifies_as_device_loss(message), "{message}"); + } + } + + /// A message longer than the inline capacity is cut, not grown — and cut at a character + /// boundary, so what survives is still valid UTF-8 rather than half a code point. Driven with + /// a multi-byte character straddling the limit, which is the only way to get that wrong. + #[test] + fn an_over_long_detail_truncates_at_a_character_boundary() { + // Each 'é' is two bytes, so the capacity is reached mid-character on an odd boundary. + let long: String = std::iter::repeat_n('é', STREAM_FAILURE_DETAIL_BYTES).collect(); + let detail = InlineDetail::from(long.as_str()); + assert!(detail.is_full()); + assert!(detail.as_str().len() <= STREAM_FAILURE_DETAIL_BYTES); + assert!( + detail.as_str().chars().all(|c| c == 'é'), + "truncation split a character: {:?}", + detail.as_str() + ); + assert!(long.starts_with(detail.as_str())); + } + + /// A detail that fits is stored and compared by content, not by the unwritten tail of its + /// buffer -- two details built from the same text are equal however they were built. + #[test] + fn details_compare_by_content_not_by_buffer() { + assert_eq!( + InlineDetail::from("abc"), + InlineDetail::from_display(&"abc") + ); + assert_ne!(InlineDetail::from("abc"), InlineDetail::from("abd")); + assert_eq!(InlineDetail::default().as_str(), ""); + } + /// Issue #44's rendering half: no `Debug` shape may reach a user-facing string. `Other`'s /// message is written through, without the variant name and quotes `format!("{:?}")` adds. #[test] fn stream_failure_displays_without_its_debug_variant_name() { - let failure = StreamFailure::Other("OS Error -1 (something)".to_string()); + let failure = StreamFailure::Other(InlineDetail::from("OS Error -1 (something)")); assert_eq!(failure.to_string(), "OS Error -1 (something)"); assert!(!failure.to_string().contains("Other(")); assert_eq!( diff --git a/crates/namir-app/src/audio_io/convert.rs b/crates/namir-app/src/audio_io/convert.rs index a555c94..bc45ee8 100644 --- a/crates/namir-app/src/audio_io/convert.rs +++ b/crates/namir-app/src/audio_io/convert.rs @@ -461,7 +461,8 @@ mod tests { crate::stream::fake_duplex_setup(&backend, MAX_BLOCK), crate::stream::default_test_engine(MAX_BLOCK), std::sync::Arc::clone(&xruns), - |_, _| {}, + |_| {}, + |_| {}, ) .unwrap(); diff --git a/crates/namir-app/src/device_state.rs b/crates/namir-app/src/device_state.rs index 951011b..c42e307 100644 --- a/crates/namir-app/src/device_state.rs +++ b/crates/namir-app/src/device_state.rs @@ -182,6 +182,99 @@ pub fn negotiate_shared_sample_rate( None } +/// FR-IO-040 applied to a duplex pair, the buffer-size half — the counterpart +/// [`negotiate_shared_sample_rate`] has had all along and this function did not (issue #86). +/// +/// # The bug this closes +/// +/// [`crate::app::run`] called [`negotiate_buffer_size`] against the **input** device's ranges and +/// then applied the answer to *both* `StreamParams`. On a duplex pair of different devices whose +/// ranges do not overlap the way the input's alone suggests — an input reporting +/// `Range { min: 16, max: 8192 }`, which picks 256, against an output whose minimum is 480 — the +/// output open is asked for `cpal::BufferSize::Fixed(256)`, fails, and the session ends with no +/// audio at all. The sample rate never had this problem because it intersects both sides; the +/// buffer size simply had no equivalent. +/// +/// # What it picks +/// +/// A frame count both sides accept, preferring `remembered` when both accept it (FR-IO-080's +/// "remember the user's choice"), otherwise the value nearest [`PREFERRED_BUFFER_FRAMES`] inside +/// some pairwise intersection of an input range with an output range. +/// +/// `None` means "ask each backend for its own default" (`cpal::BufferSize::Default`), and is +/// returned in three cases: either side covers `sample_rate_hz` with no config at all; no pair of +/// ranges intersects; or no pair of ranges is constrained on **both** sides. That last one is +/// deliberately conservative. A side reporting [`BufferSizeRange::Unknown`] has told us nothing, +/// so imposing the *other* side's number on it is a guess that can fail the open — whereas +/// `Default` is a value every backend accepts by construction. It is also what the single-sided +/// function already answered for an `Unknown` input, so no working configuration changes shape. +pub fn negotiate_shared_buffer_size( + input_configs: &[SupportedConfigRange], + output_configs: &[SupportedConfigRange], + sample_rate_hz: u32, + remembered: Option, +) -> Option { + let input: Vec = configs_at_rate(input_configs, sample_rate_hz) + .map(|c| c.buffer_size) + .collect(); + let output: Vec = configs_at_rate(output_configs, sample_rate_hz) + .map(|c| c.buffer_size) + .collect(); + if input.is_empty() || output.is_empty() { + return None; + } + + if let Some(frames) = remembered + && accepts_buffer_size(&input, frames) + && accepts_buffer_size(&output, frames) + { + return Some(frames); + } + + let mut best: Option = None; + for i in &input { + for o in &output { + let ( + BufferSizeRange::Range { + min: in_min, + max: in_max, + }, + BufferSizeRange::Range { + min: out_min, + max: out_max, + }, + ) = (i, o) + else { + // At least one side is `Unknown`: see this function's doc comment for why that + // yields `Default` rather than a number taken from the other side alone. + continue; + }; + let min = (*in_min).max(*out_min); + let max = (*in_max).min(*out_max); + if min > max { + continue; + } + let candidate = PREFERRED_BUFFER_FRAMES.clamp(min, max); + if best.is_none_or(|b| { + candidate.abs_diff(PREFERRED_BUFFER_FRAMES) < b.abs_diff(PREFERRED_BUFFER_FRAMES) + }) { + best = Some(candidate); + } + } + } + best +} + +/// Whether any of one direction's applicable buffer-size ranges covers `frames`. +/// [`BufferSizeRange::Unknown`] imposes no constraint, so it accepts anything — the same reading +/// [`negotiate_buffer_size`] has always given it. +fn accepts_buffer_size(ranges: &[BufferSizeRange], frames: u32) -> bool { + ranges.iter().any(|r| match r { + BufferSizeRange::Range { min, max } => frames >= *min && frames <= *max, + BufferSizeRange::Unknown => true, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -413,4 +506,250 @@ mod tests { Some(88_200) ); } + + // --- negotiate_shared_buffer_size: issue #86 --- + + /// **The reported bug, verbatim.** The input reports `Range { 16, 8192 }`, from which the + /// single-sided [`negotiate_buffer_size`] picks 256; the output cannot go below 480. Applying + /// the input's answer to both streams asks `cpal` for `Fixed(256)` on a device that refuses + /// it, and the session ends up with no audio at all. + #[test] + fn a_size_the_input_alone_would_pick_is_raised_to_what_the_output_can_also_open() { + let input = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + let output = vec![config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { + min: 480, + max: 4096, + }, + )]; + assert_eq!( + negotiate_buffer_size(&input, 48_000, None), + Some(256), + "the single-sided function is unchanged -- this is the answer that was wrong to apply \ + to both streams" + ); + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, None), + Some(480) + ); + } + + /// The mirror case: the *output* is the permissive side, so the intersection is driven by the + /// input's floor. A fix that only checked one direction would pass the test above and fail + /// this one. + #[test] + fn the_intersection_is_taken_in_both_directions() { + let input = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { + min: 512, + max: 2048, + }, + )]; + let output = vec![config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, None), + Some(512) + ); + } + + /// Both sides comfortably cover [`PREFERRED_BUFFER_FRAMES`], so neither constrains the answer. + #[test] + fn an_overlapping_pair_that_both_cover_the_preferred_size_gets_it() { + let input = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + let output = vec![config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { min: 32, max: 4096 }, + )]; + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, None), + Some(PREFERRED_BUFFER_FRAMES) + ); + } + + /// FR-IO-080: a remembered size both devices accept is honoured over the preferred default. + #[test] + fn a_remembered_size_both_sides_accept_is_used() { + let input = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + let output = vec![config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { min: 64, max: 4096 }, + )]; + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, Some(1024)), + Some(1024) + ); + } + + /// ...and one only the input accepts is not: it would fail the output open, which is the whole + /// defect. The negotiated answer falls back into the intersection instead. + #[test] + fn a_remembered_size_only_one_side_accepts_is_not_honoured() { + let input = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + let output = vec![config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { + min: 480, + max: 4096, + }, + )]; + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, Some(64)), + Some(480) + ); + } + + /// Two ranges that do not overlap at all leave nothing to pick, so the backends are asked for + /// their own defaults rather than handed a number one of them is certain to refuse. + #[test] + fn ranges_that_do_not_overlap_fall_back_to_the_backend_default() { + let input = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 128 }, + )]; + let output = vec![config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { + min: 480, + max: 4096, + }, + )]; + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, None), + None + ); + } + + /// An `Unknown` range on either side has told us nothing to intersect against, so the answer + /// is `Default` -- the value every backend accepts -- rather than the other side's number. + /// This is also the shape `crate::stream::FakeBackend` reports, so every stream test keeps + /// opening with `BufferSize::Default` exactly as before. + #[test] + fn an_unknown_range_on_either_side_falls_back_to_the_backend_default() { + let known = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + let unknown = vec![config(2, 48_000, 48_000, BufferSizeRange::Unknown)]; + assert_eq!( + negotiate_shared_buffer_size(&known, &unknown, 48_000, None), + None + ); + assert_eq!( + negotiate_shared_buffer_size(&unknown, &known, 48_000, None), + None + ); + assert_eq!( + negotiate_shared_buffer_size(&unknown, &unknown, 48_000, None), + None + ); + } + + /// A rate one side does not cover leaves that side with no applicable config, which is a + /// rate/config mismatch for the caller to handle -- not a buffer size to invent. + #[test] + fn a_rate_one_side_does_not_cover_yields_none() { + let input = vec![config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + let output = vec![config( + 2, + 44_100, + 44_100, + BufferSizeRange::Range { min: 16, max: 8192 }, + )]; + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, None), + None + ); + } + + /// Several reported ranges per side: the answer must come from the pair whose intersection + /// lands nearest the preferred size, not from whichever range happened to be enumerated first. + #[test] + fn the_nearest_intersection_wins_not_the_first_one_enumerated() { + let input = vec![ + config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { + min: 2048, + max: 8192, + }, + ), + config( + 1, + 48_000, + 48_000, + BufferSizeRange::Range { min: 64, max: 512 }, + ), + ]; + let output = vec![ + config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { + min: 2048, + max: 8192, + }, + ), + config( + 2, + 48_000, + 48_000, + BufferSizeRange::Range { min: 64, max: 512 }, + ), + ]; + assert_eq!( + negotiate_shared_buffer_size(&input, &output, 48_000, None), + Some(PREFERRED_BUFFER_FRAMES) + ); + } } diff --git a/crates/namir-app/src/host.rs b/crates/namir-app/src/host.rs index 8bbd72d..6c06531 100644 --- a/crates/namir-app/src/host.rs +++ b/crates/namir-app/src/host.rs @@ -30,19 +30,22 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use namir_core::ErrorCode; use namir_engine::{ParamChange, ParamId as EngineParamId, TelemetryEntry, TelemetryReader}; use namir_params::REGISTRY; use namir_state::State; use namir_ui::{ - AudioModeStatus, AudioShareMode, LibrarySnapshot, MeterReading, UiHost, UiIntent, UiNotice, - UiSnapshot, + AudioModeStatus, AudioShareMode, LibrarySnapshot, MeterReading, PresetSummary, UiHost, + UiIntent, UiNotice, UiSnapshot, }; use namir_worker::Target; use namir_worker::library::LibraryService; +use crate::audio_io::StreamFailure; use crate::instance::SharedInstance; +use crate::stream::{Direction, ThreadPriorityReport}; use crate::worker::{AppCommand, AppEvent, LoadOutcomeSummary, WorkerHandle}; /// This crate's own catalogue entries for the notices [`AppHost`] itself synthesises (as opposed @@ -107,6 +110,22 @@ pub(crate) mod local_error_codes { // // Retired id: `app.host.scan_warning`. + pub const PRESET_NAME_REFUSED: ErrorCode = ErrorCode::new( + "app.host.preset_name_refused", + Severity::Warning, + "That preset name cannot be used ({detail}).", + "Choose a name without a slash, backslash, colon, asterisk, question mark, quote, angle \ + bracket or vertical bar. Nothing was written, and your settings are unchanged.", + ); + pub const PRESET_LOCATION_UNKNOWN: ErrorCode = ErrorCode::new( + "app.host.preset_location_unknown", + Severity::Warning, + "Presets cannot be saved or listed: this environment has no per-user configuration \ + directory ({detail}).", + "Audio and every other feature still work; only named presets are unavailable. This is \ + the same degradation that stops Namir remembering your audio device between launches.", + ); + /// FR-IO-070: which catalogue entry a stream failure maps to. /// /// **It takes the classification, not the direction (issue #44).** Until M14 it took a @@ -130,7 +149,7 @@ pub(crate) mod local_error_codes { // silently pick up the wrong entry. StreamFailure::Xrun => crate::error_codes::STREAM_FAILED, StreamFailure::Other(message) => { - if crate::audio_io::classifies_as_device_loss(message) { + if crate::audio_io::classifies_as_device_loss(message.as_str()) { crate::error_codes::DEVICE_LOST } else { crate::error_codes::STREAM_FAILED @@ -144,17 +163,104 @@ const TELEMETRY_TRIM_PEAK_DB: u32 = namir_params::ParamId::from_key("telemetry.t const TELEMETRY_TRIM_AVERAGE_DB: u32 = namir_params::ParamId::from_key("telemetry.trim.average_db").0; -fn out_channel_peak_id(index: usize) -> u32 { - namir_params::ParamId::from_key(&format!("telemetry.out.ch{index}.peak_db")).0 -} -fn out_channel_average_id(index: usize) -> u32 { - namir_params::ParamId::from_key(&format!("telemetry.out.ch{index}.average_db")).0 -} - /// How many output channels [`AppHost::snapshot`] scans for telemetry — comfortably above any /// channel count this build's `ChannelConfig` ever produces (at most 2). const MAX_OUTPUT_CHANNELS_SCANNED: usize = 2; +/// The per-output-channel telemetry ids [`AppHost::read_meters`] matches each drained entry +/// against, resolved **once, at compile time** (issue #90). +/// +/// These used to be two functions calling `ParamId::from_key(&format!("telemetry.out.ch{index}. +/// peak_db"))`, invoked from *inside* the per-entry loop — so a frame draining a full +/// [`TELEMETRY_DRAIN_BATCH`] rebuilt the same four constant strings up to 256 times, each one a +/// heap allocation plus a key hash, at frame rate. `ParamId::from_key` is a `const fn` (that is +/// how [`TELEMETRY_TRIM_PEAK_DB`] above is already written), so the whole cost is removable +/// rather than merely reducible: the arrays below are the same four numbers, computed by the +/// compiler. +/// +/// Written out entry by entry rather than generated in a loop because a `const` initialiser +/// cannot `format!` a key at all — which is the point. Both are declared with +/// [`MAX_OUTPUT_CHANNELS_SCANNED`] as their length, so raising the scan width without writing the +/// matching keys here fails to compile rather than silently ignoring the new channel. +const TELEMETRY_OUT_PEAK_DB: [u32; MAX_OUTPUT_CHANNELS_SCANNED] = [ + namir_params::ParamId::from_key("telemetry.out.ch0.peak_db").0, + namir_params::ParamId::from_key("telemetry.out.ch1.peak_db").0, +]; +const TELEMETRY_OUT_AVERAGE_DB: [u32; MAX_OUTPUT_CHANNELS_SCANNED] = [ + namir_params::ParamId::from_key("telemetry.out.ch0.average_db").0, + namir_params::ParamId::from_key("telemetry.out.ch1.average_db").0, +]; + +/// How many stream failures [`AppHost::snapshot`] turns into notices in one frame. Bounds the +/// per-frame work regardless of how fast a failing backend reports; anything left waits for the +/// next frame, and a ring that overflows in the meantime drops the excess at the producer end +/// (see [`crate::app`]'s `stream_failure_sink`). +const STREAM_FAILURE_DRAIN_BATCH: usize = 8; + +/// How stale [`AppHost`]'s cached preset listing may get before another enumeration is requested. +/// +/// A GUI frame must not `read_dir` ([`UiHost::snapshot`]'s own contract), so the listing is +/// refreshed by [`crate::worker`] and each frame renders whatever the last one produced. One +/// second is short enough that a preset saved from the *plugin* (or from another copy of this +/// application) appears while the user is still looking for it, and long enough that a 60 Hz +/// window is not listing a directory 60 times a second. The same cadence `namir-clap`'s +/// `SharedInner::presets_snapshot` uses, for the same reason. +const PRESET_LISTING_MAX_AGE: Duration = Duration::from_secs(1); + +/// The UI-thread end of [`crate::stream`]'s two error callbacks: one bounded ring per direction, +/// plus the device names a notice has to name (issue #44). +/// +/// Two rings rather than one because `rtrb` is single-producer and the two `cpal` error callbacks +/// run on two different, unsynchronised threads — see [`crate::app`]'s `stream_failure_sink` for +/// why the report crosses on a ring at all rather than down the `mpsc` channel every *other* +/// [`AppEvent`] uses. +pub struct StreamFailureWatch { + input: rtrb::Consumer, + output: rtrb::Consumer, + input_device_name: String, + output_device_name: String, +} + +impl StreamFailureWatch { + /// Assembles the watch from the consumer end of each direction's ring and the device name that + /// direction was opened on. + #[must_use] + pub fn new( + input: rtrb::Consumer, + output: rtrb::Consumer, + input_device_name: String, + output_device_name: String, + ) -> Self { + Self { + input, + output, + input_device_name, + output_device_name, + } + } + + /// The next failure from either direction, input first, or `None` when both rings are empty. + fn pop(&mut self) -> Option<(Direction, StreamFailure)> { + if let Ok(failure) = self.input.pop() { + return Some((Direction::Input, failure)); + } + self.output.pop().ok().map(|f| (Direction::Output, f)) + } + + /// FR-IO-070's notice text: which side failed, on which device, and what the backend said. + /// Built here, on the UI thread — the callback that detected the failure may not format a + /// string (FR-ERR-030). + fn detail(&self, direction: Direction, failure: StreamFailure) -> String { + let (side, device) = match direction { + Direction::Input => ("input", &self.input_device_name), + Direction::Output => ("output", &self.output_device_name), + }; + // `{failure}`, not `{failure:?}` -- `StreamFailure`'s `Display` was added at M14 precisely + // so no `Debug` rendering reaches a user-facing string (issue #44). + format!("{side} device \"{device}\": {failure}") + } +} + /// Telemetry entries drained per frame. `namir-engine`'s own `TELEMETRY_SCRATCH_ENTRIES` (64) is /// the whole real chain's per-block count; this is sized the same so a frame never sees "missed" /// entries from its own drain being too small. @@ -202,6 +308,22 @@ pub struct AppHost { input_meter: MeterReading, output_meter: MeterReading, scan_progress: Option, + /// FR-STATE-030's preset directory, or `None` where [`namir_platform::config_dir`] resolved + /// nothing — an environment with no per-user configuration convention, where the session runs + /// but remembers nothing across launches (P8). + preset_dir: Option, + /// The preset directory as last enumerated by [`crate::worker`], and when the enumeration was + /// *asked for* — stamped on request, not on arrival, so two frames in the same millisecond do + /// not both queue one. + presets: Vec, + presets_listed_at: Option, + /// FR-IO-070's stream-failure reports, when this host is driving a real duplex path. `None` + /// on `crate::app`'s `open_window_without_audio` path, where there is no stream to fail. + stream_failures: Option, + /// D-13.2's thread-elevation outcome, posted by the output callback and reported from here + /// (issue #76). Cleared once reported, so the notice is written once per session rather than + /// once per frame. + thread_priority: Option>, notices: Vec, next_notice_id: AtomicU64, } @@ -232,11 +354,107 @@ impl AppHost { input_meter: MeterReading::default(), output_meter: MeterReading::default(), scan_progress: None, + preset_dir: None, + presets: Vec::new(), + presets_listed_at: None, + stream_failures: None, + thread_priority: None, notices: Vec::new(), next_notice_id: AtomicU64::new(1), } } + /// Wires FR-IO-070's stream-failure reports in. Called by [`crate::app::run`] once, after + /// [`crate::stream::open`] has handed back the consumer end of each direction's ring; a host + /// with no streams behind it (`open_window_without_audio`) simply never calls it. + pub fn watch_stream_failures(&mut self, watch: StreamFailureWatch) { + self.stream_failures = Some(watch); + } + + /// Points this host at FR-STATE-030's preset directory (`/Presets`, see + /// [`crate::presets`]). Called by [`crate::app::run`] once, with the configuration directory + /// that launch actually resolved. A host never given one still runs: `SavePreset` reports + /// [`local_error_codes::PRESET_LOCATION_UNKNOWN`] and the recall list stays empty, which + /// [`namir_ui::UiSnapshot::presets`] documents as a disabled control rather than an error. + pub fn watch_presets(&mut self, preset_dir: PathBuf) { + self.preset_dir = Some(preset_dir); + self.presets_listed_at = None; + } + + /// Asks [`crate::worker`] for a fresh preset listing if the last one is stale. Never reads a + /// directory itself — see [`PRESET_LISTING_MAX_AGE`]. + fn refresh_presets_if_stale(&mut self) { + let Some(dir) = self.preset_dir.clone() else { + return; + }; + if self + .presets_listed_at + .is_some_and(|at| at.elapsed() < PRESET_LISTING_MAX_AGE) + { + return; + } + // Stamped before the request, not after it lands. + self.presets_listed_at = Some(Instant::now()); + self.worker.send(AppCommand::ListPresets(dir)); + } + + /// Wires D-13.2's thread-elevation outcome in (issue #76). Called by [`crate::app::run`] once, + /// with [`crate::stream::RunningStreams::thread_priority`]'s report; the outcome does not exist + /// yet at that point, because it is produced by the output callback's *first* invocation, so + /// this host polls for it and reports it on whichever frame it appears. + pub fn watch_thread_priority(&mut self, report: Arc) { + self.thread_priority = Some(report); + } + + /// Reports D-13.2's elevation outcome, once, as an FR-ERR-010 record and an FR-UI-070 notice. + /// + /// `ThreadPriorityOutcome::diagnostic` supplies the catalogue entry — `None` for `Elevated`, + /// which has nothing to report — and this side supplies the `{detail}`. Both halves of that + /// split are deliberate: `namir-platform` returns an `ErrorCode` and formats nothing, because + /// obtaining one allocates nothing and is therefore safe from the audio callback, while + /// *emitting* the record is not. This function is the UI-thread end that emitting was deferred + /// to. + /// + /// The watch is dropped as soon as an outcome arrives: the elevation happens once per stream, + /// so there is nothing further to poll for. + fn report_thread_priority(&mut self) { + let outcome = match &self.thread_priority { + Some(report) => report.take(), + None => return, + }; + let Some(outcome) = outcome else { + return; + }; + self.thread_priority = None; + if let Some(code) = outcome.diagnostic() { + self.push_notice(code, thread_priority_detail(outcome)); + } + } + + /// Turns whatever the two error callbacks reported since the last frame into notices, through + /// the same [`AppEvent::StreamFailure`] arm the `mpsc` path used before issue #88 — so the + /// classification-picks-the-catalogue-entry rule (issue #44) has exactly one implementation. + /// + /// The watch is taken out of `self` for the duration so the loop can call `&mut self` methods; + /// nothing else touches the field, and it is put straight back. + fn drain_stream_failures(&mut self) { + let Some(mut watch) = self.stream_failures.take() else { + return; + }; + for _ in 0..STREAM_FAILURE_DRAIN_BATCH { + let Some((direction, failure)) = watch.pop() else { + break; + }; + let detail = watch.detail(direction, failure); + self.handle_event(AppEvent::StreamFailure { + direction, + failure, + detail, + }); + } + self.stream_failures = Some(watch); + } + /// Queues one FR-UI-070 notice **and writes the matching FR-ERR-010 log record**. /// /// Wired here rather than at each of the ten call sites (`SCAN_SAVE_FAILED`, @@ -318,6 +536,7 @@ impl AppHost { } self.last_saved = self.state.lock().unwrap_or_else(|e| e.into_inner()).clone(); } + AppEvent::PresetsListed(presets) => self.presets = presets, AppEvent::StreamFailure { direction: _, failure, @@ -408,15 +627,10 @@ impl AppHost { trim_peak = Some(entry.value); } else if entry.id == TELEMETRY_TRIM_AVERAGE_DB { trim_average = Some(entry.value); - } else { - for ch in 0..MAX_OUTPUT_CHANNELS_SCANNED { - if entry.id == out_channel_peak_id(ch) { - out_peak = Some(out_peak.map_or(entry.value, |v: f32| v.max(entry.value))); - } else if entry.id == out_channel_average_id(ch) { - out_average = - Some(out_average.map_or(entry.value, |v: f32| v.max(entry.value))); - } - } + } else if TELEMETRY_OUT_PEAK_DB.contains(&entry.id) { + out_peak = Some(out_peak.map_or(entry.value, |v: f32| v.max(entry.value))); + } else if TELEMETRY_OUT_AVERAGE_DB.contains(&entry.id) { + out_average = Some(out_average.map_or(entry.value, |v: f32| v.max(entry.value))); } } @@ -440,16 +654,22 @@ impl UiHost for AppHost { for event in self.worker.drain_events() { self.handle_event(event); } + self.drain_stream_failures(); + self.report_thread_priority(); + self.refresh_presets_if_stale(); self.read_meters(); - let params = self - .state - .lock() - .unwrap_or_else(|e| e.into_inner()) - .params - .clone(); - let unsaved_changes = - *self.state.lock().unwrap_or_else(|e| e.into_inner()) != self.last_saved; + // **One guard, both readings (issue #91).** These used to be two separate `lock()` calls, + // and `crate::worker`'s `LoadState` replaces the whole `State` behind this mutex from the + // worker thread — so a recall landing between them produced a frame whose parameter values + // came from the state before the recall and whose `unsaved_changes` flag was computed + // against the state after it. The visible symptom is a one-frame unsaved marker that is + // either spurious or missing; the underlying defect is that the two fields of one snapshot + // were not read from one state at all. Taking the guard once makes that unrepresentable. + let (params, unsaved_changes) = { + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + (state.params.clone(), *state != self.last_saved) + }; let index = self.library.snapshot(); UiSnapshot { @@ -465,6 +685,9 @@ impl UiHost for AppHost { audio_mode: self.audio_mode.clone(), unsaved_changes, notices: self.notices.clone(), + // Whatever the last off-thread enumeration produced -- a GUI frame never reads a + // directory (`refresh_presets_if_stale` only ever *asks* for one). + presets: self.presets.clone(), } } @@ -504,9 +727,30 @@ impl UiHost for AppHost { }); }); } - UiIntent::LibraryQueryChanged(_) => { - // Pure view-side filtering state (`namir_ui::library_view::LibraryViewState`); - // this host has nothing to do -- the query never touches engine/library state. + UiIntent::SavePreset { name } => { + // FR-STATE-030's save half. The name is resolved to a path *here*, because + // `namir-ui` may not name a file (D-5.1 puts `namir-platform` out of its reach) + // and says so in `UiIntent::SavePreset`'s own doc comment; a name this shell will + // not write is refused with a notice rather than written somewhere else. + let Some(dir) = self.preset_dir.clone() else { + self.push_notice( + local_error_codes::PRESET_LOCATION_UNKNOWN, + "namir_platform::config_dir resolved nothing on this system", + ); + return; + }; + let Some(path) = crate::presets::preset_path(&dir, &name) else { + self.push_notice(local_error_codes::PRESET_NAME_REFUSED, name); + return; + }; + self.worker.send(AppCommand::SaveState(path)); + // The list the user is about to look at must contain what they just saved. + self.presets_listed_at = None; + } + UiIntent::RecallPreset { path } => { + // FR-STATE-030's recall half. The path came from `UiSnapshot::presets`, i.e. from + // this host's own listing -- `namir-ui` never constructs one. + self.worker.send(AppCommand::LoadState(path)); } UiIntent::LoadLibraryEntry(path) => { self.worker.send(AppCommand::LoadLibraryEntry(path)); @@ -524,6 +768,31 @@ impl UiHost for AppHost { } } +/// The `{detail}` for `platform.thread_priority.*` (issue #76): what the OS actually answered, +/// since the catalogue entry already carries the sentence and the remedy. `Elevated` has no entry +/// and so never reaches here, but is matched rather than folded into a catch-all so a future +/// caller that does reach here with it gets something truthful. +fn thread_priority_detail(outcome: namir_platform::ThreadPriorityOutcome) -> String { + match outcome { + namir_platform::ThreadPriorityOutcome::Elevated => { + "the audio callback thread was elevated".to_string() + } + namir_platform::ThreadPriorityOutcome::PermissionDenied => { + "the operating system refused the request for want of a privilege this process does \ + not hold" + .to_string() + } + // The raw code, un-prettified: FR-ERR-050's diagnostic bundle wants the number the + // platform's own documentation uses, which is why `OsError` widened it to `i64`. + namir_platform::ThreadPriorityOutcome::OsError(code) => { + format!("the operating system call failed with code {code}") + } + namir_platform::ThreadPriorityOutcome::Unsupported => { + "namir-platform has no thread-priority implementation for this target".to_string() + } + } +} + fn default_value_of(descriptor: &namir_params::ParamDescriptor) -> f32 { match descriptor.kind { namir_params::ParamKind::Continuous { default, .. } => default, @@ -531,17 +800,24 @@ fn default_value_of(descriptor: &namir_params::ParamDescriptor) -> f32 { } } -/// Requests a preset save (FR-STATE-010) — not a [`UiIntent`] today (`namir-ui`'s FR-UI-020 screen -/// has no save/load control yet; that is FR-UI's own scope, not this crate's), but exposed here so -/// [`crate::app`] can wire a future menu/shortcut to it without reaching into [`AppHost`]'s private -/// fields. +/// FR-STATE-010's save/recall by explicit *path*, as opposed to FR-STATE-030's save/recall by +/// *name* — which is what [`UiIntent::SavePreset`]/[`UiIntent::RecallPreset`] now carry and what +/// [`AppHost::dispatch`] resolves through [`crate::presets`]. +/// +/// These two used to be documented as "not a `UiIntent` today", which stopped being true when +/// `namir-ui` grew the preset controls. They are kept, with that claim corrected, because the two +/// requirements are genuinely different gestures: FR-STATE-010 is "save this state to a file the +/// user chose", which needs a file dialog this window does not have yet, and its path is not +/// required to be inside the preset directory at all. impl AppHost { - /// Requests a save to `path`. + /// Requests a save to `path`, wherever that is. FR-STATE-030's named-preset save goes through + /// [`UiIntent::SavePreset`] instead. pub fn save_state(&self, path: PathBuf) { self.worker.send(AppCommand::SaveState(path)); } - /// Requests a load-and-recall from `path`. + /// Requests a load-and-recall from `path`, wherever that is. FR-STATE-030's named-preset + /// recall goes through [`UiIntent::RecallPreset`] instead. pub fn load_state(&self, path: PathBuf) { self.worker.send(AppCommand::LoadState(path)); } @@ -762,6 +1038,433 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// **Issue #90: the per-entry telemetry drain allocates nothing.** The four output-channel + /// telemetry ids used to be rebuilt by `format!` *inside* the loop over drained entries, so a + /// frame carrying a full [`TELEMETRY_DRAIN_BATCH`] paid up to four heap allocations and four + /// key hashes per entry — ~256 per frame, at frame rate — to recompute four compile-time + /// constants. + /// + /// Asserted with D-7.5's `assert_no_alloc` harness rather than by counting allocations + /// indirectly: `read_meters` is not audio-thread code, but "this loop must allocate nothing" + /// is exactly what that harness answers, and it is the only mechanism in this crate that can + /// fail if the `format!` comes back. Real blocks are processed first so the drain has real + /// entries — a drain of zero entries never enters the loop at all and would pass whatever the + /// loop body did. + #[test] + fn draining_telemetry_entries_allocates_nothing_per_entry() { + let dir = temp_dir("telemetry_drain_alloc"); + let (mut host, mut engine) = build_host(&dir); + + let mut left = [0.0f32; BLOCK]; + let mut right = [0.0f32; BLOCK]; + for _ in 0..8 { + left.fill(0.5); + right.fill(0.5); + let mut channels: [&mut [f32]; 2] = [&mut left, &mut right]; + let mut io = namir_engine::StageIo::new(&mut channels, BLOCK); + engine.process(&mut io); + } + + let before = host.output_meter.peak_db; + crate::rt_harness::audio_section(|| host.read_meters()); + // The drain really did carry output-channel entries, or the loop above was never entered + // and the assertion inside the harness held over nothing. + assert_ne!( + host.output_meter.peak_db, before, + "no output telemetry was drained -- this test asserted nothing" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The hoisted constants are the same ids the removed `format!`-per-entry helpers produced. + /// Cheap, and the one thing a compile-time array cannot get wrong loudly: a typo in a key + /// would silently stop matching the meter it names. + #[test] + fn the_hoisted_output_telemetry_ids_match_their_keys() { + for ch in 0..MAX_OUTPUT_CHANNELS_SCANNED { + assert_eq!( + TELEMETRY_OUT_PEAK_DB[ch], + namir_params::ParamId::from_key(&format!("telemetry.out.ch{ch}.peak_db")).0 + ); + assert_eq!( + TELEMETRY_OUT_AVERAGE_DB[ch], + namir_params::ParamId::from_key(&format!("telemetry.out.ch{ch}.average_db")).0 + ); + } + } + + /// **Issue #91: one snapshot must describe one state.** `snapshot` took the state lock twice — + /// once to clone `params`, once to compare the whole state against `last_saved` — and + /// `crate::worker`'s `LoadState` arm replaces the entire `State` behind that mutex from the + /// worker thread. A recall landing between the two acquisitions therefore produced a frame + /// whose parameter values and whose unsaved marker disagreed. + /// + /// Driven as a race rather than by injecting a delay, because the defect *is* a race and this + /// crate has no seam to pause `snapshot` halfway through: a writer thread swaps the shared + /// `State` between exactly `last_saved` and a modified copy as fast as it can, while this + /// thread snapshots repeatedly and asserts the two fields agree. With one guard the assertion + /// cannot fail; with two it fails within a few thousand iterations on this machine. + #[test] + fn a_snapshot_reads_its_params_and_its_unsaved_flag_from_one_state() { + let dir = temp_dir("snapshot_atomicity"); + let (mut host, _engine) = build_host(&dir); + let key = namir_params::stages::trim::GAIN_DB.key; + + let saved = host.last_saved.clone(); + let mut modified = saved.clone(); + modified.params.set(key, -12.0).unwrap(); + let saved_params = saved.params.clone(); + + let state = Arc::clone(&host.state); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop_writer = Arc::clone(&stop); + let writer = std::thread::spawn(move || { + let mut recalled = false; + while !stop_writer.load(Ordering::Relaxed) { + // Whole-`State` replacement, which is exactly what `crate::worker`'s `LoadState` + // arm does when a preset is recalled. + *state.lock().unwrap_or_else(|e| e.into_inner()) = if recalled { + modified.clone() + } else { + saved.clone() + }; + recalled = !recalled; + } + }); + + for _ in 0..20_000 { + let snapshot = host.snapshot(); + assert_eq!( + snapshot.unsaved_changes, + snapshot.params != saved_params, + "the snapshot's params and its unsaved marker came from different states" + ); + } + + stop.store(true, Ordering::Relaxed); + writer.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **FR-IO-070 end to end on the UI side (issue #88).** A failure pushed onto the direction's + /// ring — which is what `crate::stream`'s error callback does — becomes a notice on the next + /// snapshot, carrying the catalogue entry its *classification* chose (issue #44) and a detail + /// naming the side and the device. All the formatting happens here, on the UI thread; nothing + /// on the callback side built a string at all. + #[test] + fn a_failure_pushed_onto_the_ring_becomes_a_notice_naming_its_side_and_device() { + let dir = temp_dir("stream_failure_notice"); + let (mut host, _engine) = build_host(&dir); + + let (mut input_tx, input_rx) = rtrb::RingBuffer::new(4); + let (mut output_tx, output_rx) = rtrb::RingBuffer::new(4); + host.watch_stream_failures(StreamFailureWatch::new( + input_rx, + output_rx, + "Line (AudioBox 22VSL)".to_string(), + "Speakers (AudioBox 22VSL)".to_string(), + )); + + input_tx.push(StreamFailure::DeviceLost).unwrap(); + output_tx + .push(StreamFailure::Other(crate::audio_io::InlineDetail::from( + "the requested buffer size is not supported", + ))) + .unwrap(); + + let notices = host.snapshot().notices; + assert_eq!(notices.len(), 2, "{notices:?}"); + + let lost = ¬ices[0]; + assert_eq!(lost.code.id, crate::error_codes::DEVICE_LOST.id); + assert!(lost.detail.contains("input"), "{}", lost.detail); + assert!( + lost.detail.contains("Line (AudioBox 22VSL)"), + "{}", + lost.detail + ); + + // Unclassified, so it must *not* be promoted to a device loss -- the safe direction. + let other = ¬ices[1]; + assert_eq!(other.code.id, crate::error_codes::STREAM_FAILED.id); + assert!(other.detail.contains("output"), "{}", other.detail); + assert!( + other.detail.contains("buffer size is not supported"), + "{}", + other.detail + ); + // No `Debug` rendering anywhere in what a user reads (issue #44). + assert!(!other.detail.contains("Other("), "{}", other.detail); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// A host with no streams behind it (`crate::app`'s `open_window_without_audio`) never calls + /// [`AppHost::watch_stream_failures`], and snapshotting must not care. + #[test] + fn a_host_with_no_stream_watch_snapshots_normally() { + let dir = temp_dir("no_stream_watch"); + let (mut host, _engine) = build_host(&dir); + assert!(host.snapshot().notices.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **Issue #76's UI-thread end.** A non-`Elevated` outcome posted by the audio callback + /// becomes exactly one notice, carrying `ThreadPriorityOutcome::diagnostic`'s own catalogue + /// entry and a detail naming what the OS answered — and an `Elevated` one becomes none, since + /// there is nothing to tell anybody about a request that succeeded. + #[test] + fn a_refused_thread_elevation_becomes_exactly_one_notice() { + let dir = temp_dir("thread_priority_notice"); + let (mut host, _engine) = build_host(&dir); + let report = Arc::new(ThreadPriorityReport::new()); + host.watch_thread_priority(Arc::clone(&report)); + + // Nothing posted yet: the audio callback has not run. + assert!(host.snapshot().notices.is_empty()); + + report.post(namir_platform::ThreadPriorityOutcome::OsError( + -2_147_024_882, + )); + let notices = host.snapshot().notices; + assert_eq!(notices.len(), 1, "{notices:?}"); + assert_eq!( + notices[0].code.id, + namir_platform::error_codes::THREAD_PRIORITY_NOT_ELEVATED.id + ); + assert!( + notices[0].detail.contains("-2147024882"), + "the raw OS code is what FR-ERR-050's bundle wants: {}", + notices[0].detail + ); + + // Polled every frame, reported once. + assert_eq!(host.snapshot().notices.len(), 1); + assert_eq!(host.snapshot().notices.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The successful case says nothing, which is the point of `diagnostic()` returning `None` for + /// it: a notice per launch reading "your audio thread is fine" is noise. + #[test] + fn a_successful_thread_elevation_produces_no_notice() { + let dir = temp_dir("thread_priority_ok"); + let (mut host, _engine) = build_host(&dir); + let report = Arc::new(ThreadPriorityReport::new()); + host.watch_thread_priority(Arc::clone(&report)); + report.post(namir_platform::ThreadPriorityOutcome::Elevated); + assert!(host.snapshot().notices.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The detail is written per outcome rather than shared, and each names the thing a reader + /// would look for -- the privilege for a denial, the raw code for an OS error, the target for + /// an unsupported platform. + #[test] + fn each_elevation_outcome_gets_a_detail_that_says_what_happened() { + use namir_platform::ThreadPriorityOutcome as Outcome; + assert!(thread_priority_detail(Outcome::PermissionDenied).contains("privilege")); + assert!(thread_priority_detail(Outcome::OsError(-5)).contains("-5")); + assert!(thread_priority_detail(Outcome::Unsupported).contains("target")); + } + + /// Polls `host` until `ready` holds, or gives up. The worker thread is a real thread, so every + /// test that dispatches an intent and then looks at the result has to wait for one; bounded + /// only by test-timeout hygiene, since the event is guaranteed to arrive eventually. + fn snapshot_until( + host: &mut AppHost, + mut ready: impl FnMut(&UiSnapshot) -> bool, + ) -> UiSnapshot { + let mut snapshot = host.snapshot(); + for _ in 0..400 { + if ready(&snapshot) { + return snapshot; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + snapshot = host.snapshot(); + } + snapshot + } + + /// **FR-STATE-030's save half, end to end.** `UiIntent::SavePreset` carries a *name*; this + /// host resolves it to `/.namirpreset` (`crate::presets`), the worker writes + /// it, and the next listing contains it — which is the list `UiSnapshot::presets` hands the + /// recall control. + #[test] + fn saving_a_named_preset_writes_it_and_it_appears_in_the_next_listing() { + let dir = temp_dir("preset_save"); + let (mut host, _engine) = build_host(&dir); + let preset_dir = crate::presets::preset_dir_under(&dir); + host.watch_presets(preset_dir.clone()); + + host.dispatch(UiIntent::SavePreset { + name: " Crunch Rhythm ".to_string(), + }); + + let snapshot = snapshot_until(&mut host, |s| !s.presets.is_empty()); + assert_eq!( + snapshot + .presets + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + vec!["Crunch Rhythm"], + "the name is trimmed, and the listing names presets by file stem" + ); + assert_eq!( + snapshot.presets[0].path, + preset_dir.join("Crunch Rhythm.namirpreset") + ); + assert!(snapshot.presets[0].path.is_file()); + assert!( + snapshot.notices.is_empty(), + "a successful save reports nothing: {:?}", + snapshot.notices + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The preset directory is created on demand -- a first save into a configuration directory + /// that has never held one must not fail for want of a `mkdir`. + #[test] + fn the_first_save_creates_the_preset_directory() { + let dir = temp_dir("preset_mkdir"); + let preset_dir = crate::presets::preset_dir_under(&dir); + assert!(!preset_dir.exists()); + let (mut host, _engine) = build_host(&dir); + host.watch_presets(preset_dir.clone()); + host.dispatch(UiIntent::SavePreset { + name: "First".to_string(), + }); + snapshot_until(&mut host, |s| !s.presets.is_empty()); + assert!(preset_dir.join("First.namirpreset").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `UiIntent::SavePreset`'s own doc comment: a name illegal as a filename is *the host's* to + /// reject, and to report. Nothing may be written anywhere, least of all outside the preset + /// directory. + #[test] + fn a_preset_name_that_could_escape_the_directory_is_refused_with_a_notice() { + let dir = temp_dir("preset_hostile_name"); + let (mut host, _engine) = build_host(&dir); + let preset_dir = crate::presets::preset_dir_under(&dir); + host.watch_presets(preset_dir.clone()); + + host.dispatch(UiIntent::SavePreset { + name: "../escaped".to_string(), + }); + let snapshot = host.snapshot(); + assert_eq!(snapshot.notices.len(), 1, "{:?}", snapshot.notices); + assert_eq!( + snapshot.notices[0].code.id, + local_error_codes::PRESET_NAME_REFUSED.id + ); + assert!(!dir.join("escaped.namirpreset").exists()); + assert!(!preset_dir.exists(), "nothing was written at all"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// P8: a session with no per-user configuration directory still runs; only named presets are + /// unavailable, and asking for one says so rather than failing silently. + #[test] + fn a_host_with_no_preset_directory_reports_rather_than_writing() { + let dir = temp_dir("preset_no_dir"); + let (mut host, _engine) = build_host(&dir); + // Deliberately never calls `watch_presets`. + host.dispatch(UiIntent::SavePreset { + name: "Anything".to_string(), + }); + let snapshot = host.snapshot(); + assert_eq!(snapshot.notices.len(), 1, "{:?}", snapshot.notices); + assert_eq!( + snapshot.notices[0].code.id, + local_error_codes::PRESET_LOCATION_UNKNOWN.id + ); + assert!(snapshot.presets.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **FR-STATE-030's recall half.** A preset saved at one parameter value, recalled after the + /// value changed, puts it back — and clears the unsaved marker with it, since `last_saved` is + /// updated on `StateLoaded`. + #[test] + fn recalling_a_preset_restores_the_parameter_values_it_was_saved_with() { + let dir = temp_dir("preset_recall"); + let (mut host, _engine) = build_host(&dir); + host.watch_presets(crate::presets::preset_dir_under(&dir)); + let key = namir_params::stages::trim::GAIN_DB.key; + + host.dispatch(UiIntent::SetParam { key, value: -18.0 }); + host.dispatch(UiIntent::SavePreset { + name: "Quiet".to_string(), + }); + let snapshot = snapshot_until(&mut host, |s| !s.presets.is_empty()); + let path = snapshot.presets[0].path.clone(); + + host.dispatch(UiIntent::SetParam { key, value: -3.0 }); + assert_eq!(host.snapshot().params.get(key), Some(-3.0)); + + host.dispatch(UiIntent::RecallPreset { path }); + let snapshot = snapshot_until(&mut host, |s| s.params.get(key) == Some(-18.0)); + assert_eq!(snapshot.params.get(key), Some(-18.0)); + assert!( + !snapshot.unsaved_changes, + "a recall is the new baseline, so nothing is unsaved" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **FR-STATE-060/-070, and the prerequisite the save control could not ship without.** Until + /// this pass `grep FileRef crates/namir-app/src/` was empty: nothing in this shell ever built + /// a reference, so `AppCommand::SaveState` serialised a `State` whose `nam`/`ir` were always + /// `None` and every preset silently forgot which model and IR were loaded. A save button that + /// quietly loses the user's setup is worse than no save button, so this asserts the reference + /// actually reaches the file: its content hash (P7's identity), its display name (FR-STATE-070's + /// "the user shall be shown the missing file's name") and its originating absolute path. + /// + /// Driven with a generated IR rather than a `.nam` only because the fixture is one line + /// (D-19.1: every fixture is generated, never captured); `crate::worker`'s recording step is + /// the same code for both targets. + #[test] + fn a_saved_preset_remembers_which_resource_was_loaded() { + let dir = temp_dir("preset_references"); + let (mut host, _engine) = build_host(&dir); + host.watch_presets(crate::presets::preset_dir_under(&dir)); + + let ir_path = dir.join("cab.wav"); + let ir_bytes = + namir_fixtures::ir::to_mono_wav_bytes(&namir_fixtures::ir::delta(64), 48_000); + std::fs::write(&ir_path, &ir_bytes).unwrap(); + + host.dispatch(UiIntent::LoadLibraryEntry(ir_path.clone())); + let snapshot = snapshot_until(&mut host, |s| s.loaded_ir_name.is_some()); + assert_eq!( + snapshot.loaded_ir_name.as_deref(), + Some("cab.wav"), + "{:?}", + snapshot.notices + ); + + host.dispatch(UiIntent::SavePreset { + name: "WithCab".to_string(), + }); + let snapshot = snapshot_until(&mut host, |s| !s.presets.is_empty()); + let written = std::fs::read(&snapshot.presets[0].path).unwrap(); + + let (recalled, _warnings) = namir_state::State::read(&written).unwrap(); + let reference = recalled + .ir + .expect("the preset must remember the IR that was loaded (FR-STATE-070)"); + assert_eq!(reference.hash, namir_core::ContentHash::of(&ir_bytes)); + assert_eq!(reference.display_name, "cab.wav"); + assert_eq!( + reference.absolute.as_deref(), + Some(ir_path.to_string_lossy().as_ref()) + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// FR-UI-070: dismissing a notice removes exactly that one. #[test] fn dismiss_notice_removes_only_the_named_notice() { diff --git a/crates/namir-app/src/lib.rs b/crates/namir-app/src/lib.rs index 915cb5b..31d6038 100644 --- a/crates/namir-app/src/lib.rs +++ b/crates/namir-app/src/lib.rs @@ -36,6 +36,10 @@ //! - [`settings`] — FR-IO-080's persistence. //! - [`xrun`] — FR-IO-060's dropout counter. //! - [`latency`] — FR-IO-050's round-trip figure. +//! - [`presets`] — FR-STATE-030's named-preset locations, naming rule and listing. **Its +//! `preset_dir_under` belongs in `namir-platform`** beside `config_dir`, shared with +//! `namir-clap`'s identical `crates/namir-clap/src/presets.rs`; see that module's own doc +//! comment for why it is duplicated today and what hoisting it costs. //! - [`bridge`] — the input->output ring buffer and its own xrun detection. //! - [`instance`] — [`instance::SharedInstance`], the `Mutex`-guarded `namir_worker::Instance` //! shared between [`host`] and [`worker`] (see that module's doc comment). @@ -64,6 +68,7 @@ pub mod error_codes; pub mod host; pub mod instance; pub mod latency; +pub mod presets; #[cfg(test)] mod rt_harness; pub mod settings; diff --git a/crates/namir-app/src/presets.rs b/crates/namir-app/src/presets.rs new file mode 100644 index 0000000..6c98fdf --- /dev/null +++ b/crates/namir-app/src/presets.rs @@ -0,0 +1,202 @@ +//! FR-STATE-030's named-preset half, for the standalone application: where a `.namirpreset` lives, +//! how the set of them is listed for [`namir_ui::UiSnapshot::presets`], and the naming rule the two +//! file operations [`namir_ui::UiIntent::SavePreset`]/`RecallPreset` are held to. +//! +//! # ⚠ This resolution belongs in `namir-platform`, not here ⚠ +//! +//! FR-STATE-030's presets are "interchangeable between the two products", and interchangeability +//! fails at the *discovery* step — not at the format — if the two shells look in two different +//! directories. `namir-worker`'s [`namir_worker::library::LibraryService::open_default`] is this +//! workspace's own precedent and its own written warning: `namir-app` and `namir-clap` each +//! computing the library's default location independently is what let their library wiring drift +//! apart once already, and the fix was to make one function the only way either shell can ask. +//! +//! The same fix is owed here, and this module is **not** it: D-13.2 puts filesystem locations in +//! `namir-platform` ("Filesystem locations, config directories, log sinks … live in +//! `namir-platform` and nowhere else"), so [`preset_dir_under`] below should be a `preset_dir()` +//! beside `namir_platform::config_dir()`/`log_file_path()`, with `namir-clap` calling the same +//! function. It is here only because this change could not touch another crate. +//! +//! **`crates/namir-clap/src/presets.rs` is the other copy, and the two agree by construction of +//! this file**: the directory name (`Presets`, chosen to match `LibraryService::open_at`'s own +//! `/Library`), the extension, [`sanitise_name`]'s rejection set and +//! [`list_presets`]'s "regular files only, named by stem, sorted, unreadable directory is an empty +//! list" semantics are the same rule written twice. Hoisting it is a two-caller deletion; changing +//! either copy alone silently breaks FR-STATE-030's interchangeability claim at discovery. +//! +//! # Naming +//! +//! [`namir_ui::UiIntent::SavePreset`] carries "a name, not a path", already trimmed and non-empty, +//! and says in as many words that a name illegal as a filename is *the host's* to reject. This +//! module is that host: [`sanitise_name`] refuses anything that could escape the preset directory +//! or name something other than a plain file in it, and [`crate::host::AppHost`] reports the +//! refusal as an FR-UI-070 notice rather than writing somewhere the user did not ask for. + +use std::path::{Path, PathBuf}; + +use namir_ui::PresetSummary; + +/// The extension `docs/04-state-and-preset-format.md` gives the preset document. +pub const PRESET_EXTENSION: &str = "namirpreset"; + +/// The subdirectory of the per-user configuration directory both products must agree on. +pub const PRESET_DIR_NAME: &str = "Presets"; + +/// The preset directory under an already-resolved configuration directory. +/// +/// Takes the configuration directory rather than resolving one, because this crate has two: +/// [`namir_platform::config_dir`], and [`crate::startup_probe`]'s override of it, which points a +/// NFR-PERF-030 measurement run at a directory the harness owns. A probed launch never opens a +/// window and so never lists or writes a preset, but taking the directory as a parameter is what +/// keeps that true by construction rather than by argument. +#[must_use] +pub fn preset_dir_under(config_dir: &Path) -> PathBuf { + config_dir.join(PRESET_DIR_NAME) +} + +/// The file a preset called `name` is stored in, or `None` if `name` is not one this shell will +/// write — see [`sanitise_name`]. +#[must_use] +pub fn preset_path(dir: &Path, name: &str) -> Option { + Some(dir.join(format!("{}.{PRESET_EXTENSION}", sanitise_name(name)?))) +} + +/// The name, if it is one that can only ever name a plain file directly inside the preset +/// directory. +/// +/// Rejected: anything empty once trimmed, anything containing a path separator of either platform +/// (so a name can never reach a sibling directory), anything that is `.` or `..`, anything with a +/// Windows drive prefix, and anything containing a character Windows refuses in a filename. The +/// last is checked on every platform on purpose: a preset saved on Linux under a name Windows +/// cannot represent would be a preset the other half of FR-STATE-030's interchangeability claim +/// cannot open. +#[must_use] +pub fn sanitise_name(name: &str) -> Option<&str> { + let name = name.trim(); + if name.is_empty() || name == "." || name == ".." { + return None; + } + if name.chars().any(|c| { + matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() + }) { + return None; + } + Some(name) +} + +/// Every `.namirpreset` directly inside `dir`, named by its file stem, sorted by name. +/// +/// Non-recursive, and a directory that does not exist (or cannot be read) is an empty list rather +/// than an error: "no presets saved yet" is the ordinary first-run state, and there is nothing for +/// a user to act on in being told about it. The empty list is what the UI renders as a disabled +/// recall control, which is what [`namir_ui::UiSnapshot::presets`] documents for "the host knows +/// of none". +/// +/// **Blocking:** this reads a directory, so it runs on [`crate::worker`]'s thread, never inside +/// [`namir_ui::UiHost::snapshot`]. +#[must_use] +pub fn list_presets(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut presets: Vec = entries + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case(PRESET_EXTENSION)) + }) + .filter_map(|path| { + let name = path.file_stem()?.to_string_lossy().into_owned(); + Some(PresetSummary { name, path }) + }) + .collect(); + // A deterministic order, so the list does not reshuffle between frames on a filesystem whose + // `read_dir` order is not stable. + presets.sort_by(|a, b| a.name.cmp(&b.name)); + presets +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "namir-app-presets-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// FR-STATE-030's "interchangeable between the two products" begins at discovery: this shell + /// and `namir-clap` must name the same directory under the same configuration root. Pinned on + /// the two constants that encode it, since the other copy is in a crate this test cannot see. + #[test] + fn the_preset_directory_sits_beside_the_library_under_the_shared_config_directory() { + assert_eq!(PRESET_DIR_NAME, "Presets"); + assert_eq!(PRESET_EXTENSION, "namirpreset"); + let config = Path::new("/somewhere/config"); + assert_eq!( + preset_dir_under(config), + config.join("Presets"), + "both shells must resolve presets under the one config directory they share, the way \ + LibraryService::open_at resolves /Library" + ); + } + + #[test] + fn a_name_that_could_escape_the_preset_directory_is_refused() { + for hostile in [ + "../evil", + "..\\evil", + "sub/dir", + "sub\\dir", + "C:evil", + "..", + ".", + " ", + "bad\u{0}name", + ] { + assert!( + sanitise_name(hostile).is_none(), + "{hostile:?} must not be accepted as a preset name" + ); + assert!(preset_path(Path::new("/presets"), hostile).is_none()); + } + assert_eq!(sanitise_name(" Crunch Rhythm "), Some("Crunch Rhythm")); + assert_eq!( + preset_path(Path::new("/presets"), " Crunch Rhythm "), + Some(PathBuf::from("/presets").join("Crunch Rhythm.namirpreset")) + ); + } + + #[test] + fn listing_finds_only_preset_files_and_names_them_by_stem() { + let dir = temp_dir("listing"); + std::fs::write(dir.join("Clean.namirpreset"), b"{}").unwrap(); + std::fs::write(dir.join("Lead.NAMIRPRESET"), b"{}").unwrap(); + std::fs::write(dir.join("notes.txt"), b"x").unwrap(); + std::fs::create_dir_all(dir.join("Nested.namirpreset")).unwrap(); + + let presets = list_presets(&dir); + let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); + assert_eq!( + names, + vec!["Clean", "Lead"], + "only regular .namirpreset files, named by stem, sorted" + ); + assert_eq!(presets[0].path, dir.join("Clean.namirpreset")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_directory_that_does_not_exist_lists_nothing_rather_than_failing() { + let dir = temp_dir("absent").join("never-created"); + assert!(list_presets(&dir).is_empty()); + } +} diff --git a/crates/namir-app/src/stream.rs b/crates/namir-app/src/stream.rs index 0cdd518..c85a0f5 100644 --- a/crates/namir-app/src/stream.rs +++ b/crates/namir-app/src/stream.rs @@ -10,13 +10,14 @@ //! "first call inside the callback, gated by a one-shot flag" is the only way to satisfy both //! halves of that constraint at once). //! - Runs [`namir_engine::AudioEngine::process`] itself. -//! - Counts FR-IO-060's bridge-underrun dropouts directly, via -//! [`crate::bridge::BridgeConsumer::pull_into`]'s own return value. `cpal`'s own +//! - Counts **both** of FR-IO-060's bridge dropouts directly: the output callback's underrun, via +//! [`crate::bridge::BridgeConsumer::pull_into`]'s own return value, and — since issue #85 — the +//! input callback's overrun, via [`crate::bridge::BridgeProducer::push_captured`]'s. `cpal`'s own //! `StreamFailure::Xrun` reports arrive through the same `on_failure` callback every other //! stream error does; classifying it into the same [`crate::xrun::XrunCounter`] (rather than //! surfacing it as a one-off notice the way `StreamFailure::DeviceLost`/`Other` are) is //! [`crate::app`]'s job, since that is also where the counter this module increments for -//! bridge underruns lives. +//! bridge under- and overruns lives. //! //! # Why the engine runs in the *output* callback, not the input one //! @@ -45,12 +46,12 @@ //! `docs/manual-tests/fr-io-090-channel-mapping.md`. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering}; use std::time::Duration; use namir_core::ChannelConfig; use namir_engine::{AudioEngine, StageIo}; -use namir_platform::{DenormalGuard, elevate_current_thread_priority}; +use namir_platform::{DenormalGuard, ThreadPriorityOutcome, elevate_current_thread_priority}; use crate::audio_io::{ AudioBackend, AudioStream, DeviceInfo, HostInfo, StreamFailure, StreamParams, @@ -107,11 +108,107 @@ pub struct StreamSetup<'a> { pub max_block_size: usize, } +/// `state` values for [`ThreadPriorityReport`]. Plain `u8`s rather than a `#[repr(u8)]` enum +/// because they live in an `AtomicU8` and the decode is a `match` either way. +const PRIORITY_PENDING: u8 = 0; +const PRIORITY_ELEVATED: u8 = 1; +const PRIORITY_DENIED: u8 = 2; +const PRIORITY_OS_ERROR: u8 = 3; +const PRIORITY_UNSUPPORTED: u8 = 4; +const PRIORITY_CONSUMED: u8 = 5; + +/// Where the output callback leaves D-13.2's thread-elevation outcome for a non-audio thread to +/// read and report (issue #76). +/// +/// # Why the outcome cannot simply be logged where it happens +/// +/// A thread can only raise *its own* priority, and `cpal` offers no pre-callback hook, so +/// [`namir_platform::elevate_current_thread_priority`] has to be called from inside the first +/// output callback — see this module's own doc comment. That is the audio thread, where FR-ERR-030 +/// forbids logging and formatting for logging, where `xtask rt-logging` fails the build if this +/// module so much as names the logger, and where D-7.5's harness fails on a `format!`. The +/// outcome is nevertheless worth having: `ThreadPriorityOutcome` is `#[must_use]` precisely +/// because "expected and non-fatal" is not "ignorable", and a user reporting xruns on Linux +/// deserves to be told their process never got the priority it asked for rather than to guess. +/// +/// So the outcome travels instead of being reported: it is `Copy` and eight bytes, and this type +/// is the "an atomic ... is enough" carrier `ThreadPriorityOutcome::diagnostic`'s own doc comment +/// nominates. Posting is two atomic stores; [`crate::host::AppHost`] takes it on a later frame and +/// writes the FR-ERR-010 record from the UI thread. +/// +/// **This is what `let _ = elevate_current_thread_priority();` used to be.** That discarded the +/// distinction between "elevated" and "the OS refused", which is the only distinction the value +/// carries. +#[derive(Debug)] +pub struct ThreadPriorityReport { + /// One of the `PRIORITY_*` constants above. + state: AtomicU8, + /// The raw OS code behind [`PRIORITY_OS_ERROR`]; meaningless for every other state. + os_error: AtomicI64, +} + +impl Default for ThreadPriorityReport { + fn default() -> Self { + Self::new() + } +} + +impl ThreadPriorityReport { + /// A report nothing has been posted to yet. + #[must_use] + pub fn new() -> Self { + Self { + state: AtomicU8::new(PRIORITY_PENDING), + os_error: AtomicI64::new(0), + } + } + + /// Records `outcome`. **RT-safe:** two atomic stores, no allocation, no lock, no formatting. + /// Called once, from the output callback's first invocation. + /// + /// `pub(crate)` rather than private so [`crate::host`]'s own tests can post an outcome this + /// machine does not produce — a `PermissionDenied` on a box that grants the elevation, say. + /// Not `pub`: the only legitimate producer is this module's output callback. + pub(crate) fn post(&self, outcome: ThreadPriorityOutcome) { + let (state, os_error) = match outcome { + ThreadPriorityOutcome::Elevated => (PRIORITY_ELEVATED, 0), + ThreadPriorityOutcome::PermissionDenied => (PRIORITY_DENIED, 0), + ThreadPriorityOutcome::OsError(code) => (PRIORITY_OS_ERROR, code), + ThreadPriorityOutcome::Unsupported => (PRIORITY_UNSUPPORTED, 0), + }; + self.os_error.store(os_error, Ordering::Relaxed); + // Release, paired with the `Acquire` in `take`, so a reader that sees `PRIORITY_OS_ERROR` + // also sees the code stored just above it. + self.state.store(state, Ordering::Release); + } + + /// The outcome the audio thread posted, **once**: a second call returns `None` unless the + /// audio thread has posted again, so a caller polling every frame reports one notice rather + /// than one per frame. `None` also while the first output callback has not yet run — a stream + /// that never starts never elevates anything, and has nothing to say about it. + #[must_use] + pub fn take(&self) -> Option { + match self.state.swap(PRIORITY_CONSUMED, Ordering::AcqRel) { + PRIORITY_ELEVATED => Some(ThreadPriorityOutcome::Elevated), + PRIORITY_DENIED => Some(ThreadPriorityOutcome::PermissionDenied), + PRIORITY_OS_ERROR => Some(ThreadPriorityOutcome::OsError( + self.os_error.load(Ordering::Relaxed), + )), + PRIORITY_UNSUPPORTED => Some(ThreadPriorityOutcome::Unsupported), + // `PRIORITY_PENDING` (nothing posted yet) and `PRIORITY_CONSUMED` (already reported) + // are both "nothing to say"; writing `CONSUMED` over `PENDING` is harmless because + // `post` stores unconditionally, so a later post is still seen by a later `take`. + _ => None, + } + } +} + /// The running duplex path. Dropping this stops both streams (`AudioStream`'s own drop-stops /// contract, per `crate::audio_io`'s doc comment). pub struct RunningStreams { _input: Box, _output: Box, + thread_priority: Arc, } impl RunningStreams { @@ -127,40 +224,60 @@ impl RunningStreams { self._input.pause()?; self._output.pause() } + + /// D-13.2's elevation outcome, for a non-audio thread to report (issue #76). Handed to + /// [`crate::host::AppHost`] by [`crate::app::run`]; see [`ThreadPriorityReport`] for why the + /// outcome travels rather than being reported where it is produced. + #[must_use] + pub fn thread_priority(&self) -> Arc { + Arc::clone(&self.thread_priority) + } } /// Opens the duplex path described by `setup`, running `engine` from the output callback. -/// `on_failure` is called (from whichever callback thread detected it, per FR-IO-070's "shall not -/// crash or hang") with which side failed and why; the caller is expected to stop using the -/// returned [`RunningStreams`] and report the condition to the user (FR-IO-070's own wording) — -/// this function does not itself decide when the stream is unrecoverable, since that judgement -/// belongs to whatever owns retry/reselection policy ([`crate::app`]). +/// +/// `on_input_failure`/`on_output_failure` are called (from whichever callback thread detected it, +/// per FR-IO-070's "shall not crash or hang") with why that side failed; the caller is expected to +/// stop using the returned [`RunningStreams`] and report the condition to the user (FR-IO-070's +/// own wording) — this function does not itself decide when the stream is unrecoverable, since +/// that judgement belongs to whatever owns retry/reselection policy ([`crate::app`]). +/// +/// **One callback per direction, `FnMut`, rather than one shared `Fn` (issue #88).** These run on +/// `cpal`'s error-callback threads, which are the streams' own threads — so they are audio-thread +/// code, and the caller has to be able to put a *pre-allocated, single-producer* sink in each one +/// rather than format a message and send it down an `mpsc` channel. A single `Fn + Sync` shared +/// between both directions cannot hold one: two threads would be writing one producer. Splitting +/// the parameter is what lets [`crate::app::stream_failure_sink`] own an `rtrb::Producer` per +/// direction, which is what makes the whole path allocation-free. pub fn open( setup: StreamSetup<'_>, engine: AudioEngine, xruns: Arc, - on_failure: impl Fn(Direction, StreamFailure) + Send + Sync + 'static, + on_input_failure: impl FnMut(StreamFailure) + Send + 'static, + on_output_failure: impl FnMut(StreamFailure) + Send + 'static, ) -> Result { let capacity = (setup.max_block_size.max(1) * 8).next_power_of_two(); let (producer, consumer) = bridge(capacity); let input_channel_index = setup.input_channel_index as usize; let input_channels = setup.input_params.channels as usize; - let on_failure: Arc = Arc::new(on_failure); let input_stream = build_input( &setup, producer, input_channel_index, input_channels, - Arc::clone(&on_failure), + Arc::clone(&xruns), + Box::new(on_input_failure), )?; + let thread_priority = Arc::new(ThreadPriorityReport::new()); let output_stream = match build_output( &setup, engine, consumer, Arc::clone(&xruns), - Arc::clone(&on_failure), + Arc::clone(&thread_priority), + Box::new(on_output_failure), ) { Ok(s) => s, Err(e) => { @@ -172,6 +289,7 @@ pub fn open( Ok(RunningStreams { _input: input_stream, _output: output_stream, + thread_priority, }) } @@ -180,7 +298,8 @@ fn build_input( mut producer: BridgeProducer, channel_index: usize, channel_count: usize, - on_failure: Arc, + xruns: Arc, + on_error: Box, ) -> Result, crate::audio_io::AudioIoError> { let max_block = setup.max_block_size.max(1); let mut mono_scratch: Vec = Vec::with_capacity(max_block); @@ -201,14 +320,19 @@ fn build_input( .chunks_exact(channel_count) .map(|frame| frame.get(channel_index).copied().unwrap_or(0.0)), ); - producer.push_captured(&mono_scratch); + // FR-IO-060's *other* dropout, and until issue #85 it was thrown away: this return + // value is how many captured samples did not fit because the ring was full, which is + // a real dropout of exactly the class `crate::bridge` exists to detect. Discarding it + // did not merely lose detail — it made the session count under-report, which is the + // worst direction for a diagnostic, because a user watching a zero while their audio + // glitches concludes the counter works and the glitch is elsewhere. Counted the same + // way `build_output` counts an underrun below: one xrun per callback chunk that lost + // anything, not one per lost sample, so the two sources are commensurable. + if producer.push_captured(&mono_scratch) > 0 { + xruns.record(); + } } }); - let on_error = { - let on_failure = Arc::clone(&on_failure); - Box::new(move |failure: StreamFailure| on_failure(Direction::Input, failure)) - }; - setup.backend.build_input_stream( &setup.input_host, &setup.input_device, @@ -224,7 +348,8 @@ fn build_output( mut engine: AudioEngine, mut consumer: BridgeConsumer, xruns: Arc, - on_failure: Arc, + thread_priority: Arc, + on_error: Box, ) -> Result, crate::audio_io::AudioIoError> { let output_channels = setup.output_params.channels as usize; let left = setup.output_channel_left as usize; @@ -249,9 +374,13 @@ fn build_output( if !priority_elevated.swap(true, Ordering::AcqRel) { // D-13.2: once, lazily, from this callback thread itself -- see this module's doc // comment for why "first call inside the callback" is the only place cpal lets this - // happen. A denial is expected and non-fatal (that module's own doc comment); nothing - // here needs to react to the outcome beyond having attempted it. - let _ = elevate_current_thread_priority(); + // happen. A denial is expected and non-fatal (that module's own doc comment), so + // nothing here reacts to it -- but it is no longer *discarded* (issue #76): the + // `#[must_use]` outcome is posted, in two atomic stores, for `crate::host` to turn + // into an FR-ERR-010 record from the UI thread. This module may not name the logger + // (`xtask rt-logging`) and may not `format!` (D-7.5), which is exactly why the value + // travels instead of being reported here. + thread_priority.post(elevate_current_thread_priority()); } // D-7.4: engaged for the whole callback, not just the `engine.process` call, since this // callback's bridge-pull/write-back arithmetic is also floating point and denormal-prone @@ -310,11 +439,6 @@ fn build_output( } }); - let on_error = { - let on_failure = Arc::clone(&on_failure); - Box::new(move |failure: StreamFailure| on_failure(Direction::Output, failure)) - }; - setup.backend.build_output_stream( &setup.output_host, &setup.output_device, @@ -341,6 +465,12 @@ pub(crate) struct FakeBackend { pub(crate) input_data: std::sync::Mutex>, /// The output callback the last `build_output_stream` captured. pub(crate) output_data: std::sync::Mutex>, + /// The *error* callbacks each direction was opened with. Captured since issue #88, because + /// they are audio-thread code too — `cpal` invokes them on the stream's own thread — and until + /// then this fake dropped them on the floor, so nothing in this crate had ever run one. + pub(crate) input_error: std::sync::Mutex>, + /// As [`FakeBackend::input_error`], for the playback direction. + pub(crate) output_error: std::sync::Mutex>, /// Which device names answer [`ExclusiveModeOutcome::Engaged`] to /// `supports_exclusive`. Every other name answers `Unsupported` — what the real /// [`crate::audio_io::CpalBackend`] answers for any device with no exclusive-capable WASAPI @@ -360,6 +490,8 @@ impl FakeBackend { Self { input_data: std::sync::Mutex::new(None), output_data: std::sync::Mutex::new(None), + input_error: std::sync::Mutex::new(None), + output_error: std::sync::Mutex::new(None), exclusive_devices: Vec::new(), asked_share_modes: std::sync::Mutex::new(Vec::new()), } @@ -401,6 +533,8 @@ impl AudioStream for FakeStream { pub(crate) type InputCallback = Box; #[cfg(test)] pub(crate) type OutputCallback = Box; +#[cfg(test)] +pub(crate) type ErrorCallback = Box; #[cfg(test)] impl AudioBackend for FakeBackend { @@ -460,7 +594,7 @@ impl AudioBackend for FakeBackend { _device: &DeviceInfo, params: StreamParams, on_data: Box, - _on_error: Box, + on_error: Box, _timeout: Duration, ) -> Result, AudioIoError> { self.asked_share_modes @@ -468,6 +602,7 @@ impl AudioBackend for FakeBackend { .unwrap() .push((Direction::Input, params.share_mode)); *self.input_data.lock().unwrap() = Some(on_data); + *self.input_error.lock().unwrap() = Some(on_error); Ok(Box::new(FakeStream)) } fn build_output_stream( @@ -476,7 +611,7 @@ impl AudioBackend for FakeBackend { _device: &DeviceInfo, params: StreamParams, on_data: Box, - _on_error: Box, + on_error: Box, _timeout: Duration, ) -> Result, AudioIoError> { self.asked_share_modes @@ -484,6 +619,7 @@ impl AudioBackend for FakeBackend { .unwrap() .push((Direction::Output, params.share_mode)); *self.output_data.lock().unwrap() = Some(on_data); + *self.output_error.lock().unwrap() = Some(on_error); Ok(Box::new(FakeStream)) } } @@ -581,7 +717,13 @@ mod tests { setup(&backend, 64), engine(64), Arc::clone(&xruns), - move |_dir, _f| { + { + let failures = Arc::clone(&failures_clone); + move |_f| { + failures.fetch_add(1, Ordering::SeqCst); + } + }, + move |_f| { failures_clone.fetch_add(1, Ordering::SeqCst); }, ) @@ -620,7 +762,8 @@ mod tests { setup(&backend, 64), engine(64), Arc::clone(&xruns), - |_, _| {}, + |_| {}, + |_| {}, ) .unwrap(); @@ -641,7 +784,8 @@ mod tests { setup(&backend, 32), engine(32), Arc::clone(&xruns), - |_, _| {}, + |_| {}, + |_| {}, ) .unwrap(); @@ -671,6 +815,14 @@ mod tests { /// new chunking loop does too. The first callback pair is deliberately *outside* the harness: /// `build_output`'s first invocation elevates the thread's priority once (D-13.2), a one-time /// OS call rather than per-callback work, and a real stream pays it once as well. + /// + /// **The warm-up drives the exact-size pair only, and that is load-bearing (issue #87).** It + /// used to drive the oversized pair as well, which grew `build_input`'s `mono_scratch` to the + /// oversized length *before* the harness was armed — so the very regression this test is cited + /// as catching, an unchunked `extend` past the reservation, passed it. Re-planting the + /// unchunked form with the old warm-up in place is green; with this one it fails. Nothing on + /// the output side needs the oversized warm-up: its three buffers are sized at + /// `max_block_size` and its chunking loop keeps every write inside them. #[test] fn the_audio_callbacks_this_module_builds_allocate_nothing() { const MAX_BLOCK: usize = 64; @@ -680,7 +832,8 @@ mod tests { setup(&backend, MAX_BLOCK), engine(MAX_BLOCK), Arc::clone(&xruns), - |_, _| {}, + |_| {}, + |_| {}, ) .unwrap(); @@ -694,11 +847,10 @@ mod tests { let big_in = [0.1f32; 200]; let mut big_out = [0.0f32; 400]; - // Warm-up, un-asserted: see this test's own doc comment. + // Warm-up, un-asserted, and deliberately *only* the exact-size pair: see this test's own + // doc comment for why warming up with the oversized pair blinded it to issue #87. input_cb(&exact_in); output_cb(&mut exact_out); - input_cb(&big_in); - output_cb(&mut big_out); let mut saw_output = false; for _ in 0..32 { @@ -721,6 +873,170 @@ mod tests { ); } + /// **FR-IO-060's capture-side dropout (issue #85).** `BridgeProducer::push_captured` returns + /// how many samples did not fit because the ring was full, and [`build_input`] used to discard + /// it — so whenever capture outran the output callback the samples were dropped and the + /// session count stayed at zero. Under-reporting is the worst direction for a diagnostic: a + /// user watching a stuck zero while their audio glitches concludes the counter works and looks + /// elsewhere. + /// + /// Driven by pushing input with nothing ever pulling: the ring holds + /// `(max_block * 8).next_power_of_two()` samples, so the first few callbacks fit and must + /// count nothing, and the ones past that overrun and must. + // trace-partial: FR-IO-060 + // uncovered: FR-IO-060 — the "resettable by the user" clause has no path to exercise: + // uncovered: XrunCounter::reset has no caller outside its own two unit tests and no UiIntent + // uncovered: reaches it, and the running count surfaces only through an eprintln! rather than + // uncovered: anywhere in the window; closes M8 + #[test] + fn input_capture_that_outruns_the_output_callback_counts_an_xrun() { + const MAX_BLOCK: usize = 64; + let backend = FakeBackend::new(); + let xruns = Arc::new(XrunCounter::new()); + let _streams = open( + setup(&backend, MAX_BLOCK), + engine(MAX_BLOCK), + Arc::clone(&xruns), + |_| {}, + |_| {}, + ) + .unwrap(); + let mut input_cb = backend.input_data.lock().unwrap().take().unwrap(); + + // Comfortably inside the ring's capacity: nothing is lost, so nothing may be counted. + for _ in 0..4 { + input_cb(&[0.1f32; MAX_BLOCK]); + } + assert_eq!( + xruns.count(), + 0, + "capture that fits in the ring is not a dropout" + ); + + // Far past it, still with no output callback draining anything. + for _ in 0..32 { + input_cb(&[0.1f32; MAX_BLOCK]); + } + assert!( + xruns.count() > 0, + "capture that overran the bridge ring must reach the session's xrun count" + ); + } + + /// FR-IO-070, and the wiring half of issue #88: each direction's `cpal` error callback reaches + /// **that direction's** sink and no other. The two are now separate `FnMut`s rather than one + /// shared `Fn` taking a [`Direction`], so a crossed pair would report an input fault as an + /// output one — and would be invisible, since neither closure is handed a direction any more. + /// + /// [`FakeBackend`] captures both error callbacks for this test; before issue #88 it dropped + /// them, so nothing in this crate had ever driven one. + #[test] + fn each_directions_error_callback_reaches_only_that_directions_sink() { + let backend = FakeBackend::new(); + let seen: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let input_seen = Arc::clone(&seen); + let output_seen = Arc::clone(&seen); + let _streams = open( + setup(&backend, 64), + engine(64), + Arc::new(XrunCounter::new()), + move |f| input_seen.lock().unwrap().push((Direction::Input, f)), + move |f| output_seen.lock().unwrap().push((Direction::Output, f)), + ) + .unwrap(); + + let mut input_err = backend.input_error.lock().unwrap().take().unwrap(); + let mut output_err = backend.output_error.lock().unwrap().take().unwrap(); + let driver_fault = StreamFailure::Other(crate::audio_io::InlineDetail::from("OS Error -1")); + output_err(driver_fault); + input_err(StreamFailure::DeviceLost); + + assert_eq!( + *seen.lock().unwrap(), + vec![ + (Direction::Output, driver_fault), + (Direction::Input, StreamFailure::DeviceLost), + ] + ); + } + + /// **Issue #76: D-13.2's elevation outcome is carried off the audio thread, not discarded.** + /// `elevate_current_thread_priority` returns a `#[must_use]` outcome and this callback used to + /// answer it with `let _ = ...`, throwing away the one distinction it carries — "elevated" vs. + /// "the OS refused" — which is exactly the diagnostic a user reporting xruns on a Linux box + /// with no `rtprio` allowance needs. + /// + /// Whatever this machine's OS answers is a property of the machine, not of this code, so what + /// is asserted is the mechanism: nothing is readable before the first callback; something + /// definite is readable after it; and it reads **once**, so a host polling every frame reports + /// one notice rather than one per frame. + #[test] + fn the_first_output_callback_posts_its_elevation_outcome_for_a_non_audio_thread() { + let backend = FakeBackend::new(); + let streams = open( + setup(&backend, 64), + engine(64), + Arc::new(XrunCounter::new()), + |_| {}, + |_| {}, + ) + .unwrap(); + let report = streams.thread_priority(); + assert!( + report.take().is_none(), + "nothing has run yet, so there is nothing to report" + ); + + let mut output_cb = backend.output_data.lock().unwrap().take().unwrap(); + let mut out = [0.0f32; 128]; + output_cb(&mut out); + + assert!( + report.take().is_some(), + "the first output callback must post an outcome, whatever this OS answered" + ); + assert!( + report.take().is_none(), + "a posted outcome is reported once, not once per frame" + ); + + // Later callbacks do not elevate again (the one-shot flag), so nothing more appears. + output_cb(&mut out); + assert!(report.take().is_none()); + } + + /// The carrier itself, over every outcome `namir-platform` can produce -- including the one + /// this machine does not produce. `OsError`'s payload has to survive, since FR-ERR-050's + /// bundle is the intended consumer of that number. + #[test] + fn every_elevation_outcome_survives_the_atomic_round_trip() { + for outcome in [ + ThreadPriorityOutcome::Elevated, + ThreadPriorityOutcome::PermissionDenied, + ThreadPriorityOutcome::OsError(-2_147_024_882), + ThreadPriorityOutcome::Unsupported, + ] { + let report = ThreadPriorityReport::new(); + report.post(outcome); + assert_eq!(report.take(), Some(outcome)); + assert_eq!(report.take(), None); + } + } + + /// Posting is what the audio callback does, so it must allocate nothing -- two atomic stores + /// and no formatting, which is the whole reason the outcome travels rather than being logged + /// where it is produced. + #[test] + fn posting_an_elevation_outcome_allocates_nothing() { + let report = ThreadPriorityReport::new(); + crate::rt_harness::audio_section(|| { + report.post(ThreadPriorityOutcome::OsError(5)); + report.post(ThreadPriorityOutcome::Elevated); + }); + assert_eq!(report.take(), Some(ThreadPriorityOutcome::Elevated)); + } + /// FR-IO-020: whatever share mode [`crate::app`] settled on reaches **both** backend opens /// unchanged. This module does not renegotiate, downgrade or second-guess it — the whole /// all-or-nothing rule (`crate::app::negotiate_share_mode`) would be undone by one direction @@ -733,7 +1049,8 @@ mod tests { setup_with_share_mode(&backend, 64, mode), engine(64), Arc::new(XrunCounter::new()), - |_, _| {}, + |_| {}, + |_| {}, ) .unwrap(); assert_eq!(backend.share_mode_asked_for(Direction::Input), Some(mode)); diff --git a/crates/namir-app/src/worker.rs b/crates/namir-app/src/worker.rs index 798f5fb..6c126ef 100644 --- a/crates/namir-app/src/worker.rs +++ b/crates/namir-app/src/worker.rs @@ -14,12 +14,13 @@ //! uses" (non-blocking). Routing them through this thread instead would add a full channel round //! trip to the single highest-frequency interaction in the whole application for no benefit. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; +use namir_core::ContentHash; use namir_library::LibraryResolver; -use namir_state::State; +use namir_state::{FileRef, RelPath, State}; use namir_worker::library::{LibraryService, ScanHandle, ScanOutcome}; use namir_worker::pool::ThreadPool; use namir_worker::recall::{RecallOutcome, ResourceRecall}; @@ -39,6 +40,10 @@ pub enum AppCommand { SaveState(PathBuf), /// FR-STATE-030: load and recall a saved state from `path`. LoadState(PathBuf), + /// FR-STATE-030's recall half needs a list to choose from: enumerate `` and report it + /// back as [`AppEvent::PresetsListed`]. Off-thread because it reads a directory, which + /// [`crate::host::AppHost::snapshot`] may not do. + ListPresets(PathBuf), /// Stops the worker thread. Sent automatically by [`WorkerHandle`]'s `Drop`. Shutdown, } @@ -75,11 +80,24 @@ pub enum AppEvent { /// Why nothing was recalled, if the file could not be read or parsed at all. error: Option, }, - /// FR-IO-070: an audio stream failed (device lost, or another backend error) — sent by - /// [`crate::stream`]'s own error callback, not by this thread's own loop, via the cloneable - /// sender [`WorkerHandle::event_sender`] hands out. D-16.2's audio-thread-side of this event - /// has already happened by the time this arrives — see `crate::stream`'s own module doc - /// comment for the callback boundary this crosses. + /// FR-STATE-030: the preset directory as last enumerated. Replaces whatever + /// [`crate::host::AppHost`] was showing; an empty list is the ordinary first-run answer, not + /// an error. + PresetsListed(Vec), + /// FR-IO-070: an audio stream failed (device lost, or another backend error). + /// + /// **Not sent by this thread, and since issue #88 not sent through this channel either.** The + /// value is built by [`crate::host::AppHost`] on the UI thread, out of what + /// [`crate::stream`]'s error callbacks pushed into [`crate::host::StreamFailureWatch`]'s + /// bounded rings, and handed straight to `AppHost::handle_event`. It stays an [`AppEvent`] + /// because the handling — issue #44's "the classification picks the catalogue entry" rule — + /// should have exactly one implementation, not because a worker event is what crosses. + /// + /// It used to travel down this `mpsc` channel, sent from inside the `cpal` error callback: + /// that meant a `format!` and a queue-node allocation on the stream's own thread, which + /// NFR-RT-010 and FR-ERR-030 both forbid. D-16.2's audio-thread side of this event has + /// already happened by the time this arrives — see `crate::stream`'s own module doc comment + /// for the callback boundary it crosses. /// /// **Carries the classification as well as a message since M14 (issue #44).** It used to carry /// only `crate::audio_io::StreamFailure`'s **`Debug`** rendering, which had two consequences a @@ -269,9 +287,15 @@ impl WorkerHandle { } /// A cloneable sender onto this handle's own event queue — for a producer other than the - /// worker thread's own loop (`crate::stream`'s error callback, running on an audio callback - /// thread) to report into the same stream [`AppHost`](crate::host::AppHost) already polls, - /// rather than inventing a second queue. + /// worker thread's own loop to report into the same stream + /// [`AppHost`](crate::host::AppHost) already polls, rather than inventing a second queue. + /// + /// Its one caller was `crate::stream`'s error callback, and issue #88 took that away: an + /// `mpsc` send allocates a queue node, which an audio-callback thread may not do. It is kept + /// rather than deleted because the seam is still the right one for any *non*-RT producer, and + /// because a `pub` method with no caller is a smaller thing to carry than a re-derived channel + /// the next such producer would otherwise invent. **Not for a producer on an audio thread** — + /// that is what `crate::host::StreamFailureWatch`'s rings are for. pub fn event_sender(&self) -> mpsc::Sender { self.event_tx.clone() } @@ -286,6 +310,47 @@ impl Drop for WorkerHandle { } } +/// FR-STATE-070: records which file a stage was just given, so a later `SaveState` writes a +/// preset that still knows what to reload. +/// +/// The same three candidates `namir-clap`'s `worker_jobs::record_reference` records, in the same +/// order D-11.3 resolves them in — library-relative first (the one that makes a preset portable +/// between two machines whose library sits at different absolute paths), then the originating +/// absolute path, then the content hash, which is always present and is the identity (P7). +fn record_reference( + ctx: &WorkerContext, + target: Target, + hash: ContentHash, + display_name: String, + path: &Path, +) { + let reference = FileRef { + hash, + library_relative: library_relative_reference(&ctx.library_roots, path), + absolute: Some(path.to_string_lossy().into_owned()), + display_name, + embedded: None, + }; + let mut state = ctx.state.lock().unwrap_or_else(|e| e.into_inner()); + match target { + Target::Nam => state.nam = Some(reference), + Target::Ir => state.ir = Some(reference), + } +} + +/// `path` expressed relative to whichever configured library root contains it, or `None` if it +/// lies outside all of them (a file loaded from somewhere else entirely, for which there is no +/// library-relative form to record). +/// +/// The first containing root wins, matching the order `namir_library::LibraryResolver` itself +/// tries them in, so a path recorded here resolves back to the same file it came from. +fn library_relative_reference(roots: &[PathBuf], path: &Path) -> Option { + roots.iter().find_map(|root| { + let relative = path.strip_prefix(root).ok()?; + RelPath::from_relative_path(relative).ok() + }) +} + fn run(ctx: WorkerContext, commands: mpsc::Receiver, events: mpsc::Sender) { let mut scan_handle: Option = None; for command in commands { @@ -313,13 +378,49 @@ fn run(ctx: WorkerContext, commands: mpsc::Receiver, events: mpsc::S namir_library::ItemKind::Ir => Target::Ir, }; let source_desc = path.display().to_string(); - let outcome = ctx - .instance - .with(|instance| instance.load(&ctx.cache, target, LoadSource::File(path))); + // **Read here, hash here, load from bytes (FR-STATE-060/-070).** This used to be + // `LoadSource::File(path)`, which reads the file inside `Instance::load` and hands + // back nothing but a result — so this shell had no content hash, never built a + // `FileRef`, and `AppCommand::SaveState` below wrote a preset that had silently + // forgotten which model and IR were loaded. P7 makes the content hash the identity + // of a resource, and a `FileRef` cannot be constructed without one. + // `namir-clap`'s `worker_jobs::spawn_load_library_entry` already had exactly this + // shape; this is the same three lines, so the two shells record the same reference + // for the same file. + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + let _ = events.send(AppEvent::LoadFinished { + target, + source: source_desc, + outcome: LoadOutcomeSummary::Failed(namir_worker::WorkerError::new( + namir_worker::error_codes::FILE_UNREADABLE, + e.to_string(), + )), + }); + continue; + } + }; + let hash = ContentHash::of(&bytes); + let display_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let outcome = ctx.instance.with(|instance| { + instance.load( + &ctx.cache, + target, + LoadSource::Bytes(Arc::from(bytes.into_boxed_slice())), + ) + }); + let summary: LoadOutcomeSummary = outcome.result.into(); + if matches!(summary, LoadOutcomeSummary::Loaded { .. }) { + record_reference(&ctx, target, hash, display_name, &path); + } let _ = events.send(AppEvent::LoadFinished { target, source: source_desc, - outcome: outcome.result.into(), + outcome: summary, }); } AppCommand::RescanLibrary => { @@ -340,10 +441,34 @@ fn run(ctx: WorkerContext, commands: mpsc::Receiver, events: mpsc::S handle.cancel(); } } + AppCommand::ListPresets(dir) => { + let _ = events.send(AppEvent::PresetsListed(crate::presets::list_presets(&dir))); + } AppCommand::SaveState(path) => { let state = ctx.state.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let bytes = state.write(); - let error = std::fs::write(&path, bytes).err().map(|e| e.to_string()); + // `try_write`, not `write`: NFR-SEC-020's document ceiling is enforced on the + // write side too, and FR-STATE-080's embedded copy is the one thing in this + // format that can realistically reach it. A refusal here is a reportable error, + // not a truncated file on disk. + let bytes = match state.try_write() { + Ok(bytes) => bytes, + Err(e) => { + let _ = events.send(AppEvent::StateSaved { + path, + error: Some(e.to_string()), + }); + continue; + } + }; + // The preset directory is created on demand: a first save into a configuration + // directory that has never held one must not fail for want of a `mkdir`. + let error = path + .parent() + .map(std::fs::create_dir_all) + .transpose() + .and_then(|_| std::fs::write(&path, bytes)) + .err() + .map(|e| e.to_string()); let _ = events.send(AppEvent::StateSaved { path, error }); } AppCommand::LoadState(path) => { diff --git a/crates/namir-clap/src/audio.rs b/crates/namir-clap/src/audio.rs index 505f8f1..4dff3a2 100644 --- a/crates/namir-clap/src/audio.rs +++ b/crates/namir-clap/src/audio.rs @@ -96,7 +96,14 @@ pub struct NamirAudioProcessor<'a> { shared: &'a NamirShared<'a>, host: HostAudioProcessorHandle<'a>, priority_elevated: bool, + /// The engine's *own* last `latency_samples()` reading — not the figure the host is being + /// told, which `SharedInner::latency_samples` holds and which can legitimately differ from + /// this while an activation's replay is still in flight (issue #93; see + /// `SharedInner::carried_latency`). last_seen_latency: u32, + /// This activation's sample rate, kept so [`Self::publish_latency`] can record which rate the + /// figure it publishes was measured at without asking the engine again. + sample_rate_hz: u32, } impl<'a> NamirAudioProcessor<'a> { @@ -120,6 +127,13 @@ impl<'a> NamirAudioProcessor<'a> { } } + /// This instance's shared state — `pub(crate)` accessor rather than a `pub(crate)` field, so + /// `crate::params_ext`'s `flush` can reach the parameter mirror without every field of this + /// audio-thread type becoming reachable from outside the module that owns the audio thread. + pub(crate) fn shared(&self) -> &'a NamirShared<'a> { + self.shared + } + /// One direct-applied change plus its `ParamMirror` update — the one piece of logic both /// `process()`'s own automation loop and `crate::params_ext`'s `PluginAudioProcessorParams:: /// flush` (called when active but `process()` was not, per `clack_extensions::params`'s own @@ -133,18 +147,23 @@ impl<'a> NamirAudioProcessor<'a> { /// this module's doc comment for the full FR-CLAP-040 sequence. fn publish_latency(&mut self) { let latency = self.engine.chain().latency_samples(); + if latency == self.last_seen_latency { + // Nothing moved. In particular this is the whole of a block during an activation's + // replay, where the engine still reports 0 and `SharedInner::latency_samples` is + // deliberately holding the figure the replay will converge on -- so this must compare + // against the engine's own last reading, never against the published one, or it would + // republish the transient zero and re-open issue #93's loop from the other side. + return; + } + self.last_seen_latency = latency; self.shared .inner - .latency_samples - .store(latency, Ordering::Relaxed); - if latency != self.last_seen_latency { - self.last_seen_latency = latency; - self.shared - .inner - .latency_dirty - .store(true, Ordering::Relaxed); - self.host.shared().request_callback(); - } + .publish_latency(latency, self.sample_rate_hz); + self.shared + .inner + .latency_dirty + .store(true, Ordering::Relaxed); + self.host.shared().request_callback(); } } @@ -195,14 +214,22 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> shared.inner.install_instance(instance); shared.inner.active.store(true, Ordering::Relaxed); - let latency = engine.chain().latency_samples(); - shared + // The engine this activation just built is a *default* one, so this is 0 -- and + // publishing that zero on every activation, while `spawn_recall` below is about to put + // the model (and its latency) back, is exactly what made FR-CLAP-040's restart + // unbounded (issue #93). `carried_latency` is what decides whether the figure the host + // already has survives this activation; see its doc comment for both of its conditions. + let engine_latency = engine.chain().latency_samples(); + let sample_rate_hz = sample_rate.hz(); + let reported = shared .inner - .latency_samples - .store(latency, Ordering::Relaxed); + .carried_latency(sample_rate_hz) + .unwrap_or(engine_latency); + shared.inner.publish_latency(reported, sample_rate_hz); // Permitted here unconditionally per `clack_extensions::latency::HostLatency::changed`'s // own doc comment ("allowed to change only during the activate callback") — see this - // module's doc comment for the full sequence. + // module's doc comment for the full sequence. It is also what records `reported` as the + // figure the host has been given, which `on_main_thread` then compares against. main_thread.notify_latency_changed(); // FR-STATE-030/050's replay: whatever this instance's `ParamMirror`/resource references @@ -217,7 +244,8 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> shared, host, priority_elevated: false, - last_seen_latency: latency, + last_seen_latency: engine_latency, + sample_rate_hz, }) } @@ -233,7 +261,17 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> // D-13.2: once, at first `process()` activation — see `namir_platform::thread_priority`'s // own module doc comment for why this cadence (not once per callback) is correct. if !self.priority_elevated { - let _ = namir_platform::elevate_current_thread_priority(); + // The outcome is `#[must_use]` and is carried *off* this thread rather than reported + // here: FR-ERR-030 forbids logging and logging-formatting on the audio thread, and + // `xtask rt-logging` forbids this module from so much as naming the logger. Two atomic + // stores, and `on_main_thread` turns them into a notice -- see + // `SharedInner::record_thread_priority_outcome`. + let outcome = namir_platform::elevate_current_thread_priority(); + if self.shared.inner.record_thread_priority_outcome(outcome) { + // Only when there is something to say, and only once per instance -- see that + // method's own doc comment. A successful elevation wakes nobody. + self.host.shared().request_callback(); + } self.priority_elevated = true; } @@ -269,6 +307,13 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> self.publish_latency(); + // FR-PARAM-030's other direction (issue #94): a knob the user turned in *this* plugin's + // editor is reported back to the host as automation, wrapped in a gesture, so the host can + // record it and keep its own generic UI in step. See `crate::params_ext`'s + // `emit_gui_param_changes` for why this is allocation-free and why host-originated changes + // are never echoed back through it. + crate::params_ext::emit_gui_param_changes(&self.shared.inner.params, events.output); + Ok(ProcessStatus::Continue) } diff --git a/crates/namir-clap/src/error_codes.rs b/crates/namir-clap/src/error_codes.rs index 56cf852..b1a412e 100644 --- a/crates/namir-clap/src/error_codes.rs +++ b/crates/namir-clap/src/error_codes.rs @@ -38,6 +38,30 @@ pub const INVALID_SAMPLE_RATE: ErrorCode = ErrorCode::new( the plugin.", ); +/// A named preset could not be placed: this environment has no per-user configuration directory +/// (so [`crate::presets::preset_dir`] resolved nothing), or the name the user typed is not one +/// that can name a plain file inside it. `namir_ui::UiIntent::SavePreset`'s own doc comment makes +/// rejecting such a name the host's responsibility, and this is that rejection made visible +/// instead of silent. +pub const PRESET_UNAVAILABLE: ErrorCode = ErrorCode::new( + "clap.preset.unavailable", + Severity::Warning, + "The preset could not be saved: {detail}.", + "Pick a name without slashes, colons or other characters a filename cannot contain. If no \ + preset folder exists at all, Namir's standalone application creates one the first time it \ + saves a preset.", +); + +/// A preset file could not be written or read back — a full disk, a read-only folder, a file +/// another program holds open. +pub const PRESET_IO_FAILED: ErrorCode = ErrorCode::new( + "clap.preset.io_failed", + Severity::Error, + "The preset file could not be read or written ({detail}).", + "Check that the preset folder exists and is writable, then try again. namir.log records the \ + exact path and the operating system's own reason.", +); + #[cfg(test)] mod tests { use super::*; @@ -48,6 +72,8 @@ mod tests { LIBRARY_UNAVAILABLE, GUI_INVALID_PARENT, INVALID_SAMPLE_RATE, + PRESET_UNAVAILABLE, + PRESET_IO_FAILED, ]); } } diff --git a/crates/namir-clap/src/gui.rs b/crates/namir-clap/src/gui.rs index 9f991ca..7c8d856 100644 --- a/crates/namir-clap/src/gui.rs +++ b/crates/namir-clap/src/gui.rs @@ -165,10 +165,11 @@ impl<'a> PluginGuiImpl for NamirMainThread<'a> { previous.close(); } - let host = ClapUiHost::new( - std::sync::Arc::clone(&self.shared.inner), - self.shared.inner.telemetry_reader(), - ); + // No telemetry reader is passed: `ClapUiHost` fetches whichever one is live, on every + // frame that needs it, precisely because there may be none yet at editor-open time and + // because the one that exists now is retired by the next deactivate/reactivate cycle + // (issue #95). + let host = ClapUiHost::new(std::sync::Arc::clone(&self.shared.inner)); self.window = Some(namir_ui::open_parented(&handle, "Namir", host)); Ok(()) @@ -189,8 +190,47 @@ impl<'a> PluginGuiImpl for NamirMainThread<'a> { }) } - fn set_size(&mut self, _size: GuiSize) -> Result<(), PluginError> { - Ok(()) + /// **Issue #98: the refusal is reported, not disguised as success.** + /// + /// `can_resize()` below is `false` and [`Self::get_size`] returns the same fixed + /// [`GUI_WIDTH`]x[`GUI_HEIGHT`] whatever a host asks for, so this can only ever *decline*. + /// CLAP's own `clap_plugin_gui.set_size` is a `bool`-returning call whose contract is "returns + /// true if the size is (was) accepted"; returning `Ok` for a request nothing acted on tells a + /// host it may size its parent window to a figure the editor never adopted, and leaves the + /// editor clipped or adrift in dead space with nothing reporting why. + /// + /// The exact current size is accepted, because that request genuinely is satisfied — with + /// nothing to do. A host that echoes `get_size` back (the documented opening sequence has one + /// call `set_size` when it "remembers previous session's size") must not be told the plugin + /// cannot hold the size it just reported. + /// + /// **The refusal does not currently reach the host, and that is upstream, not here.** + /// `clack-extensions` 0.1.1's `set_size` trampoline (`src/gui/plugin.rs:403-412`) is + /// `PluginWrapper::handle(plugin, |p| Ok(p.main_thread().as_mut().set_size(size))).is_some()` + /// — it wraps the plugin's whole `Result` as the *success value* and then reports whether the + /// call panicked, so an `Err` returned here becomes `true` at the C ABI. Every neighbouring + /// method in that same file gets this right (`set_scale`, `show`, `hide` are all + /// `Ok(...is_ok())`), so this is a defect in one function rather than the crate's convention. + /// The version is pinned exactly (D-14.2/R-2), so the fix is upstream's or a later pin's; + /// [`accepts_size`] is factored out so the decision this plugin makes is testable and correct + /// on the day the answer starts being transmitted, and + /// `tests/clap_host_gui.rs` carries a live record of the swallowing. + /// + /// **Why refuse rather than become resizable.** FR-CLAP-110 (host-driven resize) is a *Should* + /// this round declares out of scope (see this crate's `lib.rs`), and the fixed 960x640 is a + /// deliberate, sufficient size: it is comfortably above FR-UI-080's 800x600 floor, and issue + /// #42's fix — bounding the notice list vertically — was specifically taken so the editor works + /// *at* that size rather than needing to grow. A real resize would have to reach + /// `namir_ui::open_parented`'s `baseview` window and re-lay-out the egui frame, which is + /// `namir-ui` work and a `namir-ui` decision; nothing in this crate can honestly do it today, + /// and saying so is the whole of what this fix is. + fn set_size(&mut self, size: GuiSize) -> Result<(), PluginError> { + if accepts_size(size) { + return Ok(()); + } + Err(PluginError::Message( + "this editor is a fixed size (can_resize() is false)", + )) } fn can_resize(&mut self) -> bool { @@ -205,3 +245,43 @@ impl<'a> PluginGuiImpl for NamirMainThread<'a> { Ok(()) } } + +/// Whether the editor can adopt `size` — the whole of [`PluginGuiImpl::set_size`]'s decision, split +/// out so it is reachable from a test (constructing a `NamirMainThread` needs a live +/// `HostMainThreadHandle`, which only a real instantiation produces) and so the one-line answer is +/// stated once. See `set_size`'s doc comment for why the answer is what it is, and for why a host +/// currently cannot hear it. +fn accepts_size(size: GuiSize) -> bool { + size.width == GUI_WIDTH && size.height == GUI_HEIGHT +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Issue #98: a fixed-size editor accepts exactly the size it has, and refuses every other — + /// rather than reporting success for a request that changed nothing. + #[test] + fn only_the_size_the_editor_actually_has_is_accepted() { + assert!(accepts_size(GuiSize { + width: GUI_WIDTH, + height: GUI_HEIGHT + })); + for refused in [ + (800, 600), + (1920, 1080), + (GUI_WIDTH, 480), + (1280, GUI_HEIGHT), + (0, 0), + ] { + assert!( + !accepts_size(GuiSize { + width: refused.0, + height: refused.1 + }), + "{refused:?} is not a size get_size() will ever report, so accepting it would be \ + a lie a host then sizes its parent window from" + ); + } + } +} diff --git a/crates/namir-clap/src/lib.rs b/crates/namir-clap/src/lib.rs index 723cefc..091916d 100644 --- a/crates/namir-clap/src/lib.rs +++ b/crates/namir-clap/src/lib.rs @@ -35,6 +35,8 @@ //! - [`params_ext`], [`audio_ports_ext`], [`latency_ext`], [`state_ext`] — the remaining CLAP //! extensions (`params`/FR-CLAP-060's bypass convention, `audio-ports`/FR-CLAP-030, //! `latency`/FR-CLAP-040, `state`/FR-CLAP-050). +//! - [`presets`] — FR-STATE-030's named-preset locations and listing. **Its `preset_dir` belongs +//! in `namir-platform`** so both shells resolve one directory; see that module's own doc comment. //! - [`error_codes`] — this crate's own D-16.1 catalogue entries. //! //! # Deliberately out of scope this round @@ -56,6 +58,7 @@ mod latency_ext; mod main_thread; mod param_mirror; mod params_ext; +mod presets; mod shared; mod state_ext; mod ui_host; diff --git a/crates/namir-clap/src/main_thread.rs b/crates/namir-clap/src/main_thread.rs index 0b9db0a..76e7020 100644 --- a/crates/namir-clap/src/main_thread.rs +++ b/crates/namir-clap/src/main_thread.rs @@ -44,11 +44,60 @@ impl<'a> NamirMainThread<'a> { /// silent no-op rather than a panic — the same tolerance every other optional extension in /// this crate gets. pub(crate) fn notify_latency_changed(&mut self) { + // Recorded whether or not the host advertised the extension, and *before* the call: + // `latency_announced` is "the figure this plugin has published as authoritative", which is + // what `on_main_thread` below decides a restart against (issue #93). A host with no + // `latency` extension is one that will never be told anything, so restarting it repeatedly + // for a figure it cannot read would be the same loop with none of the benefit. + let published = self.shared.inner.latency_samples.load(Ordering::Relaxed); + self.shared + .inner + .latency_announced + .store(published, Ordering::Relaxed); if let Some(latency) = self.host_latency { latency.changed(&mut self.host); } } + /// Asks the host to schedule a `params` flush, so a parameter the user moved in this plugin's + /// own editor reaches it as automation even when no `process()` call is coming (issue #94). + /// + /// **Opportunistic, and honestly so.** The natural caller would be + /// `crate::ui_host::ClapUiHost::set_param`, on the GUI thread, at the moment the knob moves — + /// and it cannot be: `namir_ui::open_parented` requires `H: UiHost + 'static`, so `ClapUiHost` + /// holds only the `'static` `Arc` and can reach no `HostSharedHandle`, which is + /// `'a`-bound (see `crate::shared`'s module doc comment for why that split exists at all). + /// While the plugin is active this costs nothing, because the host is calling `process()` + /// every block and the changes go out there; while it is inactive, this fires on whatever + /// main-thread callback happens next, and until then the change is held — never lost — in the + /// mirror's pending set. + /// Tells the host to re-read every parameter if something behind its back changed them all — + /// today, a preset recalled from this plugin's own editor + /// (`crate::worker_jobs::spawn_recall_preset`). + /// + /// Serviced here and from `PluginMainThreadParams::flush`, both of which are `[main-thread]`; + /// see [`Self::request_param_flush_if_pending`] for why a GUI-thread caller cannot do it + /// itself, which is the same constraint in the same place. + pub(crate) fn rescan_params_if_pending(&mut self) { + if self + .shared + .inner + .params_rescan_pending + .swap(false, Ordering::AcqRel) + { + self.notify_params_changed(); + } + } + + fn request_param_flush_if_pending(&mut self) { + if !self.shared.inner.params.has_gui_pending() { + return; + } + if let Some(params) = self.host_params { + params.request_flush(&self.host.shared()); + } + } + /// Tells the host every parameter's value should be re-queried, without needing a restart — /// `clack_extensions::params`'s own "Loading a preset" scenario ("call `HostParams::rescan` /// if anything changed"). **Required, not a nicety:** `crate::state_ext`'s `load` adopts a @@ -71,17 +120,35 @@ impl<'a> PluginMainThread<'a, NamirShared<'a>> for NamirMainThread<'a> { /// active, CLAP's own contract requires a restart before the new value may be announced (see /// `crate::audio`'s module doc comment); otherwise it is safe to announce directly. fn on_main_thread(&mut self) { + // D-13.2's elevation outcome, produced on the audio thread and reportable only here. + self.shared.inner.report_thread_priority_outcome(); + + // A preset recalled from the plugin's own editor replaced every parameter value on a pool + // thread, where `HostParams::rescan` — `[main-thread]` — cannot be called. Same + // opportunism, and same cause, as `request_param_flush_if_pending` below. + self.rescan_params_if_pending(); + if self .shared .inner .latency_dirty .swap(false, Ordering::Relaxed) { - if self.shared.inner.active.load(Ordering::Relaxed) { - self.host.shared().request_restart(); - } else { + let latency = self.shared.inner.latency_samples.load(Ordering::Relaxed); + if !self.shared.inner.active.load(Ordering::Relaxed) { self.notify_latency_changed(); + } else if latency != self.shared.inner.latency_announced.load(Ordering::Relaxed) { + self.host.shared().request_restart(); } + // Active, and the figure already matches what the host was told: nothing to + // renegotiate. **This is issue #93's exit.** Every `activate()` rebuilds a default + // engine and replays this instance's model onto it asynchronously, so the audio thread + // observes that model's latency arrive again on *every* activation; asking for another + // restart each time is a cycle that never terminates while a rate-mismatched model + // stays loaded. A restart is only worth requesting when the host's figure is actually + // wrong. } + + self.request_param_flush_if_pending(); } } diff --git a/crates/namir-clap/src/param_mirror.rs b/crates/namir-clap/src/param_mirror.rs index 9815f8c..e289639 100644 --- a/crates/namir-clap/src/param_mirror.rs +++ b/crates/namir-clap/src/param_mirror.rs @@ -36,14 +36,30 @@ //! that is not on any measured hot path (host automation delivers one event at a time, not a //! per-sample torrent), so there is nothing to buy back. -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use namir_params::REGISTRY; use namir_state::ParamValues; +/// The GUI-origin pending set is one bit per [`REGISTRY`] entry, so the registry has to fit in a +/// `u64`. It holds 31 entries today; this fails the build rather than silently dropping the 65th +/// parameter's change on the floor, which is the failure mode a wider structure would be bought to +/// avoid and a narrower one would hide. +const _: () = assert!( + REGISTRY.len() <= 64, + "ParamMirror::gui_pending is a u64 bitmask, one bit per REGISTRY entry" +); + /// The lock-free mirror. See this module's doc comment. pub(crate) struct ParamMirror { values: Box<[AtomicU32]>, + /// Bit *i* set means `REGISTRY[i]`'s current value was written **by this plugin's own editor** + /// and has not yet been reported to the host as automation (issue #94). + /// + /// Only [`Self::set_by_key_from_gui`] sets a bit. Host-originated writes + /// ([`Self::set_by_id`], from `crate::audio`'s automation path and from `params`' flush) must + /// not, or the plugin would echo the host's own automation straight back at it. + gui_pending: AtomicU64, } impl ParamMirror { @@ -55,7 +71,10 @@ impl ParamMirror { .iter() .map(|d| AtomicU32::new(defaults.get(d.key).unwrap_or(0.0).to_bits())) .collect(); - Self { values } + Self { + values, + gui_pending: AtomicU64::new(0), + } } fn index_of_id(id: u32) -> Option { @@ -82,16 +101,70 @@ impl ParamMirror { Self::index_of_id(id).map(|i| f32::from_bits(self.values[i].load(Ordering::Relaxed))) } - /// Sets the entry with this `ParamDescriptor::key`. Returns `false`, changing nothing, if - /// `key` names no `REGISTRY` entry. + /// Sets the entry with this `ParamDescriptor::key` **without** marking it as a GUI-originated + /// change. Returns `false`, changing nothing, if `key` names no `REGISTRY` entry. + /// + /// `#[cfg(test)]` since issue #94: every production write by key is a user gesture in this + /// plugin's own editor and goes through [`Self::set_by_key_from_gui`], which is this plus the + /// pending-set mark. Tests that want to seed a value without also queueing an automation + /// report to a host they do not have use this one. + #[cfg(test)] pub(crate) fn set_by_key(&self, key: &str, value: f32) -> bool { - let Some(i) = Self::index_of_key(key) else { + self.store_by_key(key, value).is_some() + } + + /// Stores `value` under `key`, returning the `REGISTRY` index it landed at. + fn store_by_key(&self, key: &str, value: f32) -> Option { + let i = Self::index_of_key(key)?; + self.values[i].store(value.to_bits(), Ordering::Relaxed); + Some(i) + } + + /// [`Self::set_by_key`], plus "and tell the host about it": marks the entry as a + /// GUI-originated change awaiting delivery as an automation gesture (issue #94). + /// + /// The one caller is `crate::ui_host::ClapUiHost::set_param`, which is the only path in this + /// crate a *user gesture inside the plugin's own editor* takes. Everything else that writes + /// the mirror — host automation, a `params` flush, a preset/state load — is the host's own + /// change or is announced to it by other means (`HostParams::rescan`), and goes through the + /// unmarked setters. + pub(crate) fn set_by_key_from_gui(&self, key: &str, value: f32) -> bool { + let Some(i) = self.store_by_key(key, value) else { return false; }; - self.values[i].store(value.to_bits(), Ordering::Relaxed); + // Marked *after* the value is stored, so a drain that sees the bit is guaranteed to read + // this value or a later one, never the previous one. + self.gui_pending.fetch_or(1u64 << i, Ordering::Release); true } + /// Whether any GUI-originated change is still waiting to be reported to the host. + pub(crate) fn has_gui_pending(&self) -> bool { + self.gui_pending.load(Ordering::Acquire) != 0 + } + + /// Claims the whole GUI-origin pending set, clearing it. + /// + /// Taken rather than read-then-cleared so that a knob moved *while* a drain is in flight + /// re-marks its own bit and is delivered by the next one — the race can duplicate a report, + /// which a host treats as an idempotent automation point, and cannot drop one. + pub(crate) fn take_gui_pending(&self) -> u64 { + self.gui_pending.swap(0, Ordering::AcqRel) + } + + /// Puts a bit back, for a change whose delivery to the host failed (a full output-event + /// buffer). See [`Self::take_gui_pending`]. + pub(crate) fn restore_gui_pending(&self, bits: u64) { + self.gui_pending.fetch_or(bits, Ordering::Release); + } + + /// The current value of `REGISTRY[index]`, or `None` if `index` is out of range. + pub(crate) fn value_at(&self, index: usize) -> Option { + self.values + .get(index) + .map(|v| f32::from_bits(v.load(Ordering::Relaxed))) + } + /// Overwrites every entry from `params` (FR-STATE-030's preset recall, and the GUI/host /// `state` load path) — anything `params` doesn't carry a value for keeps its current mirror /// value rather than reverting to a default, matching `ParamValues::from_document_section`'s @@ -210,4 +283,70 @@ mod tests { "final value {final_value} was not written by any thread -- a value tore" ); } + + /// **Issue #94's ledger, at the mirror.** A GUI-originated write is queued for the host; a + /// host-originated one is not, or the plugin would echo the host's own automation back at it. + #[test] + fn only_a_gui_originated_write_joins_the_pending_set() { + let mirror = ParamMirror::new(); + assert!(!mirror.has_gui_pending()); + + assert!(mirror.set_by_id(namir_params::stages::trim::GAIN_DB.id.0, 1.0)); + assert!( + !mirror.has_gui_pending(), + "host automation must not be reported back to the host" + ); + + assert!(mirror.set_by_key_from_gui(namir_params::stages::trim::GAIN_DB.key, 2.0)); + assert!(mirror.has_gui_pending()); + + let index = ParamMirror::index_of_key(namir_params::stages::trim::GAIN_DB.key).unwrap(); + assert_eq!(mirror.take_gui_pending(), 1u64 << index); + assert!( + !mirror.has_gui_pending(), + "taking the pending set must clear it, so one change is reported once" + ); + assert_eq!(mirror.value_at(index), Some(2.0)); + } + + /// An unknown key changes nothing and queues nothing. + #[test] + fn an_unknown_key_from_the_gui_queues_nothing() { + let mirror = ParamMirror::new(); + assert!(!mirror.set_by_key_from_gui("not.a.real.key", 1.0)); + assert!(!mirror.has_gui_pending()); + } + + /// A delivery that failed puts its change back rather than dropping it — the host's output + /// event buffer is allowed to be full, and a lost automation point is exactly what issue #94 + /// is about. + #[test] + fn a_restored_bit_is_reported_again() { + let mirror = ParamMirror::new(); + mirror.set_by_key_from_gui(namir_params::stages::out::GAIN_DB.key, -3.0); + let taken = mirror.take_gui_pending(); + assert_ne!(taken, 0); + mirror.restore_gui_pending(taken); + assert_eq!(mirror.take_gui_pending(), taken); + } + + /// Several parameters moved before one drain are all reported, each exactly once. + #[test] + fn every_moved_parameter_is_in_one_drain() { + let mirror = ParamMirror::new(); + let keys = [ + namir_params::stages::trim::GAIN_DB.key, + namir_params::stages::out::GAIN_DB.key, + namir_params::global::GLOBAL_BYPASS.key, + ]; + for key in keys { + mirror.set_by_key_from_gui(key, 1.0); + } + let taken = mirror.take_gui_pending(); + assert_eq!(taken.count_ones(), keys.len() as u32); + for key in keys { + let index = ParamMirror::index_of_key(key).unwrap(); + assert_ne!(taken & (1u64 << index), 0, "{key} must be in the drain"); + } + } } diff --git a/crates/namir-clap/src/params_ext.rs b/crates/namir-clap/src/params_ext.rs index 84eadea..1d88938 100644 --- a/crates/namir-clap/src/params_ext.rs +++ b/crates/namir-clap/src/params_ext.rs @@ -17,8 +17,12 @@ use clack_extensions::params::{ ParamDisplayWriter, ParamInfo, ParamInfoFlags, ParamInfoWriter, PluginAudioProcessorParams, PluginMainThreadParams, }; +use clack_plugin::events::event_types::{ + ParamGestureBeginEvent, ParamGestureEndEvent, ParamValueEvent, +}; use clack_plugin::events::io::{InputEvents, OutputEvents}; use clack_plugin::events::spaces::CoreEventSpace; +use clack_plugin::events::{Match, Pckn}; use clack_plugin::utils::{ClapId, Cookie}; use namir_engine::{ParamChange, ParamId as EngineParamId}; use namir_params::global::GLOBAL_BYPASS; @@ -89,25 +93,88 @@ fn parse_text_to_value(descriptor: &ParamDescriptor, text: &str) -> Option } } -/// Applies every `ParamValue` event in `input` to `apply`, mirroring it into the `ParamMirror` via -/// `mirror` — shared between the main-thread and audio-processor `flush` implementations below. -fn apply_flush_events( - input: &InputEvents, - mut apply: impl FnMut(ParamChange), - mirror: &crate::param_mirror::ParamMirror, -) { +/// Decodes every `ParamValue` event in `input` and hands each to `apply` — the one event-decode +/// loop **both** `flush` implementations below run. +/// +/// Issue #97: the audio-processor impl used to hand-roll an identical copy of this loop, while +/// this helper's own doc comment claimed to be shared by both. Two copies of one decode is exactly +/// the drift a shared helper exists to prevent, and `crate::audio`'s `apply_direct_and_mirror` +/// already avoids it for the *apply* half. +/// +/// Mirroring is the caller's, deliberately: the two sides mirror through different paths (the +/// audio side through `apply_direct_and_mirror`, which is itself the "one direct-applied change +/// plus its mirror update" both audio-thread entry points share), so folding it in here would put +/// a second mirror write on that path rather than removing one. +fn apply_flush_events(input: &InputEvents, mut apply: impl FnMut(EngineParamId, f32)) { for event in input.iter() { if let Some(CoreEventSpace::ParamValue(ev)) = event.as_core_event() && let Some(id) = ev.param_id() { - let value = ev.value() as f32; - apply(ParamChange { - id: EngineParamId(id.get()), - value, - }); - mirror.set_by_id(id.get(), value); + apply(EngineParamId(id.get()), ev.value() as f32); + } + } +} + +/// Reports every parameter the user moved in **this plugin's own editor** to the host, as a +/// gesture-wrapped automation point — issue #94, and `clack_extensions::params`' own "Turning a +/// knob on the Plugin interface" scenario ("send an automation event and don't forget to wrap the +/// parameter change(s) with `ParamGestureBeginEvent` and `ParamGestureEndEvent`"). +/// +/// Without this a knob turned in the editor reached the engine and the mirror and stopped there: +/// the host could not record the move as automation, and its own generic parameter UI stayed stale +/// until it independently re-polled `get_value`. +/// +/// # Why a begin/end pair around every single change +/// +/// `namir-ui` emits one [`namir_ui::UiIntent::SetParam`] per changed value and has no notion of a +/// drag beginning or ending, so this crate cannot honestly report a *long* gesture; it reports each +/// change as its own complete one, which is what makes a host record it rather than treat it as an +/// unterminated drag. Widening `UiIntent` to carry drag boundaries is a `namir-ui` change, not a +/// `namir-clap` one. +/// +/// # Real-time safety +/// +/// Called from `process()` (the audio thread) as well as from both `flush` implementations. +/// Allocation-free and bounded: one `swap`, at most 64 iterations (one per `REGISTRY` entry, and +/// only for entries actually marked), three stack-built `#[repr(C)]` events each, and no branch +/// that can loop. `OutputEvents::try_push` calls the host's own callback, which the host is +/// required to keep real-time safe for exactly this reason. +/// +/// A `try_push` the host refuses (a full buffer) puts the change back in the pending set rather +/// than dropping it, so it is reported on the next block instead of silently lost. +pub(crate) fn emit_gui_param_changes( + mirror: &crate::param_mirror::ParamMirror, + out: &mut OutputEvents, +) { + let mut pending = mirror.take_gui_pending(); + let mut undelivered = 0u64; + while pending != 0 { + let index = pending.trailing_zeros() as usize; + let bit = 1u64 << index; + pending &= !bit; + + let (Some(descriptor), Some(value)) = (REGISTRY.get(index), mirror.value_at(index)) else { + continue; + }; + let id = ClapId::new(descriptor.id.0); + let delivered = out.try_push(ParamGestureBeginEvent::new(0, id)).is_ok() + && out + .try_push(ParamValueEvent::new( + 0, + id, + Pckn::new(Match::All, Match::All, Match::All, Match::All), + value as f64, + Cookie::empty(), + )) + .is_ok() + && out.try_push(ParamGestureEndEvent::new(0, id)).is_ok(); + if !delivered { + undelivered |= bit; } } + if undelivered != 0 { + mirror.restore_gui_pending(undelivered); + } } impl<'a> PluginMainThreadParams for NamirMainThread<'a> { @@ -152,22 +219,29 @@ impl<'a> PluginMainThreadParams for NamirMainThread<'a> { fn flush( &mut self, input_parameter_changes: &InputEvents, - _output_parameter_changes: &mut OutputEvents, + output_parameter_changes: &mut OutputEvents, ) { // Inactive (no live engine): update only the mirror, which the *next* `activate()`'s // replay (`crate::audio`) will push onto a fresh engine. See `crate::shared`'s // `SharedInner::with_instance` — a `None` instance here is not an error, just "not yet // activated", handled the same way `try_submit_param` degrades when abandoned. - let mirror = &self.shared.inner.params; - apply_flush_events( - input_parameter_changes, - |change| { - self.shared.inner.with_instance(|instance| { - let _ = instance.try_submit_param(change); - }); - }, - mirror, - ); + // A preset recalled from this plugin's editor may be waiting to be announced; a flush is + // a `[main-thread]` call, so it is one of the two places that can do it. + self.rescan_params_if_pending(); + + // The `&'a NamirShared` is copied out first so the closure borrows only it, never `self`. + let shared = self.shared; + let mirror = &shared.inner.params; + apply_flush_events(input_parameter_changes, |id, value| { + mirror.set_by_id(id.0, value); + shared.inner.with_instance(|instance| { + let _ = instance.try_submit_param(ParamChange { id, value }); + }); + }); + // The outbound half (issue #94). This is the *only* channel a GUI-originated change has + // while the plugin is inactive, which is why `crate::main_thread`'s + // `request_param_flush_if_pending` exists to ask for this call at all. + emit_gui_param_changes(mirror, output_parameter_changes); } } @@ -175,18 +249,22 @@ impl<'a> PluginAudioProcessorParams for NamirAudioProcessor<'a> { fn flush( &mut self, input_parameter_changes: &InputEvents, - _output_parameter_changes: &mut OutputEvents, + output_parameter_changes: &mut OutputEvents, ) { // Active, but `process()` was not called this cycle -- still the audio thread (per // `clack_plugin`'s own thread-model doc comment on `PluginAudioProcessorParams`), so this // uses the same direct-apply path `process()` itself uses, not the ring. - for event in input_parameter_changes.iter() { - if let Some(CoreEventSpace::ParamValue(ev)) = event.as_core_event() - && let Some(id) = ev.param_id() - { - self.apply_direct_and_mirror(EngineParamId(id.get()), ev.value() as f32); - } - } + // + // Through `apply_flush_events`, which is what that helper's own doc comment has always + // said it was for: this impl used to hand-roll the identical decode loop (issue #97), two + // copies of one event decode with nothing keeping them in step. The `&'a NamirShared` is + // copied out first so the mirror borrow does not hold a borrow of `self` across the + // closure's `&mut self.engine`. + let shared = self.shared(); + apply_flush_events(input_parameter_changes, |id, value| { + self.apply_direct_and_mirror(id, value) + }); + emit_gui_param_changes(&shared.inner.params, output_parameter_changes); } } @@ -243,4 +321,85 @@ mod tests { fn descriptor_by_id_returns_none_for_an_unknown_id() { assert!(descriptor_by_id(ClapId::new(0xFFFF_FFFE)).is_none()); } + + /// **Issue #94, at the seam that talks to the host.** A knob moved in the plugin's own editor + /// comes out as a complete, gesture-wrapped automation point, on the parameter's own id and + /// carrying its plain value. + /// + /// Driven through a real `clack_common::events::io::EventBuffer` — the same type + /// `tests/support`'s host harness collects a block's output events into — so what is asserted + /// is the actual CLAP event stream, not an intermediate of this module's own. + /// + /// **Asserted through `UnknownEvent::as_event`, not `as_core_event`, and that is not a + /// stylistic choice.** `clack-common` 0.1.1's `CoreEventSpace::from_unknown` + /// (`src/events/spaces/core.rs:66-84`) has arms for eleven of its thirteen variants and omits + /// exactly the two gesture ones, so `as_core_event()` answers `None` for a + /// `ParamGestureBeginEvent` that is perfectly well-formed — the enum carries + /// `ParamGestureBegin`/`ParamGestureEnd` variants that its own decoder can never produce. + /// A host reads the raw `clap_event_header`, so this is a defect in clack's convenience + /// decoder rather than in what this plugin emits; checking the header's own `type_id` is both + /// the accurate assertion and the one that will not silently start passing for the wrong + /// reason if that decoder is ever fixed. + #[test] + fn a_gui_originated_change_comes_out_as_a_gesture_wrapped_automation_point() { + use clack_plugin::events::event_types::{ + ParamGestureBeginEvent, ParamGestureEndEvent, ParamValueEvent, + }; + use clack_plugin::events::io::EventBuffer; + + let mirror = crate::param_mirror::ParamMirror::new(); + let descriptor = &namir_params::stages::trim::GAIN_DB; + mirror.set_by_key_from_gui(descriptor.key, 4.5); + + let mut buffer = EventBuffer::with_capacity(8); + emit_gui_param_changes(&mirror, &mut buffer.as_output()); + + let events: Vec<&clack_plugin::events::UnknownEvent> = buffer.iter().collect(); + assert_eq!( + events.len(), + 3, + "a user gesture is begin + value + end, or a host has no complete gesture to record" + ); + let expected_id = Some(ClapId::new(descriptor.id.0)); + + let begin = events[0] + .as_event::() + .expect("the first event must be a gesture begin"); + assert_eq!(begin.param_id(), expected_id); + + let value = events[1] + .as_event::() + .expect("the second event must be the value"); + assert_eq!(value.param_id(), expected_id); + assert_eq!(value.value(), 4.5); + + let end = events[2] + .as_event::() + .expect("the third event must be a gesture end"); + assert_eq!(end.param_id(), expected_id); + + // Reported once: a second drain with nothing new emits nothing at all. + let mut again = EventBuffer::with_capacity(8); + emit_gui_param_changes(&mirror, &mut again.as_output()); + assert!( + again.is_empty(), + "a change already reported must not be reported again every block" + ); + } + + /// The other half of the same rule: a change that came *from* the host is not sent back to it. + #[test] + fn a_host_originated_change_produces_no_output_events() { + use clack_plugin::events::io::EventBuffer; + + let mirror = crate::param_mirror::ParamMirror::new(); + mirror.set_by_id(namir_params::stages::trim::GAIN_DB.id.0, 9.0); + + let mut buffer = EventBuffer::with_capacity(8); + emit_gui_param_changes(&mirror, &mut buffer.as_output()); + assert!( + buffer.is_empty(), + "echoing the host's own automation back at it is a feedback loop, not a report" + ); + } } diff --git a/crates/namir-clap/src/presets.rs b/crates/namir-clap/src/presets.rs new file mode 100644 index 0000000..f0c32d0 --- /dev/null +++ b/crates/namir-clap/src/presets.rs @@ -0,0 +1,181 @@ +//! FR-STATE-030's named-preset half, for the plugin: where a `.namirpreset` lives, how the set of +//! them is listed for [`namir_ui::UiSnapshot::presets`], and the two file operations +//! [`namir_ui::UiIntent::SavePreset`]/[`RecallPreset`](namir_ui::UiIntent::RecallPreset) name. +//! +//! # ⚠ This resolution belongs in `namir-platform`, not here ⚠ +//! +//! FR-STATE-030's presets are "interchangeable between the two products", and interchangeability +//! fails at the *discovery* step — not at the format — if the two shells look in two different +//! directories. `namir-worker`'s [`namir_worker::library::LibraryService::open_default`] is this +//! workspace's own precedent and its own written warning: `namir-clap` and `namir-app` each +//! computing the library's default location independently is what let their library wiring drift +//! apart once already (see `crate::shared`'s module doc comment), and the fix was to make one +//! function the only way either shell can ask. +//! +//! The same fix is owed here, and this module is **not** it: D-13.2 puts filesystem locations in +//! `namir-platform` ("Filesystem locations, config directories, log sinks … live in +//! `namir-platform` and nowhere else"), so [`preset_dir`] below should be a `preset_dir()` beside +//! `namir_platform::config_dir()`/`log_file_path()`, with `namir-app` calling the same function. +//! It is here only because this change could not touch another crate; the constant it encodes — +//! `/Presets`, chosen to match `LibraryService::open_at`'s own `/Library` +//! — is the thing to hoist, unchanged, so nothing moves under a user who already saved a preset. +//! +//! # Naming +//! +//! `namir_ui::UiIntent::SavePreset` carries "a name, not a path", already trimmed and non-empty, +//! and says in as many words that a name illegal as a filename is *the host's* to reject. This +//! module is that host: [`sanitise_name`] refuses anything that could escape the preset directory +//! or name something other than a plain file in it, and the caller reports the refusal as an +//! FR-UI-070 notice rather than writing somewhere the user did not ask for. + +use std::path::{Path, PathBuf}; + +use namir_ui::PresetSummary; + +/// The extension `docs/04-state-and-preset-format.md` gives the preset document. +pub(crate) const PRESET_EXTENSION: &str = "namirpreset"; + +/// The directory both products must agree on. `None` under exactly the conditions +/// [`namir_platform::config_dir`] returns `None` for — an environment with no per-user +/// configuration convention this workspace claims to know. +/// +/// **See this module's doc comment**: this function's body is what belongs in `namir-platform`. +pub(crate) fn preset_dir() -> Option { + namir_platform::config_dir().map(|dir| dir.join("Presets")) +} + +/// The file a preset called `name` is stored in, or `None` if `name` is not one this shell will +/// write — see [`sanitise_name`]. +pub(crate) fn preset_path(dir: &Path, name: &str) -> Option { + Some(dir.join(format!("{}.{PRESET_EXTENSION}", sanitise_name(name)?))) +} + +/// The name, if it is one that can only ever name a plain file directly inside the preset +/// directory. +/// +/// Rejected: anything empty once trimmed, anything containing a path separator of either platform +/// (so a name can never reach a sibling directory), anything that is `.` or `..`, anything with a +/// Windows drive prefix, and anything containing a character Windows refuses in a filename. The +/// last is checked on every platform on purpose: a preset saved on Linux under a name Windows +/// cannot represent would be a preset the other half of FR-STATE-030's interchangeability claim +/// cannot open. +pub(crate) fn sanitise_name(name: &str) -> Option<&str> { + let name = name.trim(); + if name.is_empty() || name == "." || name == ".." { + return None; + } + if name.chars().any(|c| { + matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() + }) { + return None; + } + Some(name) +} + +/// Every `.namirpreset` directly inside `dir`, named by its file stem, sorted by name. +/// +/// Non-recursive, and a directory that does not exist (or cannot be read) is an empty list rather +/// than an error: "no presets saved yet" is the ordinary first-run state, and there is nothing for +/// a user to act on in being told about it. A real read failure is still visible — the caller logs +/// nothing here, but the empty list is what the UI renders as a disabled recall control, which is +/// what `namir_ui::UiSnapshot::presets` documents for "the host knows of none". +pub(crate) fn list_presets(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut presets: Vec = entries + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case(PRESET_EXTENSION)) + }) + .filter_map(|path| { + let name = path.file_stem()?.to_string_lossy().into_owned(); + Some(PresetSummary { name, path }) + }) + .collect(); + // A deterministic order, so the list does not reshuffle between frames on a filesystem whose + // `read_dir` order is not stable. + presets.sort_by(|a, b| a.name.cmp(&b.name)); + presets +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "namir-clap-presets-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn the_preset_directory_sits_beside_the_library_under_the_shared_config_directory() { + // Skipped rather than failed where there is no per-user config directory at all -- the + // same degradation `config_dir` itself documents. + let Some(config) = namir_platform::config_dir() else { + return; + }; + let dir = preset_dir().expect("a preset dir exists wherever a config dir does"); + assert_eq!( + dir.parent(), + Some(config.as_path()), + "both shells must resolve presets under the one config directory they share, the way \ + LibraryService::open_at resolves /Library" + ); + } + + #[test] + fn a_name_that_could_escape_the_preset_directory_is_refused() { + for hostile in [ + "../evil", + "..\\evil", + "sub/dir", + "sub\\dir", + "C:evil", + "..", + ".", + " ", + "bad\u{0}name", + ] { + assert!( + sanitise_name(hostile).is_none(), + "{hostile:?} must not be accepted as a preset name" + ); + } + assert_eq!(sanitise_name(" Crunch Rhythm "), Some("Crunch Rhythm")); + } + + #[test] + fn listing_finds_only_preset_files_and_names_them_by_stem() { + let dir = temp_dir("listing"); + std::fs::write(dir.join("Clean.namirpreset"), b"{}").unwrap(); + std::fs::write(dir.join("Lead.NAMIRPRESET"), b"{}").unwrap(); + std::fs::write(dir.join("notes.txt"), b"x").unwrap(); + std::fs::create_dir_all(dir.join("Nested.namirpreset")).unwrap(); + + let presets = list_presets(&dir); + let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); + assert_eq!( + names, + vec!["Clean", "Lead"], + "only regular .namirpreset files, named by stem, sorted" + ); + assert_eq!(presets[0].path, dir.join("Clean.namirpreset")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_directory_that_does_not_exist_lists_nothing_rather_than_failing() { + let dir = temp_dir("absent").join("never-created"); + assert!(list_presets(&dir).is_empty()); + } +} diff --git a/crates/namir-clap/src/shared.rs b/crates/namir-clap/src/shared.rs index f0b45d3..efc4244 100644 --- a/crates/namir-clap/src/shared.rs +++ b/crates/namir-clap/src/shared.rs @@ -53,19 +53,30 @@ //! a *second* root — that gap is real and still open — but a single, correct default needed no //! new UI to fix, only for this crate to stop assuming "unconfigured" was a harmless state to //! leave a destructive scan operation pointed at. -//! - `latency_samples`/`latency_dirty` — see `crate::audio`'s module doc comment for the full -//! FR-CLAP-040 story; these are the audio-thread-writable, main-thread-readable channel between -//! the two halves of it. - -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +//! - `latency_samples`/`latency_announced`/`latency_basis_rate`/`latency_dirty` — see +//! `crate::audio`'s module doc comment for the full FR-CLAP-040 story, and +//! [`SharedInner::carried_latency`] for the part of it (issue #93) that has to survive an +//! activation; these are the audio-thread-writable, main-thread-readable channel between the two +//! halves of it. +//! - `telemetry`/`telemetry_generation` — the live engine's meter feed, and a counter that lets a +//! [`ClapUiHost`] holding a *clone* of the reader notice that the clone it holds has been +//! retired (issue #95). See [`SharedInner::set_telemetry_reader`]. +//! - `thread_priority_kind`/`thread_priority_os_error` — D-13.2's elevation outcome, parked here by +//! the audio thread and reported from the main one. See +//! [`SharedInner::record_thread_priority_outcome`] for why it cannot be reported where it is +//! produced. + +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; use clack_plugin::plugin::PluginShared; use namir_core::ErrorCode; use namir_engine::TelemetryReader; use namir_library::ScanProgress; +use namir_platform::ThreadPriorityOutcome; use namir_state::{Document, FileRef, State}; -use namir_ui::UiNotice; +use namir_ui::{PresetSummary, UiNotice}; use namir_worker::library::{LibraryService, ScanHandle}; use namir_worker::pool::ThreadPool; use namir_worker::{Instance, ResourceCache}; @@ -90,10 +101,31 @@ pub(crate) struct SharedInner { /// The chain's own last-measured `latency_samples()`, published by the audio thread every /// block (see `crate::audio`). pub(crate) latency_samples: AtomicU32, + /// The value of `latency_samples` at the moment the host was last *told* to re-read it + /// (`crate::main_thread`'s `notify_latency_changed`, which is every `activate()` plus + /// `on_main_thread`'s inactive branch). + /// + /// **This is what closes issue #93's restart loop.** A restart is only worth asking a host for + /// when the figure it currently believes is wrong; comparing against what was actually + /// announced — rather than against whatever a freshly rebuilt engine happens to report before + /// its replay has landed — is what makes "the model I already restarted for came back" a + /// non-event. See `crate::main_thread`'s `on_main_thread`. + pub(crate) latency_announced: AtomicU32, + /// The sample rate `latency_samples` was measured at, or 0 if it has never been measured. + /// [`Self::carried_latency`] is the only reader; see its doc comment. + pub(crate) latency_basis_rate: AtomicU32, /// Set by the audio thread when `latency_samples` changed since it was last reported to the /// host; cleared once `on_main_thread` has acted on it. See `crate::audio`'s module doc /// comment for the full FR-CLAP-040 sequencing. pub(crate) latency_dirty: AtomicBool, + /// D-13.2's elevation outcome as an atomic pair — the discriminant, and `OsError`'s payload. + /// Written from the audio thread, read and reported from the main one. See + /// [`Self::record_thread_priority_outcome`]. + thread_priority_kind: AtomicU8, + thread_priority_os_error: AtomicI64, + /// Whether an outcome worth reporting has already been recorded for this instance. See + /// [`SharedInner::record_thread_priority_outcome`]'s return value. + thread_priority_seen: AtomicBool, library: Mutex>, scan_progress: Mutex>, scan_handle: Mutex>, @@ -102,8 +134,41 @@ pub(crate) struct SharedInner { /// why the GUI keeps its own clone rather than reading through here directly (each clone /// tracks an independent cursor, so the GUI's drain cadence never affects anyone else's). telemetry: Mutex>, + /// Incremented by every [`Self::set_telemetry_reader`] call. A GUI-side holder of a clone + /// compares this against the generation its own clone came from and re-clones when it is + /// behind — issue #95, where a clone taken once at editor-open outlived the ring it read from. + telemetry_generation: AtomicU64, + /// FR-STATE-030's preset list, as last enumerated off-thread, and when that was — see + /// [`SharedInner::presets_snapshot`], which is modelled on [`SharedInner::library_snapshot`]'s + /// "never block the GUI thread, fill in a moment later" contract. + presets: Mutex>, + presets_listed_at: Mutex>, + /// Set when a preset recall has replaced every parameter value behind the host's back, so the + /// next main-thread callback can tell it to re-read them (`HostParams::rescan`) — see + /// `crate::main_thread`'s `notify_params_changed`, and its `on_main_thread` for why this is a + /// flag rather than a direct call. + pub(crate) params_rescan_pending: AtomicBool, } +/// How stale [`SharedInner::presets_snapshot`] lets its cached listing get before enumerating the +/// preset directory again. +/// +/// A GUI frame must not do a `read_dir`, so the listing is refreshed by a pool job and the GUI +/// renders whatever the last one produced. One second is short enough that a preset saved from the +/// standalone application (or from another instance of this plugin) appears while the user is +/// still looking for it, and long enough that a 60 Hz editor is not listing a directory 60 times a +/// second. +const PRESET_LISTING_MAX_AGE: Duration = Duration::from_secs(1); + +/// `thread_priority_kind`'s discriminants. Nothing outside this module reads them: the pair of +/// atomics is a private transport for one `ThreadPriorityOutcome` between two threads, and both +/// ends of it are `SharedInner` methods. +const THREAD_PRIORITY_UNREPORTED: u8 = 0; +const THREAD_PRIORITY_ELEVATED: u8 = 1; +const THREAD_PRIORITY_DENIED: u8 = 2; +const THREAD_PRIORITY_OS_ERROR: u8 = 3; +const THREAD_PRIORITY_UNSUPPORTED: u8 = 4; + impl SharedInner { pub(crate) fn new() -> Self { // M14 (§22 R-18, issue #22): `open_default` no longer reads `library-index.json` — the @@ -113,7 +178,20 @@ impl SharedInner { // straight back where it was. The load's warnings are drained in `library_snapshot` // instead, which the GUI calls every frame and which is off the instantiation path. let library = LibraryService::open_default().map(|(service, _)| service); + Self::with_library(library) + } + + /// [`Self::new`] against an explicitly supplied per-user configuration directory, for tests + /// that need a library whose roots they control — the same injectable-path seam + /// [`LibraryService::open_at`] exists for, and the only way a test can assert anything about + /// [`Self::library_roots`] without depending on what this developer's machine happens to hold. + #[cfg(test)] + pub(crate) fn new_at(config_dir: &std::path::Path) -> Self { + let (service, _warnings) = LibraryService::open_at(config_dir); + Self::with_library(Some(service)) + } + fn with_library(library: Option) -> Self { Self { params: ParamMirror::new(), cache: ResourceCache::shared(), @@ -127,11 +205,20 @@ impl SharedInner { unsaved_changes: AtomicBool::new(false), active: AtomicBool::new(false), latency_samples: AtomicU32::new(0), + latency_announced: AtomicU32::new(0), + latency_basis_rate: AtomicU32::new(0), latency_dirty: AtomicBool::new(false), + thread_priority_kind: AtomicU8::new(THREAD_PRIORITY_UNREPORTED), + thread_priority_os_error: AtomicI64::new(0), + thread_priority_seen: AtomicBool::new(false), library: Mutex::new(library), scan_progress: Mutex::new(None), scan_handle: Mutex::new(None), telemetry: Mutex::new(None), + telemetry_generation: AtomicU64::new(0), + presets: Mutex::new(Vec::new()), + presets_listed_at: Mutex::new(None), + params_rescan_pending: AtomicBool::new(false), } } @@ -139,8 +226,36 @@ impl SharedInner { lock(&self.telemetry).clone() } + /// Installs (or clears) the reader every GUI-side meter drain works from, and bumps + /// [`Self::telemetry_generation`] so a holder of a stale clone notices. + /// + /// The generation is bumped *after* the new reader is in place, so a reader that observes the + /// new generation is guaranteed to fetch the new reader (or a later one) and never the retired + /// one. pub(crate) fn set_telemetry_reader(&self, reader: Option) { *lock(&self.telemetry) = reader; + self.telemetry_generation.fetch_add(1, Ordering::Release); + } + + /// How many times [`Self::set_telemetry_reader`] has been called. See `crate::ui_host`'s + /// `rebind_telemetry_if_stale` — issue #95. + pub(crate) fn telemetry_generation(&self) -> u64 { + self.telemetry_generation.load(Ordering::Acquire) + } + + /// The library roots a resolver built for this instance must search — FR-STATE-070's first + /// resolution candidate, `library_relative`, resolves against exactly these. + /// + /// Read off the held [`LibraryService`] rather than restated, for the same reason + /// `LibraryService::open_default` is the one function both shells bootstrap through: issue + /// #96 was `crate::worker_jobs::spawn_recall` building its resolver with a hardcoded empty + /// list, so every `library_relative` reference in a preset resolved in the standalone + /// application and missed in the plugin. + pub(crate) fn library_roots(&self) -> Vec { + lock(&self.library) + .as_ref() + .map(|service| service.roots().to_vec()) + .unwrap_or_default() } pub(crate) fn nam_ref(&self) -> Option { @@ -250,6 +365,43 @@ impl SharedInner { namir_ui::LibrarySnapshot { index, scan } } + /// FR-STATE-030's preset list as the GUI sees it this frame, and the point at which a stale + /// listing is refreshed — off-thread, exactly as `library_snapshot` defers its own parse. + /// + /// Never blocks and never touches the filesystem on the calling thread: until the first + /// enumeration lands this returns an empty list, which `namir_ui::UiSnapshot::presets` + /// documents as "the host knows of none (or has not looked yet)" and which the UI renders as a + /// disabled recall control rather than as an error. + pub(crate) fn presets_snapshot(self: &Arc) -> Vec { + self.refresh_presets_if_stale(); + lock(&self.presets).clone() + } + + /// Forces the next [`Self::presets_snapshot`] to re-enumerate — called after this instance + /// writes a preset, so the list it just added to is not up to a second out of date. + pub(crate) fn mark_presets_stale(&self) { + *lock(&self.presets_listed_at) = None; + } + + fn refresh_presets_if_stale(self: &Arc) { + { + let mut listed_at = lock(&self.presets_listed_at); + if listed_at.is_some_and(|at| at.elapsed() < PRESET_LISTING_MAX_AGE) { + return; + } + // Stamped before the job runs, not after: two frames in the same millisecond must not + // both queue an enumeration. + *listed_at = Some(Instant::now()); + } + let this = Arc::clone(self); + self.pool.spawn(move || { + let listed = crate::presets::preset_dir() + .map(|dir| crate::presets::list_presets(&dir)) + .unwrap_or_default(); + *lock(&this.presets) = listed; + }); + } + pub(crate) fn start_library_scan(self: &Arc) { let library_guard = lock(&self.library); let Some(service) = library_guard.as_ref() else { @@ -287,6 +439,117 @@ impl SharedInner { } } + /// The latency figure a fresh `activate()` should keep reporting rather than replacing with + /// the zero its freshly built engine reports — **issue #93's other half**. + /// + /// Every activation builds a default engine (latency 0) and dispatches + /// `crate::worker_jobs::spawn_recall` to reload whatever this instance stands for. Publishing + /// the transient zero and then the replayed model's real figure is what made the plugin + /// observe a latency *change* on every single activation, and ask for a restart for it — a + /// cycle with no exit for as long as a rate-mismatched model stayed loaded. + /// + /// So when a replay is pending, the figure the host already has is carried across the + /// activation instead: it is the value that same replay converged on last time, and the + /// activation is the plugin's own restart, not a configuration change. + /// + /// **Two conditions, both necessary.** There must be something to replay (`nam_ref`/`ir_ref`), + /// or the engine's zero is simply the truth; and the activation's sample rate must match the + /// rate the carried figure was measured at, because D-9.2's resampler — the chain's only + /// source of latency in 1.0 — exists precisely when the model's rate differs from the + /// session's, so a rate change can legitimately move the converged figure to something else. + /// When either fails the caller adopts the engine's own reading and the ordinary + /// change-detection path does the rest, at the cost of the one restart it was always going to + /// cost. + pub(crate) fn carried_latency(&self, sample_rate_hz: u32) -> Option { + let replay_pending = self.nam_ref().is_some() || self.ir_ref().is_some(); + let basis = self.latency_basis_rate.load(Ordering::Relaxed); + (replay_pending && basis != 0 && basis == sample_rate_hz) + .then(|| self.latency_samples.load(Ordering::Relaxed)) + } + + /// Records the figure `clap_plugin_latency.get` reports, and the sample rate it was measured + /// at (which [`Self::carried_latency`] later checks against). + pub(crate) fn publish_latency(&self, latency: u32, sample_rate_hz: u32) { + self.latency_samples.store(latency, Ordering::Relaxed); + self.latency_basis_rate + .store(sample_rate_hz, Ordering::Relaxed); + } + + /// D-13.2's outcome, parked for the main thread to report — **the audio thread cannot report + /// it itself** (issue #76's follow-up). + /// + /// `namir_platform::elevate_current_thread_priority` can only raise the priority of the thread + /// that calls it, so its one caller here is `crate::audio`'s `process()`. FR-ERR-030 forbids + /// logging, allocation and logging-formatting on that thread, and `xtask rt-logging` fails the + /// build if `crates/namir-clap/src/audio.rs` so much as names the logger — so the outcome + /// crosses to the main thread as two plain atomic stores and becomes a notice (and an + /// FR-ERR-010 record) in [`Self::report_thread_priority_outcome`], exactly the way `activate`'s + /// unusable-sample-rate condition already goes through `push_notice` here rather than there. + /// + /// Wait-free and allocation-free: two relaxed-and-release stores of a discriminant and an + /// `i64`. The payload is written before the discriminant is released, so a main thread that + /// acquires a non-zero discriminant sees the payload that belongs to it. + /// + /// **Returns whether the main thread is worth waking for this** — `false` for an elevation + /// that succeeded (nothing to report) and for the second and later activations of one + /// instance, since the answer cannot change within a process and one notice about it is the + /// right number. That makes the extra `request_callback` a once-per-instance event rather than + /// a once-per-activation one, which is also what keeps `tests/clap_host_latency.rs`'s waits on + /// "the plugin asked for a callback" unambiguous. + #[must_use] + pub(crate) fn record_thread_priority_outcome(&self, outcome: ThreadPriorityOutcome) -> bool { + if outcome.diagnostic().is_none() || self.thread_priority_seen.swap(true, Ordering::AcqRel) + { + return false; + } + let kind = match outcome { + ThreadPriorityOutcome::Elevated => THREAD_PRIORITY_ELEVATED, + ThreadPriorityOutcome::PermissionDenied => THREAD_PRIORITY_DENIED, + ThreadPriorityOutcome::OsError(code) => { + self.thread_priority_os_error.store(code, Ordering::Relaxed); + THREAD_PRIORITY_OS_ERROR + } + ThreadPriorityOutcome::Unsupported => THREAD_PRIORITY_UNSUPPORTED, + }; + self.thread_priority_kind.store(kind, Ordering::Release); + true + } + + /// Turns whatever [`Self::record_thread_priority_outcome`] parked into an FR-UI-070 notice and + /// an FR-ERR-010 record, once. Called from `crate::main_thread`'s `on_main_thread`. + /// + /// Reported once per outcome, not once per block: the discriminant is taken (swapped back to + /// "nothing to report") by whoever reads it, and `process()` only writes it once per audio + /// processor anyway. A successful elevation reports nothing at all — + /// `ThreadPriorityOutcome::diagnostic()` is `None` for it, which is the whole reason that + /// method exists rather than a bare `bool`. + pub(crate) fn report_thread_priority_outcome(&self) { + let kind = self + .thread_priority_kind + .swap(THREAD_PRIORITY_UNREPORTED, Ordering::Acquire); + let outcome = match kind { + THREAD_PRIORITY_ELEVATED => ThreadPriorityOutcome::Elevated, + THREAD_PRIORITY_DENIED => ThreadPriorityOutcome::PermissionDenied, + THREAD_PRIORITY_OS_ERROR => ThreadPriorityOutcome::OsError( + self.thread_priority_os_error.load(Ordering::Relaxed), + ), + THREAD_PRIORITY_UNSUPPORTED => ThreadPriorityOutcome::Unsupported, + _ => return, + }; + let Some(code) = outcome.diagnostic() else { + return; + }; + // Formatted here, on the main thread, from a value the audio thread only ever stored -- + // FR-ERR-030's "no formatting for logging on the audio thread" is why the payload crosses + // as an `i64` rather than as a message. + match outcome { + ThreadPriorityOutcome::OsError(os) => { + self.push_notice(code, format!("the OS reported error {os}")) + } + _ => self.push_notice(code, "the audio thread runs at its default priority"), + } + } + fn lock_instance(&self) -> MutexGuard<'_, Option> { lock(&self.instance) } @@ -536,4 +799,141 @@ mod tests { inner.dismiss_notice(id); assert!(inner.notices().is_empty()); } + + fn temp_config_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "namir-clap-shared-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// **Issue #96.** The roots a resolver is built from come off the `LibraryService` this + /// instance holds. Built against an injected configuration directory, so what is asserted is + /// the wiring rather than whatever this developer's machine happens to have configured. + #[test] + fn the_library_roots_are_the_services_own_and_not_an_empty_list() { + let config = temp_config_dir("roots"); + let inner = SharedInner::new_at(&config); + + let roots = inner.library_roots(); + assert_eq!( + roots, + vec![config.join("Library")], + "an empty root list here is issue #96: LibraryResolver::resolve_library_relative \ + cannot succeed against one, so FR-STATE-070's first resolution candidate is dead in \ + the plugin" + ); + + // And it is a set a real resolver can actually resolve against. + std::fs::create_dir_all(config.join("Library").join("marshall")).unwrap(); + std::fs::write(config.join("Library/marshall/jcm800.nam"), b"{}").unwrap(); + let index = namir_library::Index::empty(); + let resolver = namir_library::LibraryResolver::new(&index, &roots); + let rel = namir_state::RelPath::parse("marshall/jcm800.nam").unwrap(); + assert_eq!( + namir_state::FileResolver::resolve_library_relative(&resolver, &rel), + Some(config.join("Library/marshall/jcm800.nam")) + ); + + let _ = std::fs::remove_dir_all(&config); + } + + /// Issue #76's follow-up: D-13.2's elevation outcome is produced on the audio thread and + /// becomes a notice on the main one. A refusal is reported... + #[test] + fn a_refused_priority_elevation_becomes_a_notice_on_the_main_thread() { + let inner = SharedInner::new(); + assert!( + inner.record_thread_priority_outcome(ThreadPriorityOutcome::PermissionDenied), + "a refusal is worth waking the main thread for" + ); + assert!( + inner.notices().is_empty(), + "the audio thread must not push the notice itself -- FR-ERR-030" + ); + + inner.report_thread_priority_outcome(); + let notices = inner.notices(); + assert_eq!(notices.len(), 1); + assert_eq!( + notices[0].code.id, + ThreadPriorityOutcome::PermissionDenied + .diagnostic() + .expect("a refusal has a diagnostic") + .id + ); + + // ...once. A second callback with nothing new parked reports nothing. + inner.report_thread_priority_outcome(); + assert_eq!(inner.notices().len(), 1); + } + + /// ...and a success is not. `ThreadPriorityOutcome::diagnostic()` returning `None` for + /// `Elevated` is the whole reason that method exists rather than a bare `bool`. + #[test] + fn a_successful_priority_elevation_reports_nothing() { + let inner = SharedInner::new(); + assert!( + !inner.record_thread_priority_outcome(ThreadPriorityOutcome::Elevated), + "a successful elevation is not worth a main-thread callback" + ); + inner.report_thread_priority_outcome(); + assert!(inner.notices().is_empty()); + } + + /// An `OsError`'s payload survives the crossing, so FR-ERR-010's record names the number the + /// platform's own documentation uses — the formatting happens on the main thread, which is + /// exactly why the payload crosses as an `i64` and not as a message. + #[test] + fn an_os_error_code_survives_the_crossing_to_the_main_thread() { + let inner = SharedInner::new(); + assert!(inner.record_thread_priority_outcome(ThreadPriorityOutcome::OsError(-2147024882))); + inner.report_thread_priority_outcome(); + let notices = inner.notices(); + assert_eq!(notices.len(), 1); + assert!( + notices[0].detail.contains("-2147024882"), + "the OS's own error code must reach the record: {:?}", + notices[0].detail + ); + } + + /// **Issue #93's carrying rule**, at the field that implements it. A figure measured at one + /// sample rate is only carried across an activation at the *same* rate, and only while there + /// is something for that activation's replay to restore. + #[test] + fn a_latency_figure_is_carried_only_for_a_pending_replay_at_the_same_rate() { + let inner = SharedInner::new(); + inner.publish_latency(512, 48_000); + + assert_eq!( + inner.carried_latency(48_000), + None, + "with nothing loaded there is no replay coming, so the fresh engine's own reading is \ + the truth" + ); + + inner.set_nam_ref(Some(FileRef { + hash: namir_core::ContentHash::of(b"m"), + library_relative: None, + absolute: None, + display_name: "m.nam".to_string(), + embedded: None, + })); + assert_eq!( + inner.carried_latency(48_000), + Some(512), + "the replay will put this model, and its latency, back -- republishing zero in the \ + meantime is what made the restart unbounded (issue #93)" + ); + assert_eq!( + inner.carried_latency(44_100), + None, + "at another rate D-9.2's resampler may not be needed at all, so the old figure is not \ + evidence about the new configuration" + ); + } } diff --git a/crates/namir-clap/src/state_ext.rs b/crates/namir-clap/src/state_ext.rs index 40bf2db..9d3a63e 100644 --- a/crates/namir-clap/src/state_ext.rs +++ b/crates/namir-clap/src/state_ext.rs @@ -26,10 +26,19 @@ use crate::shared::SharedInner; /// Before the split, `push_notice`'s arm here ran in no test at all, which is what /// FR-CLAP-050's `uncovered:` field said. /// -/// Returns the same `PluginError` `load` returns for an unparseable document. -fn adopt_document_bytes(inner: &SharedInner, bytes: &[u8]) -> Result<(), PluginError> { - let (state, warnings) = - State::read(bytes).map_err(|_| PluginError::Message("failed to parse plugin state"))?; +/// **Also the preset-recall path** (`crate::worker_jobs::spawn_recall_preset`): a `.namirpreset` +/// file and a host's state blob are the same document (`docs/04-state-and-preset-format.md`), so +/// they are adopted by the same function rather than by two that could come to disagree about +/// which warnings are tolerated. +/// +/// Returns `namir-state`'s own error, not a `PluginError`: the caller that has a host stream to +/// answer maps it to one, and the caller that has a user in front of it reports the real +/// catalogue id. +pub(crate) fn adopt_document_bytes( + inner: &SharedInner, + bytes: &[u8], +) -> Result<(), namir_state::StateError> { + let (state, warnings) = State::read(bytes)?; for w in warnings { inner.push_notice(w.code, w.detail); } @@ -50,7 +59,13 @@ impl<'a> PluginStateImpl for NamirMainThread<'a> { let state = self.shared.inner.snapshot_state(); let onto = self.shared.inner.last_document(); let document = state.write_onto(&onto); - let bytes = document.to_pretty_bytes(); + // The *checked* writer: NFR-SEC-020's ceiling is enforced on the way out as well as on the + // way in, so an FR-STATE-080 embedded copy large enough to exceed it fails here rather + // than producing a blob this plugin's own `load` would refuse on the next session -- + // the moment at which the user's settings are already the thing being lost. + let bytes = document + .try_to_pretty_bytes() + .map_err(|_| PluginError::Message("the plugin state is too large to write"))?; output .write_all(&bytes) .map_err(|_| PluginError::Message("failed to write plugin state"))?; @@ -65,7 +80,8 @@ impl<'a> PluginStateImpl for NamirMainThread<'a> { .read_to_end(&mut bytes) .map_err(|_| PluginError::Message("failed to read plugin state"))?; - adopt_document_bytes(&self.shared.inner, &bytes)?; + adopt_document_bytes(&self.shared.inner, &bytes) + .map_err(|_| PluginError::Message("failed to parse plugin state"))?; // Tells the host every parameter's value should be re-queried (`clack_extensions::params`'s // own "Loading a preset" scenario) — see `NamirMainThread::notify_params_changed`'s own diff --git a/crates/namir-clap/src/ui_host.rs b/crates/namir-clap/src/ui_host.rs index 779a4df..73ff496 100644 --- a/crates/namir-clap/src/ui_host.rs +++ b/crates/namir-clap/src/ui_host.rs @@ -61,7 +61,19 @@ const MAX_DRAIN_BATCHES: usize = 8; pub(crate) struct ClapUiHost { inner: Arc, + /// A clone of the live engine's telemetry reader, or `None` before the first `activate()`. + /// + /// **Re-fetched, not captured** (issue #95). This used to be handed in at editor-open and + /// never looked at again, which broke the meters in two ordinary situations: a host that opens + /// the editor before the first `activate()` — common, and the plugin's own `get_size`/ + /// `set_parent` sequence happens on the main thread with no audio configured yet — got `None` + /// and read -inf for the editor's whole life; and every deactivate/reactivate cycle + /// (including the one the plugin itself asks for when its latency changes) installs a *fresh* + /// ring, leaving a captured clone draining a retired one for ever. telemetry: Option, + /// The [`SharedInner::telemetry_generation`] the clone above came from. When the shared + /// counter has moved past it, the clone is stale — see [`ClapUiHost::rebind_telemetry_if_stale`]. + telemetry_generation: u64, /// The last reading [`ClapUiHost::drain_meters`] actually saw, held so that a GUI frame which /// drains no telemetry shows the previous value rather than dropping to silence. /// @@ -79,15 +91,37 @@ pub(crate) struct ClapUiHost { } impl ClapUiHost { - pub(crate) fn new(inner: Arc, telemetry: Option) -> Self { + /// Builds the bridge for one editor window. Takes no telemetry reader: whichever one is live + /// is fetched on demand, including the case where none exists yet (issue #95). + pub(crate) fn new(inner: Arc) -> Self { Self { inner, - telemetry, + telemetry: None, + // `SharedInner`'s counter starts at 0 too and is bumped by every + // `set_telemetry_reader`, so "0 and no reader" is exactly the state of an instance + // that has never been activated -- and any activation, past or future, moves it. + telemetry_generation: 0, input_peak_db: f32::NEG_INFINITY, output_peak_db: f32::NEG_INFINITY, } } + /// Re-clones the telemetry reader when the engine behind it has been replaced. + /// + /// The held peaks are reset with it: they describe a ring that no longer exists, and holding + /// them would leave the meters frozen at whatever the retired engine's last block happened to + /// be until the new one publishes — reading -inf for a moment after a restart is the truth. + fn rebind_telemetry_if_stale(&mut self) { + let generation = self.inner.telemetry_generation(); + if generation == self.telemetry_generation { + return; + } + self.telemetry_generation = generation; + self.telemetry = self.inner.telemetry_reader(); + self.input_peak_db = f32::NEG_INFINITY; + self.output_peak_db = f32::NEG_INFINITY; + } + /// Reads whatever the engine has published since the last GUI frame and replaces the held /// readings with it. /// @@ -98,6 +132,7 @@ impl ClapUiHost { /// unconditional so that a frame which drained nothing holds the previous reading instead of /// flashing to silence; that is also why the fields cannot simply be reset at the top. fn drain_meters(&mut self) { + self.rebind_telemetry_if_stale(); let Some(reader) = self.telemetry.as_mut() else { return; }; @@ -145,6 +180,9 @@ impl UiHost for ClapUiHost { loaded_model_name: self.inner.nam_ref().map(|r| r.display_name), loaded_ir_name: self.inner.ir_ref().map(|r| r.display_name), library: self.inner.library_snapshot(), + // FR-STATE-030's preset list. Enumerated off-thread and cached — see + // `SharedInner::presets_snapshot`; a GUI frame never reads a directory. + presets: self.inner.presets_snapshot(), // FR-IO-020's indicator is `None` here, and permanently: a CLAP plugin never opens an // audio device — the host owns it, and hands this plugin buffers it has already // captured — so there is no share mode this crate could report without inventing one. @@ -163,10 +201,22 @@ impl UiHost for ClapUiHost { self.set_param(key, default); } } - UiIntent::LibraryQueryChanged(_query) => { - // FR-UI-060's filtering is computed inside `namir-ui` itself from the raw index - // this host already hands it every frame (`library_view`'s own module doc - // comment); the query text has nothing for a `UiHost` to act on. + UiIntent::SavePreset { name } => { + worker_jobs::spawn_save_preset(Arc::clone(&self.inner), name); + // Left out of the `mark_dirty` below with `RecallPreset`, and for the mirror-image + // reason: a save is what *clears* the dirty flag, and the job that writes the file + // is the one that clears it once the bytes are actually on disk. + return; + } + UiIntent::RecallPreset { path } => { + worker_jobs::spawn_recall_preset(Arc::clone(&self.inner), path); + // **The carve-out**: a recall makes this instance *match* what was last + // recalled, which is the definition of not-dirty (`UiSnapshot::unsaved_changes`). + // Marking it dirty here would also race the job that marks it clean, so the + // flag is left entirely to `adopt_document_bytes`, exactly as it is for a + // host-driven `state` load. `SavePreset` is left out for the same reason from + // the other direction. + return; } UiIntent::LoadLibraryEntry(path) => { worker_jobs::spawn_load_library_entry(Arc::clone(&self.inner), path); @@ -181,7 +231,12 @@ impl UiHost for ClapUiHost { impl ClapUiHost { fn set_param(&self, key: &'static str, value: f32) { - self.inner.params.set_by_key(key, value); + // `set_by_key_from_gui`, not `set_by_key`: this is the one path in this crate a user + // gesture *inside the plugin's own editor* takes, so the change is also queued for + // delivery to the host as automation (issue #94 — see + // `crate::params_ext::emit_gui_param_changes`). Host-originated writes deliberately use + // the unmarked setter, or the plugin would echo the host's automation back at it. + self.inner.params.set_by_key_from_gui(key, value); let Some(descriptor) = REGISTRY.iter().find(|d| d.key == key) else { return; }; @@ -210,20 +265,31 @@ mod tests { use namir_params::stages::trim; fn host() -> ClapUiHost { - ClapUiHost::new(Arc::new(SharedInner::new()), None) + ClapUiHost::new(Arc::new(SharedInner::new())) } /// A host wired to a real telemetry ring, plus the producer end to publish into it. /// - /// Every test above this point passes `None` for the reader, which is exactly why the ratchet - /// below survived from M6 to M13: `drain_meters` returned at its first line in every test that + /// Every test above this point had no reader at all, which is exactly why the ratchet below + /// survived from M6 to M13: `drain_meters` returned at its first line in every test that /// existed, so the only meter code in this crate was never executed by the suite at all. + /// + /// The reader is installed **through `SharedInner`**, the way `crate::audio`'s `activate` does + /// it, rather than handed to the constructor — since issue #95 that is the only way it can be + /// installed, and it is what lets the two tests below drive the transitions the old + /// captured-once reader could not survive. fn host_with_telemetry() -> (ClapUiHost, namir_engine::TelemetryProducer) { + let inner = Arc::new(SharedInner::new()); + let producer = install_ring(&inner); + (ClapUiHost::new(inner), producer) + } + + /// Installs a fresh telemetry ring on `inner`, exactly as an `activate()` would, and returns + /// the producer end. + fn install_ring(inner: &Arc) -> namir_engine::TelemetryProducer { let (producer, reader) = namir_engine::telemetry_ring(256); - ( - ClapUiHost::new(Arc::new(SharedInner::new()), Some(reader)), - producer, - ) + inner.set_telemetry_reader(Some(reader)); + producer } fn publish(producer: &mut namir_engine::TelemetryProducer, id: u32, value: f32) { @@ -298,6 +364,125 @@ mod tests { assert_eq!(h.snapshot().input_meter.peak_db, f32::NEG_INFINITY); } + /// **Issue #95, first half.** A host is entitled to open the editor before it ever activates + /// the plugin — and the plugin's own `gui` sequence (`create`, `get_size`, `set_parent`) is + /// all `[main-thread]` work with no audio configuration in sight, so this is the ordinary + /// order, not an exotic one. The reader captured at editor-open was `None` then, and the + /// meters read -inf for the editor's entire life. + #[test] + fn an_editor_opened_before_the_first_activation_still_gets_meters() { + let inner = Arc::new(SharedInner::new()); + let mut h = ClapUiHost::new(Arc::clone(&inner)); + + // The editor is already rendering frames, and there is nothing to show yet. + assert_eq!(h.snapshot().output_meter.peak_db, f32::NEG_INFINITY); + + // ...and now the host activates the plugin, which installs a ring. + let mut producer = install_ring(&inner); + publish(&mut producer, telemetry_output_peak_id(0), -7.5); + + assert_eq!( + h.snapshot().output_meter.peak_db, + -7.5, + "an editor opened before the first activate() must pick up the ring that activation \ + installs, not stay bound to the absence it saw at set_parent time" + ); + } + + /// **Issue #95, second half.** Every deactivate/reactivate cycle installs a *fresh* ring + /// (`crate::audio`'s `activate` builds a whole new engine), including the cycle the plugin + /// itself asks for when its latency changes. A reader cloned once at editor-open goes on + /// draining the retired ring, so the meters freeze at whatever the old engine last published. + #[test] + fn a_reactivation_rebinds_the_meters_to_the_new_ring() { + let inner = Arc::new(SharedInner::new()); + let mut h = ClapUiHost::new(Arc::clone(&inner)); + + let mut first = install_ring(&inner); + publish(&mut first, telemetry_output_peak_id(0), -6.0); + assert_eq!(h.snapshot().output_meter.peak_db, -6.0); + + // deactivate(): the engine, and its ring, are gone. + inner.set_telemetry_reader(None); + assert_eq!( + h.snapshot().output_meter.peak_db, + f32::NEG_INFINITY, + "with no engine there is no signal, and holding the retired ring's last reading would \ + show a meter that is simply wrong" + ); + + // activate(): a new engine, a new ring. + let mut second = install_ring(&inner); + publish(&mut second, telemetry_output_peak_id(0), -21.0); + assert_eq!( + h.snapshot().output_meter.peak_db, + -21.0, + "the meters must follow the live engine across a restart -- publishing into the ring \ + the new activation installed must move them" + ); + + // The retired ring is genuinely no longer read. + publish(&mut first, telemetry_output_peak_id(0), -1.0); + assert_eq!( + h.snapshot().output_meter.peak_db, + -21.0, + "a publish into the retired ring must not move the meters" + ); + } + + /// **Issue #94.** A knob turned in the plugin's own editor is queued for delivery to the host + /// as automation; nothing else that writes the mirror is. + #[test] + fn a_gui_param_change_is_queued_for_the_host_but_host_automation_is_not() { + let mut h = host(); + assert!(!h.inner.params.has_gui_pending()); + + h.dispatch(UiIntent::SetParam { + key: trim::GAIN_DB.key, + value: 3.0, + }); + assert!( + h.inner.params.has_gui_pending(), + "a GUI-originated change must be queued for the host, or automation written from the \ + editor is silently lost" + ); + h.inner.params.take_gui_pending(); + + // The path host automation takes (`crate::audio`'s `apply_direct_and_mirror`) writes the + // same mirror and must *not* queue anything, or the plugin echoes the host back at itself. + h.inner.params.set_by_id(trim::GAIN_DB.id.0, 4.0); + assert!( + !h.inner.params.has_gui_pending(), + "a host-originated change must not be reported back to the host" + ); + } + + /// A reset gesture is a user gesture too, and reaches the host the same way. + #[test] + fn a_reset_to_default_is_queued_for_the_host_as_well() { + let mut h = host(); + h.dispatch(UiIntent::ResetParamToDefault { + key: trim::GAIN_DB.key, + }); + assert!(h.inner.params.has_gui_pending()); + } + + /// FR-STATE-030: recalling a preset makes this instance *match* what was recalled, so it must + /// not be left looking like it has unsaved changes. + #[test] + fn recalling_a_preset_does_not_mark_the_instance_dirty() { + let mut h = host(); + assert!(!h.inner.is_dirty()); + h.dispatch(UiIntent::RecallPreset { + path: std::path::PathBuf::from("no-such-preset.namirpreset"), + }); + assert!( + !h.inner.is_dirty(), + "a recall is the definition of not-dirty; the job that adopts the document owns the \ + flag from here" + ); + } + #[test] fn snapshot_reflects_the_mirror() { let mut h = host(); diff --git a/crates/namir-clap/src/worker_jobs.rs b/crates/namir-clap/src/worker_jobs.rs index e3b1766..5864589 100644 --- a/crates/namir-clap/src/worker_jobs.rs +++ b/crates/namir-clap/src/worker_jobs.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use namir_core::ContentHash; -use namir_state::FileRef; +use namir_state::{FileRef, RelPath}; use namir_worker::{LoadSource, Target}; use crate::shared::SharedInner; @@ -94,11 +94,13 @@ fn record_reference( ) { let reference = FileRef { hash, - // A full library-relative path needs the matching root identity, which - // `namir_library::LibraryEntry` does not carry back to the caller today (only the - // resolved absolute path) -- recorded as `absolute` (FR-STATE-070's second resolution - // candidate) rather than its first, library-relative one. See this module's doc comment. - library_relative: None, + // FR-STATE-070's **first** resolution candidate, and the one that makes a preset portable + // between two machines whose library sits at two different absolute paths. It used to be + // a hardcoded `None` here on the grounds that `namir_library::LibraryEntry` hands back + // only the resolved absolute path -- but the root it was found under is not a mystery, + // it is one of the roots this instance's own `LibraryService` is configured with, and + // stripping it off is all "library-relative" means (issue #96's other half). + library_relative: library_relative_reference(shared, path), absolute: Some(path.to_string_lossy().into_owned()), display_name, embedded: None, @@ -109,6 +111,113 @@ fn record_reference( } } +/// `path` expressed relative to whichever of this instance's library roots contains it, or `None` +/// if it lies outside all of them (a file the user loaded from somewhere else entirely, for which +/// there is no library-relative form to record). +/// +/// The first containing root wins, matching the order `namir_library::LibraryResolver` itself +/// tries them in, so a path recorded here resolves back to the same file it came from. +fn library_relative_reference(shared: &SharedInner, path: &Path) -> Option { + shared.library_roots().iter().find_map(|root| { + let relative = path.strip_prefix(root).ok()?; + RelPath::from_relative_path(relative).ok() + }) +} + +/// FR-STATE-030's save half: write this instance's current state to `/`. +/// +/// On the pool, not the GUI thread, for the same reason every other job in this module is: it +/// creates a directory, serialises a document and writes a file. Every failure it can meet — no +/// preset directory on this system, a name that cannot be a filename, a document over +/// NFR-SEC-020's ceiling, a write the OS refused — becomes an FR-UI-070 notice rather than a +/// silently dropped click. +pub(crate) fn spawn_save_preset(shared: Arc, name: String) { + let inner = Arc::clone(&shared); + shared.pool.spawn(move || { + let shared = inner; + let Some(dir) = crate::presets::preset_dir() else { + shared.push_notice( + crate::error_codes::PRESET_UNAVAILABLE, + "this system has no per-user configuration directory to keep presets in", + ); + return; + }; + let Some(path) = crate::presets::preset_path(&dir, &name) else { + shared.push_notice( + crate::error_codes::PRESET_UNAVAILABLE, + format!("{name:?} is not a usable preset name"), + ); + return; + }; + if let Err(e) = std::fs::create_dir_all(&dir) { + shared.push_notice( + crate::error_codes::PRESET_IO_FAILED, + format!("{}: {e}", dir.display()), + ); + return; + } + + // The same document a host `state` save produces -- D-11.2's write-back included, so a + // preset written by a build that did not understand every section still carries them -- + // and the same *checked* writer, so a preset this build cannot read back is never written. + let document = shared.snapshot_state().write_onto(&shared.last_document()); + let bytes = match document.try_to_pretty_bytes() { + Ok(bytes) => bytes, + Err(e) => { + shared.push_notice(e.code, e.detail); + return; + } + }; + match std::fs::write(&path, &bytes) { + Ok(()) => { + shared.set_last_document(document); + shared.mark_clean(); + shared.mark_presets_stale(); + } + Err(e) => shared.push_notice( + crate::error_codes::PRESET_IO_FAILED, + format!("{}: {e}", path.display()), + ), + } + }); +} + +/// FR-STATE-030's recall half: load the preset at `path` onto this instance. +/// +/// Follows the host-driven `state` load exactly (`crate::state_ext`) — the same +/// `adopt_document_bytes`, the same `spawn_recall` afterwards — because a `.namirpreset` and a +/// host's state blob are the same document. The two differences are both about who is asking: +/// the bytes come from a file rather than a `clap_istream`, and the host has to be told its +/// cached parameter values are stale, which a GUI-thread caller cannot do itself (see +/// `crate::main_thread`'s `notify_params_changed`). +pub(crate) fn spawn_recall_preset(shared: Arc, path: PathBuf) { + let inner = Arc::clone(&shared); + shared.pool.spawn(move || { + let shared = inner; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + shared.push_notice( + namir_worker::error_codes::FILE_UNREADABLE, + format!("{}: {e}", path.display()), + ); + return; + } + }; + if let Err(e) = crate::state_ext::adopt_document_bytes(&shared, &bytes) { + shared.push_notice(e.code, e.detail); + return; + } + // The mirror now holds values the host has never seen. It cannot be told from here -- + // this is a pool thread and `HostParams::rescan` is `[main-thread]` -- so the request is + // parked for whichever main-thread callback comes next. + shared + .params_rescan_pending + .store(true, std::sync::atomic::Ordering::Release); + spawn_recall(Arc::clone(&shared)); + }); +} + /// FR-STATE-030/050's replay: whatever `shared`'s `ParamMirror`/resource references currently /// stand for is pushed onto its live engine (if any — see `SharedInner::with_instance`'s own /// doc comment for why "no engine yet" is a normal, non-error outcome here). Shared between @@ -129,7 +238,15 @@ pub(crate) fn spawn_recall(shared: Arc) { return; // Nothing to replay; the common case for a brand-new instance. } let index = shared.library_snapshot().index; - let roots: Vec = Vec::new(); + // **Issue #96: the real roots, off the `LibraryService` this instance already holds.** + // This was a hardcoded `Vec::new()`, so `LibraryResolver::resolve_library_relative` could + // never succeed here and FR-STATE-070's *first* resolution candidate was dead in the + // plugin: a preset carrying a `library_relative` reference resolved in `namir-app` (which + // passes `LibraryService::roots()`) and fell through to hash search -- or reported Missing + // -- in `namir-clap`. That is an FR-CFG-020 parity divergence, and the same "the two + // shells' library wiring drifted apart" failure `crate::shared`'s own module doc comment + // records one layer up for the bootstrap itself. + let roots = shared.library_roots(); let resolver = namir_library::LibraryResolver::new(&index, &roots); shared.with_instance(|instance| { let outcome = instance.recall(&shared.cache, &state, &resolver); @@ -160,3 +277,78 @@ fn library_target(shared: &SharedInner, path: &Path) -> Option { namir_library::ItemKind::Ir => Target::Ir, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared::SharedInner; + + fn temp_config_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "namir-clap-worker-jobs-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// **Issue #96's other half.** A file loaded from inside a library root is recorded with + /// FR-STATE-070's *first* resolution candidate, not only its absolute path — that is the one + /// field that survives the project being opened on another machine whose library sits + /// somewhere else. + #[test] + fn a_file_under_a_library_root_is_recorded_library_relative() { + let config = temp_config_dir("relative"); + let shared = SharedInner::new_at(&config); + let path = config.join("Library").join("marshall").join("jcm800.nam"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"{}").unwrap(); + + record_reference( + &shared, + Target::Nam, + ContentHash::of(b"{}"), + "jcm800.nam".to_string(), + &path, + ); + + let reference = shared.nam_ref().expect("the reference must be recorded"); + assert_eq!( + reference.library_relative.as_ref().map(|r| r.as_str()), + Some("marshall/jcm800.nam"), + "a hardcoded None here is why a preset's library_relative reference resolved in \ + namir-app and missed in the plugin" + ); + assert_eq!( + reference.absolute, + Some(path.to_string_lossy().into_owned()) + ); + + let _ = std::fs::remove_dir_all(&config); + } + + /// A file from outside every root has no library-relative form, and inventing one would be + /// worse than recording none: it would resolve, on another machine, to a different file. + #[test] + fn a_file_outside_every_root_records_no_library_relative_form() { + let config = temp_config_dir("outside"); + let shared = SharedInner::new_at(&config); + let path = config.join("elsewhere.nam"); + std::fs::write(&path, b"{}").unwrap(); + + record_reference( + &shared, + Target::Ir, + ContentHash::of(b"{}"), + "elsewhere.nam".to_string(), + &path, + ); + + let reference = shared.ir_ref().expect("the reference must be recorded"); + assert!(reference.library_relative.is_none()); + assert!(reference.absolute.is_some()); + + let _ = std::fs::remove_dir_all(&config); + } +} diff --git a/crates/namir-clap/tests/clap_host_gui.rs b/crates/namir-clap/tests/clap_host_gui.rs index 8835151..a73f782 100644 --- a/crates/namir-clap/tests/clap_host_gui.rs +++ b/crates/namir-clap/tests/clap_host_gui.rs @@ -412,3 +412,74 @@ fn declining_the_gui_renders_bit_identical_audio_to_opening_it() { opened_output[differing.unwrap_or_default()] ); } + +/// Issue #98, and the upstream defect that stops it being observable here. +/// +/// `crates/namir-clap/src/gui.rs`'s `set_size` now refuses every size but the fixed 960x640 it +/// actually has — `can_resize()` is `false` and `get_size()` returns that same figure whatever a +/// host asks for, so reporting success for anything else tells a host it may size its parent +/// window to a figure the editor never adopted. Its unit test (`gui::tests`) asserts that +/// decision directly. +/// +/// **This test asserts the opposite, on purpose, and is a live record rather than an approval.** +/// `clack-extensions` 0.1.1's `set_size` trampoline (`src/gui/plugin.rs:403-412`) reads +/// `PluginWrapper::handle(plugin, |p| Ok(p.main_thread().as_mut().set_size(size))).is_some()`: +/// the plugin's `Result` is wrapped as the closure's *success value*, so the boolean the host +/// receives says only "the call did not panic". `set_scale`, `show` and `hide` in that same file +/// are all `Ok(...is_ok())`, so this is one function's defect, not the crate's convention — and +/// this workspace pins clack exactly (D-14.2, R-2), so it is not this crate's to fix. +/// +/// The day that pin moves to a version that transmits the answer, this test fails and says so, +/// which is exactly what a recorded gap is for. What *is* asserted unconditionally is the part +/// that matters to a host either way: nothing about the editor moved. +#[cfg(feature = "host-ext-tests")] +#[test] +fn a_refused_set_size_changes_nothing_and_clack_0_1_1_swallows_the_refusal() { + use clack_extensions::gui::{GuiSize, PluginGui}; + use support::{main_thread_handle, require_plugin_extension}; + + let (_entry, mut instance) = instantiate_default(); + let gui = require_plugin_extension::(&mut instance); + let mut handle = main_thread_handle(&mut instance); + + gui.create(&mut handle, EMBEDDED_WIN32) + .expect("create must succeed for the negotiated configuration"); + + for requested in [ + GuiSize { + width: 800, + height: 600, + }, + GuiSize { + width: 1920, + height: 1080, + }, + GuiSize { + width: EXPECTED_GUI_SIZE.width, + height: 480, + }, + ] { + assert!( + gui.set_size(&mut handle, requested).is_ok(), + "clack 0.1.1 reports success for every set_size call; if this now fails, the pin has \ + moved to a version that transmits the plugin's refusal and this test should assert \ + Err(GuiError::SetSizeError) instead -- src/gui.rs already returns it" + ); + assert_eq!( + gui.get_size(&mut handle), + Some(EXPECTED_GUI_SIZE), + "whatever the host was told, the editor is still the size it always was" + ); + assert!( + !gui.can_resize(&mut handle), + "and still says it cannot be resized" + ); + } + + assert!( + gui.set_size(&mut handle, EXPECTED_GUI_SIZE).is_ok(), + "the size the editor already has is a request it does satisfy" + ); + + gui.destroy(&mut handle); +} diff --git a/crates/namir-clap/tests/clap_host_latency.rs b/crates/namir-clap/tests/clap_host_latency.rs index 4b8b232..9bc07d4 100644 --- a/crates/namir-clap/tests/clap_host_latency.rs +++ b/crates/namir-clap/tests/clap_host_latency.rs @@ -269,13 +269,15 @@ mod host_ext { /// `clap_plugin_latency.get`. /// 2. **The restart the plugin asked for.** Deactivate/reactivate; `activate()` announces the /// change with `HostLatency::changed`, which is the only point CLAP permits it while a - /// plugin is being (re)activated. The freshly built engine reports `0` again — the model is - /// replayed asynchronously (`crate::worker_jobs::spawn_recall`), so this is a genuine second - /// transition rather than a repeat of the first. - /// 3. **The same change announced directly.** The replay lands, the latency moves back off - /// zero, and this time the host deactivates *before* servicing the callback — the other - /// branch of `on_main_thread`, which announces directly because a restart is meaningless - /// for an inactive plugin. + /// plugin is being (re)activated. The figure it announces is the one the host already has — + /// **not zero** (issue #93): the freshly built engine does report zero, but its replay is + /// already dispatched and will put the same model, and the same latency, straight back, so + /// `SharedInner::carried_latency` keeps the converged figure across the activation rather + /// than publishing a transient the plugin would then have to restart for again. + /// 3. **The same change announced directly.** The replay lands, the engine's own reading moves + /// back off zero, and this time the host deactivates *before* servicing the callback — the + /// other branch of `on_main_thread`, which announces directly because a restart is + /// meaningless for an inactive plugin. // trace: FR-CLAP-040 #[test] fn a_model_change_that_adds_resampling_latency_is_reported_and_notified_on_every_transition() { @@ -373,9 +375,10 @@ mod host_ext { ); assert_eq!( latency.get(&mut main_thread_handle(&mut instance)), - 0, - "the freshly built engine has no model yet (the replay is dispatched to the worker \ - pool), so the announced figure is zero again" + expected, + "issue #93: the freshly built engine has no model yet, but its replay is already \ + dispatched and converges on the same figure -- announcing the transient zero here is \ + what made every activation observe a latency *change* and ask to be restarted for it" ); // -- Limb 3: the replay lands, and the callback is serviced while inactive ------------ @@ -457,4 +460,123 @@ mod host_ext { std::thread::yield_now(); } } + + /// **Issue #93: the restart FR-CLAP-040's contract requires must terminate.** + /// + /// The test above stops the sequence by deactivating before it services the second callback, + /// so it never re-enters `on_main_thread`'s `active` branch a second time. A real host does + /// not: it honours the restart request with a deactivate/reactivate cycle and then keeps + /// processing. Every `activate()` builds a *default* engine (latency 0) and dispatches + /// `spawn_recall` to reload the model asynchronously, so the same model that provoked the + /// first restart provokes the identical change again on every subsequent activation — and, + /// before this was fixed, another `request_restart` with it, for as long as a rate-mismatched + /// model stayed loaded. + /// + /// This drives the loop the way a host would and asserts it closes: **at most one** restart, + /// and the figure the host is left reading is the real one. + #[test] + fn a_rate_mismatched_model_asks_for_one_restart_and_then_settles() { + /// Blocks processed after the replay has landed, to give a plugin that is still churning + /// a chance to ask for another restart before this test concludes it has settled. + const SETTLE_BLOCKS: usize = 64; + + let model = model_json_bytes(); + let expected = engine_latency_for(&model); + assert!( + expected > 0, + "a {MODEL_RATE_HZ} Hz model in a {DEFAULT_SAMPLE_RATE} Hz engine must engage D-9.2's \ + resampler; with zero there is no restart for this test to bound" + ); + let document = state_document_bytes(&model); + + let (_entry, mut instance) = instantiate_default(); + let latency = require_plugin_extension::(&mut instance); + let state = require_plugin_extension::(&mut instance); + + let mut processor = activate_default(&mut instance) + .start_processing() + .expect("processing must start"); + let mut bufs = StereoBuffers::default_size(); + let tone = sine_1k(bufs.max_frames(), DEFAULT_SAMPLE_RATE, AMPLITUDE); + bufs.fill_input(|_channel, frame| tone[frame]); + + // A few blocks before anything is measured, for the same reason limb 0 of the test above + // runs them: the *first* `process()` of an instance may request a main-thread callback + // for D-13.2's thread-priority outcome (`SharedInner::record_thread_priority_outcome`, + // once per instance), and a wait on "the plugin asked for a callback" has to be about + // latency and nothing else. + for _ in 0..4 { + audio_section(|| bufs.process_block(&mut processor, BLOCK)) + .expect("a warm-up block must process"); + } + instance.access_shared_handler(|shared| shared.reset_request_counts()); + + // The host loads the project's state, and the model's latency reaches the audio thread. + let mut reader = document.as_slice(); + state + .load(&mut main_thread_handle(&mut instance), &mut reader) + .expect("the host-driven state load must succeed"); + process_until( + &mut bufs, + &mut processor, + LIMB_TIMEOUT, + "first change", + || instance.access_shared_handler(|shared| shared.callback_requests()) > 0, + ); + + // A host services the callback and honours the restart it produces. + instance.call_on_main_thread_callback(); + assert_eq!( + instance.access_shared_handler(|shared| shared.restart_requests()), + 1, + "the first latency change must produce exactly one restart request" + ); + let stopped = processor.stop_processing(); + instance.deactivate(stopped); + instance.access_shared_handler(|shared| shared.reset_request_counts()); + let mut processor = activate_default(&mut instance) + .start_processing() + .expect("processing must restart"); + + // ...and then keeps processing. The activation's own replay restores the same model and + // therefore the same latency; that is not a *new* change, and must not cost another + // restart. + process_until(&mut bufs, &mut processor, LIMB_TIMEOUT, "replay", || { + instance.access_shared_handler(|shared| shared.callback_requests()) > 0 + }); + instance.call_on_main_thread_callback(); + assert_eq!( + instance.access_shared_handler(|shared| shared.restart_requests()), + 0, + "the replay of the model the plugin already restarted for asked for a second restart \ + -- that is issue #93's loop: every activate() rebuilds a default engine at latency 0, \ + reloads the same model, and observes the same change again, indefinitely" + ); + + for _ in 0..SETTLE_BLOCKS { + audio_section(|| bufs.process_block(&mut processor, BLOCK)) + .expect("a settled block must process"); + if instance.access_shared_handler(|shared| shared.callback_requests()) > 0 { + instance.access_shared_handler(|shared| shared.reset_request_counts()); + instance.call_on_main_thread_callback(); + } + assert_eq!( + instance.access_shared_handler(|shared| shared.restart_requests()), + 0, + "the plugin is still asking to be restarted after it has settled" + ); + } + + assert_eq!( + latency.get(&mut main_thread_handle(&mut instance)), + expected, + "after the one restart it asked for, the figure the host reads must be the real \ + latency of the model that is loaded -- not the zero a freshly rebuilt engine reports \ + while its replay is still in flight" + ); + + let stopped = processor.stop_processing(); + instance.deactivate(stopped); + drop(instance); // `clap_plugin.destroy` + } } diff --git a/crates/namir-clap/tests/fr_cfg_020_shell_parity.rs b/crates/namir-clap/tests/fr_cfg_020_shell_parity.rs index e07d8d2..669a168 100644 --- a/crates/namir-clap/tests/fr_cfg_020_shell_parity.rs +++ b/crates/namir-clap/tests/fr_cfg_020_shell_parity.rs @@ -101,7 +101,7 @@ mod host_ext { HostInfo, ShareMode, StreamFailure, StreamParams, SupportedConfigRange, }; use namir_app::instance::SharedInstance; - use namir_app::stream::{self, Direction, StreamSetup}; + use namir_app::stream::{self, StreamSetup}; use namir_app::worker::{ AppCommand, AppEvent, LoadOutcomeSummary, RecallOutcomeSummary, WorkerContext, WorkerHandle, }; @@ -795,13 +795,20 @@ mod host_ext { let xruns = Arc::new(XrunCounter::new()); let failures = Arc::new(AtomicUsize::new(0)); let streams = { - let failures = Arc::clone(&failures); + // One callback per direction since issue #88 (`namir_app::stream::open`'s own doc + // comment): both are pointed at the same counter here, because this test only cares + // that no stream failed at all, not which side would have. + let input_failures = Arc::clone(&failures); + let output_failures = Arc::clone(&failures); stream::open( app_stream_setup(&backend), engine, Arc::clone(&xruns), - move |_direction: Direction, _failure: StreamFailure| { - failures.fetch_add(1, Ordering::SeqCst); + move |_failure: StreamFailure| { + input_failures.fetch_add(1, Ordering::SeqCst); + }, + move |_failure: StreamFailure| { + output_failures.fetch_add(1, Ordering::SeqCst); }, ) .expect("the streams must open") diff --git a/crates/namir-clap/tests/support/mod.rs b/crates/namir-clap/tests/support/mod.rs index 23ec4b6..d8a7acc 100644 --- a/crates/namir-clap/tests/support/mod.rs +++ b/crates/namir-clap/tests/support/mod.rs @@ -511,9 +511,14 @@ impl StereoBuffers { output_ports: AudioPorts::with_capacity(CHANNELS, 1), input: [vec![0.0; max_frames], vec![0.0; max_frames]], output: [vec![0.0; max_frames], vec![0.0; max_frames]], - // 64 standard-event slots up front so a plugin that emits output events does not - // allocate mid-block and trip `audio_section`. Namir emits none today. - out_events: EventBuffer::with_capacity(64), + // Standard-event slots up front so a plugin that emits output events does not + // allocate mid-block and trip `audio_section`. Namir *does* emit them since issue + // #94: a parameter the user moved in the plugin's own editor comes out as a + // gesture-wrapped automation point (three events), so the worst case is three times + // `namir_params::REGISTRY`'s length in one block -- 96 today. Sized past that, since + // an `EventBuffer` that has to grow allocates, and this buffer is written from inside + // an `audio_section`. + out_events: EventBuffer::with_capacity(256), max_frames, steady_time: 0, } diff --git a/crates/namir-ui/src/app.rs b/crates/namir-ui/src/app.rs index bb698ed..3e078e4 100644 --- a/crates/namir-ui/src/app.rs +++ b/crates/namir-ui/src/app.rs @@ -23,6 +23,11 @@ use crate::{UiIntent, meter}; #[derive(Default)] pub struct ViewState { library: LibraryViewState, + /// What the user has typed into the preset-name box, between the frame they type it and the + /// frame they press Save. This crate's own transient state -- a half-typed preset name is not + /// engine state and never reaches a host, which is exactly why it lives here rather than in a + /// [`UiSnapshot`] field the host would have to echo back every frame. + preset_name: String, /// The brand mark's uploaded texture, `None` until the first frame draws it. Cached here /// rather than re-uploaded per frame -- see `brand`'s module doc comment for why this crate's /// own view state is the right owner for it. @@ -58,6 +63,7 @@ pub fn render( ); } }); + preset_controls(ui, snapshot, &mut view.preset_name, intents); notices::render(ui, &snapshot.notices, intents); }); @@ -93,13 +99,19 @@ pub fn render( .as_deref() .unwrap_or("(no IR loaded)"), ); - param_section(ui, "Impulse Response", "ir.", &snapshot.params, intents); + // Heading already drawn above the IR-name label this section belongs to -- see + // `param_controls`' doc comment (issue #103). + param_controls(ui, "ir.", &snapshot.params, intents); param_section(ui, "EQ", "eq.", &snapshot.params, intents); - ui.heading("Output"); + // No `ui.heading("Output")` here: the meter row below is labelled "Output" and + // both controls under it are named "Output ...", so a heading would be the third + // "Output" on four consecutive rows -- the same duplication issue #103 reports, + // with the meter as the element in between. Mirrors the input side, where the + // "Input" meter likewise stands as its own row above its controls. meter::render(ui, "Output", snapshot.output_meter); - param_section(ui, "Output", "out.", &snapshot.params, intents); + param_controls(ui, "out.", &snapshot.params, intents); render_single(ui, &OUTPUT_CEILING_DB, &snapshot.params, intents); ui.separator(); @@ -108,6 +120,87 @@ pub fn render( }); } +/// FR-STATE-030's two controls: name a preset and save it, or pick one the host listed and +/// recall it. Appends [`UiIntent::SavePreset`] / [`UiIntent::RecallPreset`] for whichever the user +/// operated this frame. +/// +/// # Why here, and why these two shapes (issue #100) +/// +/// FR-STATE-030 is a Must and this crate is the only GUI, so a save and a recall gesture have to +/// exist here or they exist nowhere. Before this row, `UiSnapshot::unsaved_changes` was rendered +/// two labels to the left as "* unsaved changes" and `UiIntent` had no variant that could resolve +/// it: the screen stated a problem and offered no control for it. The row is placed beside that +/// indicator for exactly that reason. +/// +/// **Save takes a name; recall takes a path.** Neither is a file dialog, and that asymmetry is +/// D-5.1's, not a shortcut. This crate may not depend on `namir-platform`, so it cannot know where +/// a preset directory is; it therefore hands the host a *name* to place, and can only offer for +/// recall the paths the host itself listed in [`UiSnapshot::presets`]. A host that wants a real +/// file picker can still open one when it receives either intent -- which is also where +/// NFR-PORT-030's "no blocking dialog on an audio-affecting path" has to be honoured, since only +/// the host knows what its own dialog would block. +/// +/// The save button is disabled while the box is empty rather than emitting an empty name: "a +/// **named** preset" is the requirement's own wording, and a host handed an empty name could only +/// invent a filename or refuse. +fn preset_controls( + ui: &mut egui::Ui, + snapshot: &UiSnapshot, + preset_name: &mut String, + intents: &mut Vec, +) { + ui.horizontal(|ui| { + let label = ui + .add(egui::Label::new("Preset").sense(egui::Sense::hover())) + .on_hover_text( + "Save the current settings under a name, or recall one you saved earlier. \ + Presets are interchangeable between the standalone application and the plugin.", + ); + let entry = ui + .add( + egui::TextEdit::singleline(preset_name) + .hint_text("Preset name") + .desired_width(160.0), + ) + .labelled_by(label.id); + + let name = preset_name.trim().to_string(); + // Enter inside the box saves too -- the same gesture the button is, for a user whose + // hands are already on the keyboard (FR-UI-030's "operable by keyboard"). + let entered = entry.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let save = ui + .add_enabled(!name.is_empty(), egui::Button::new("Save preset")) + .on_hover_text("Save the current settings as a preset under the name to the left."); + if !name.is_empty() && (save.clicked() || entered) { + intents.push(UiIntent::SavePreset { name }); + } + + let mut recalled: Option = None; + let has_presets = !snapshot.presets.is_empty(); + ui.add_enabled_ui(has_presets, |ui| { + egui::ComboBox::from_id_salt("namir_ui_preset_recall") + .selected_text(if has_presets { + "Recall preset" + } else { + "No saved presets" + }) + .show_ui(ui, |ui| { + for preset in &snapshot.presets { + // `false`: this is a menu of actions, not a selection that persists -- + // nothing in a snapshot says which preset is "current", and claiming one + // was would be the same class of lie `audio_mode_label` refuses to tell. + if ui.selectable_label(false, &preset.name).clicked() { + recalled = Some(preset.path.clone()); + } + } + }); + }); + if let Some(path) = recalled { + intents.push(UiIntent::RecallPreset { path }); + } + }); +} + /// FR-IO-020's mode indicator as one line of text. Deliberately **not** routed through /// [`param_section`]: a share mode is not a `namir_params::REGISTRY` entry, and /// `every_registry_key_is_covered_by_a_section_prefix_or_a_named_single_control` (below) pins every @@ -125,10 +218,8 @@ fn audio_mode_label(mode: &crate::host::AudioModeStatus) -> String { format!("{name} mode — {}", mode.device_name) } -/// One [`param_control`] for every `REGISTRY` entry whose key starts with `prefix`, under a -/// heading -- reads the live registry rather than a hand-maintained per-section list, so a -/// parameter added to a stage's descriptor module (`namir-params/src/stages/*.rs`) appears here -/// automatically. +/// A heading, then [`param_controls`] for `prefix` -- the ordinary section, for the four stages +/// whose heading has nothing between it and its own controls. fn param_section( ui: &mut egui::Ui, title: &str, @@ -137,6 +228,26 @@ fn param_section( intents: &mut Vec, ) { ui.heading(title); + param_controls(ui, prefix, params, intents); +} + +/// One [`param_control`] for every `REGISTRY` entry whose key starts with `prefix`, with **no** +/// heading of its own -- reads the live registry rather than a hand-maintained per-section list, +/// so a parameter added to a stage's descriptor module (`namir-params/src/stages/*.rs`) appears +/// here automatically. +/// +/// Split out of [`param_section`] for issue #103. Two sections put something between their +/// heading and their controls -- the IR name under "Impulse Response", the output meter under +/// "Output" -- so both drew the heading themselves *and* called `param_section` with the same +/// title, and the shipped screen carried each of those two headings twice, separated only by the +/// element in between. The heading is the caller's to draw whenever anything comes between it and +/// the controls; `param_section` stays the shorthand for when nothing does. +fn param_controls( + ui: &mut egui::Ui, + prefix: &str, + params: &ParamValues, + intents: &mut Vec, +) { for (descriptor, value) in params.iter().filter(|(d, _)| d.key.starts_with(prefix)) { param_control(ui, descriptor, value, intents); } @@ -545,6 +656,290 @@ mod tests { assert_eq!(namir_ui.host.dispatched, dispatched); } + /// One press-and-release of the primary button at `pos`, as one frame's events. + fn click_at(pos: egui::Pos2) -> Vec { + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }, + ] + } + + /// The rect of the first shape whose painted text is exactly `needle`, or `None`. + fn find_text(output: &egui::FullOutput, needle: &str) -> Option { + painted_texts(output) + .into_iter() + .find(|(text, _)| text == needle) + .map(|(_, rect)| rect) + } + + /// A [`NamirUi`] over a [`RecordingHost`] plus its own [`egui::Context`], with a running + /// clock -- the shape every multi-frame interaction test below drives. + struct Driver { + ui: NamirUi, + ctx: egui::Context, + time: f64, + } + + impl Driver { + fn new(snapshot: UiSnapshot) -> Self { + Self { + ui: NamirUi::new(RecordingHost { + snapshot, + dispatched: Vec::new(), + }), + ctx: egui::Context::default(), + time: 0.0, + } + } + + /// One whole frame through `NamirUi::frame` -- snapshot, render, dispatch -- carrying + /// `events`, returning what it painted. + fn frame(&mut self, events: Vec) -> egui::FullOutput { + self.time += 0.1; + let time = self.time; + let ui = &mut self.ui; + self.ctx.run_ui(frame_input(time, events), |u| ui.frame(u)) + } + + /// Where the control painting exactly `needle` ended up, once the layout has settled. + /// + /// **Two idle frames, and the second is the one measured.** A panel is sized from what it + /// measured the frame before, so the first frame after any change paints a partial row: + /// with the preset row, frame 0 paints the name box but neither the "Preset" label nor the + /// buttons, which puts the box at an x it will not keep. A rect taken from that frame + /// sends the click below to where the control *was*, and the interaction lands on nothing. + fn locate(&mut self, needle: &str) -> egui::Rect { + self.frame(Vec::new()); + let output = self.frame(Vec::new()); + find_text(&output, needle).unwrap_or_else(|| { + panic!( + "nothing painting {needle:?} is on screen; painted: {:?}", + painted_texts(&output) + .into_iter() + .map(|(text, _)| text) + .collect::>() + ) + }) + } + + /// Types `text` into whichever text box paints `hint` while empty: locate it, click into + /// it, then send the text. + fn type_into(&mut self, hint: &str, text: &str) { + let rect = self.locate(hint); + self.frame(click_at(rect.center())); + self.frame(vec![egui::Event::Text(text.to_string())]); + } + + /// Clicks whichever control paints `needle`. + fn click_text(&mut self, needle: &str) { + let rect = self.locate(needle); + self.frame(click_at(rect.center())); + } + } + + /// **Issue #100, the save half, driven end to end.** FR-STATE-030 is a Must and `namir-ui` is + /// the only GUI: before this, `UiSnapshot::unsaved_changes` was rendered as "* unsaved + /// changes" and `UiIntent` had no variant that could resolve it, so the screen showed the user + /// a dirty flag and no control that could act on it. + /// + /// Typed into the real box and clicked on the real button, both located by the text `render` + /// painted, so nothing here depends on a layout constant this module would have to expose. + #[test] + fn naming_a_preset_and_pressing_save_dispatches_a_save_intent() { + let mut driver = Driver::new(UiSnapshot { + unsaved_changes: true, + ..UiSnapshot::default() + }); + driver.type_into("Preset name", "Crunch"); + assert!( + driver.ui.host.dispatched.is_empty(), + "typing a name is not yet a save: {:?}", + driver.ui.host.dispatched + ); + + driver.click_text("Save preset"); + assert_eq!( + driver.ui.host.dispatched, + vec![UiIntent::SavePreset { + name: "Crunch".to_string() + }] + ); + } + + /// The dirty flag and the control that resolves it are on screen together -- the exact + /// complaint issue #100 opens with. Asserted on one frame's paint output, so a save control + /// that existed only on some other screen or behind a menu would not satisfy it. + #[test] + fn the_unsaved_changes_flag_is_shown_beside_a_control_that_can_resolve_it() { + let mut driver = Driver::new(UiSnapshot { + unsaved_changes: true, + ..UiSnapshot::default() + }); + driver.frame(Vec::new()); + let output = driver.frame(Vec::new()); + assert!( + find_text(&output, "* unsaved changes").is_some(), + "the dirty flag is shown" + ); + assert!( + find_text(&output, "Save preset").is_some(), + "and a save control is shown on the same screen" + ); + } + + /// An empty name is not a preset: FR-STATE-030 says "a **named** preset", and a host handed an + /// empty name would have to invent a filename or refuse. The control refuses first. + #[test] + fn saving_with_an_empty_name_dispatches_nothing() { + let mut driver = Driver::new(UiSnapshot::default()); + driver.click_text("Save preset"); + assert!( + driver.ui.host.dispatched.is_empty(), + "an unnamed save must not reach the host: {:?}", + driver.ui.host.dispatched + ); + } + + /// FR-UI-030's "operable by keyboard", for the one gesture this row adds: a user whose hands + /// are already in the name box presses Enter rather than reaching for the button. + #[test] + fn pressing_enter_in_the_name_box_saves_under_that_name() { + let mut driver = Driver::new(UiSnapshot::default()); + driver.type_into("Preset name", "Crunch"); + driver.frame(vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }]); + assert_eq!( + driver.ui.host.dispatched, + vec![UiIntent::SavePreset { + name: "Crunch".to_string() + }] + ); + } + + /// A host that has listed no presets gets a control that says so and does nothing, rather than + /// an empty menu or no control at all -- "there is nothing to recall" and "this build cannot + /// recall" must not look the same, the same rule `audio_mode_label` follows for a share mode. + #[test] + fn with_no_presets_listed_the_recall_control_says_so_and_dispatches_nothing() { + let mut driver = Driver::new(UiSnapshot::default()); + driver.click_text("No saved presets"); + assert!( + driver.ui.host.dispatched.is_empty(), + "{:?}", + driver.ui.host.dispatched + ); + } + + /// **Issue #100, the recall half.** The user picks from what the host listed, and the intent + /// carries that entry's own path -- not its name, and not a path this crate built: a host can + /// only ever be asked to recall something it itself put in [`UiSnapshot::presets`]. + /// + /// The second preset is chosen deliberately, so an implementation that always reported the + /// first cannot pass. + #[test] + fn recalling_a_preset_dispatches_that_presets_own_path() { + let mut driver = Driver::new(UiSnapshot { + presets: vec![ + crate::host::PresetSummary { + name: "Clean".to_string(), + path: std::path::PathBuf::from("/presets/clean.namirpreset"), + }, + crate::host::PresetSummary { + name: "Crunch".to_string(), + path: std::path::PathBuf::from("/presets/crunch.namirpreset"), + }, + ], + ..UiSnapshot::default() + }); + + driver.click_text("Recall preset"); + driver.click_text("Crunch"); + + assert_eq!( + driver.ui.host.dispatched, + vec![UiIntent::RecallPreset { + path: std::path::PathBuf::from("/presets/crunch.namirpreset") + }] + ); + } + + /// **Issue #103.** Every section heading `render` paints must be painted once. Two were + /// painted twice: `ui.heading("Impulse Response")` was immediately followed by a + /// `param_section` whose own title was also `"Impulse Response"`, separated on screen only by + /// the IR-name label, and `"Output"` had the identical shape with the output meter between the + /// two copies. The smoke test above only asserts that rendering does not panic, so nothing + /// caught it. + /// + /// Driven at a window tall enough that the central panel's `ScrollArea` has no content below + /// the fold: `egui` culls a widget whose rectangle is not visible, so a heading scrolled out + /// of view is never painted at all, and a duplicate-count assertion at 960x640 would be + /// counting what fits rather than what is drawn. + /// + /// `"Input Trim"` is deliberately **not** on this list and is not a defect: it is painted + /// twice because `trim.gain_db`'s own `ParamDescriptor::name` is also "Input Trim", so the + /// second painting is a control's name, not a repeated heading. + /// + /// `"Output"` had a **third** painting the issue's own diagnosis does not name: the output + /// meter's label. `ui.heading("Output")`, a meter labelled "Output" and a `param_section` + /// titled "Output" put the word on three consecutive rows. Removing the section title alone + /// left two, and this test is what said so -- which is why `"Input"` (the input meter's label, + /// with no heading above it) is on the list too, as the shape the output side now matches. + #[test] + fn each_section_heading_is_painted_exactly_once() { + const TALL: egui::Rect = + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1200.0, 2400.0)); + let mut view = ViewState::default(); + let snapshot = UiSnapshot::default(); + let mut intents = Vec::new(); + + // Two frames: a panel is sized from what it measured the frame before. + let ctx = egui::Context::default(); + let input = || egui::RawInput { + screen_rect: Some(TALL), + ..Default::default() + }; + let _ = ctx.run_ui(input(), |ui| { + render(ui, &mut view, &snapshot, &mut intents); + }); + let output = ctx.run_ui(input(), |ui| { + render(ui, &mut view, &snapshot, &mut intents); + }); + let painted = painted_texts(&output); + + for heading in [ + "Library", + "Input", + "Gate", + "Model", + "NAM", + "Impulse Response", + "EQ", + "Output", + ] { + let count = painted.iter().filter(|(text, _)| text == heading).count(); + assert_eq!( + count, 1, + "the heading {heading:?} was painted {count} times, not once" + ); + } + } + /// **Issue #42's other axis, at the layer that owns the container.** The horizontal half of /// that issue — a long notice pushing `Dismiss` past the right edge — is fixed and asserted in /// `notices`' own tests. The vertical half is a property of *this* module, because it is here diff --git a/crates/namir-ui/src/host.rs b/crates/namir-ui/src/host.rs index 958aed8..0bc94e3 100644 --- a/crates/namir-ui/src/host.rs +++ b/crates/namir-ui/src/host.rs @@ -122,6 +122,22 @@ pub struct AudioModeStatus { pub device_name: String, } +/// One preset the host knows the user can recall, for the recall control to list (FR-STATE-030). +/// +/// A name and a path, and nothing else: this crate cannot open a file, does not know where a +/// preset directory lives (that is `namir-platform`, which D-5.1 puts out of reach), and has no +/// business parsing a `namir_state::State` it would only render one field of. The host enumerates +/// whatever it considers recallable -- a preset directory, a factory set, a most-recently-used +/// list -- and this crate draws the names it is given. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresetSummary { + /// What the user sees and picks by. Not required to be unique; the `path` is the identity. + pub name: String, + /// What [`crate::UiIntent::RecallPreset`] names when this entry is chosen. Opaque here -- + /// this crate never reads it, only hands it back. + pub path: PathBuf, +} + /// Everything [`crate::render`] needs to draw one frame of FR-UI-020's screen -- a single, /// self-contained, read-only picture of engine/library/preset state at one instant. Built fresh by /// [`UiHost::snapshot`] every frame; this crate never retains one past the frame it was rendered @@ -154,6 +170,10 @@ pub struct UiSnapshot { pub unsaved_changes: bool, /// FR-UI-070's non-modal notices currently shown, oldest first. pub notices: Vec, + /// FR-STATE-030's recallable presets, in whatever order the host wants them listed. Empty + /// when the host knows of none (or has not looked yet), in which case the recall control + /// renders disabled rather than vanishing -- see [`crate::render`]. + pub presets: Vec, } impl Default for UiSnapshot { @@ -172,6 +192,7 @@ impl Default for UiSnapshot { audio_mode: None, unsaved_changes: false, notices: Vec::new(), + presets: Vec::new(), } } } @@ -196,8 +217,6 @@ pub enum UiIntent { /// The parameter's stable string key. key: &'static str, }, - /// The library search box's text changed to this value. - LibraryQueryChanged(String), /// The user asked to load the library entry at this path (FR-UI-050-adjacent: this is /// `namir-library`'s FR-LIB-060 "select" gesture, wired to a double-click in /// [`crate::library_view`]). @@ -211,6 +230,33 @@ pub enum UiIntent { /// The dismissed [`UiNotice`]'s `id`. id: u64, }, + /// FR-STATE-030's save half: write the current state as a preset under this name. + /// + /// **A name, not a path**, and that is the whole of the layering argument. FR-STATE-030 says + /// "save the current state as a *named* preset"; where a named preset lives is a + /// `namir-platform` question, and D-5.1 puts `namir-platform` out of this crate's reach. The + /// host resolves the name to a `.namirpreset` path under whatever directory it considers the + /// user's, which is also what lets the standalone and the plugin agree on where a preset goes + /// without this crate knowing either answer. + /// + /// Already trimmed of surrounding whitespace and never empty -- the control that emits this + /// is disabled until the box holds a name. Nothing else about the string is checked here: a + /// name that is illegal as a filename, or one that would overwrite an existing preset, is the + /// host's to reject (and to report through a [`UiNotice`]), since only the host knows the + /// filesystem it is about to write to. + SavePreset { + /// The preset's name, as typed. + name: String, + }, + /// FR-STATE-030's recall half: load the preset at this path onto the running instance. + /// + /// The path is one this crate was handed in [`UiSnapshot::presets`] and never constructed -- + /// so a host can only ever be asked to recall something it itself listed, and this crate + /// stays unable to name a file of its own. + RecallPreset { + /// The chosen [`PresetSummary`]'s `path`, verbatim. + path: PathBuf, + }, } /// D-5.1's seam: implemented by whatever crate owns the real engine/worker/library underneath this diff --git a/crates/namir-ui/src/lib.rs b/crates/namir-ui/src/lib.rs index 73eea6b..654e132 100644 --- a/crates/namir-ui/src/lib.rs +++ b/crates/namir-ui/src/lib.rs @@ -17,7 +17,8 @@ //! library index, scan progress, pending error notices) and it renders exactly that -- nothing it //! draws can be more than one frame stale, and nothing it draws has a side effect on its own. The //! only way this crate ever asks for something to change is by producing a [`UiIntent`] ("set this -//! parameter to X", "load this library entry", "start/cancel a scan", "dismiss this notice"), +//! parameter to X", "load this library entry", "start/cancel a scan", "dismiss this notice", +//! "save the current state under this name", "recall the preset at this path"), //! which is handed to the [`UiHost`] trait the caller implements. `namir-app` and `namir-clap` are //! this milestone's two implementors: each turns a `UiIntent` into a real call against its own //! `Chain`/`namir-worker`/`namir-library::Index`, and each turns its own state into a fresh @@ -53,6 +54,13 @@ //! 10,000-file corpus in that module's own test, not a guessed row count. //! - FR-UI-070 -- [`notices::render`]'s non-modal, individually-dismissible notice lines. //! +//! Contributed to but **not** closed here (issue #100): FR-STATE-030's save and recall gestures. +//! `app::preset_controls` renders the two controls and emits [`UiIntent::SavePreset`] / +//! [`UiIntent::RecallPreset`]; the requirement's `*Verify:*` code is `I` and its subject is +//! "interchangeable between the standalone application and the CLAP plugin", so what closes it is +//! an integration test across both shells' `UiHost` implementations, not anything in this crate. +//! Its `trace-partial:` therefore stays where it is, on `namir-worker`'s recall test. +//! //! Out of scope, deliberately: //! - **Actually driving a `Chain`, a `namir-worker` instance, or a `namir-library` scan** -- that //! is precisely what [`UiHost`] exists to hand off, to `namir-app`/`namir-clap`. @@ -73,8 +81,8 @@ mod notices; pub use app::{NamirUi, ViewState, open_blocking, open_parented, render}; pub use host::{ - AudioModeStatus, AudioShareMode, LibrarySnapshot, MeterReading, UiHost, UiIntent, UiNotice, - UiSnapshot, + AudioModeStatus, AudioShareMode, LibrarySnapshot, MeterReading, PresetSummary, UiHost, + UiIntent, UiNotice, UiSnapshot, }; pub use library_view::{LibraryViewState, entry_label}; // The list-side half of FR-UI-070, shared by both shells rather than copied into each -- see diff --git a/crates/namir-ui/src/library_view.rs b/crates/namir-ui/src/library_view.rs index 720a3b7..11aabbf 100644 --- a/crates/namir-ui/src/library_view.rs +++ b/crates/namir-ui/src/library_view.rs @@ -125,12 +125,14 @@ pub fn render( let search_label = ui .add(egui::Label::new("Search").sense(egui::Sense::hover())) .on_hover_text("Filters by file name and, for NAM models, author/gear/description."); - let search = ui + // No intent is emitted when this changes, deliberately (issue #104). The query is view state: + // it lives in `LibraryViewState`, `ensure_filtered` runs `namir_library::filter` against the + // snapshot's own index, and no `UiSnapshot` field can feed a query back the other way -- so a + // per-keystroke `String` clone across the seam bought a host nothing, and both shells + // explicitly did nothing with it. + let _search = ui .add(TextEdit::singleline(&mut state.query_text).hint_text("Search name, author, gear...")) .labelled_by(search_label.id); - if search.changed() { - intents.push(UiIntent::LibraryQueryChanged(state.query_text.clone())); - } state.ensure_filtered(&snapshot.index); @@ -282,6 +284,103 @@ mod tests { assert_eq!(state.filtered_count(), 2); } + /// **Issue #104, driven through the real text box.** Typing in the search field must not + /// dispatch anything: the query lives in [`LibraryViewState`] and [`filter`] runs against the + /// snapshot's own index, so the whole gesture is view-local. `UiIntent::LibraryQueryChanged` + /// carried a fresh `String` clone per keystroke to two hosts that both explicitly did nothing + /// with it, and no `UiSnapshot` field could ever feed a query back the other way. + /// + /// Both halves are asserted, because "emits nothing" is only correct if the typing still + /// *worked*: the intent list stays empty **and** the list narrows to the matching entry. + #[test] + fn typing_in_the_search_box_filters_locally_and_dispatches_nothing() { + const WINDOW: egui::Rect = + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(400.0, 600.0)); + let mut index = Index::empty(); + index.upsert(entry("a.nam", "Alpha")); + index.upsert(entry("b.nam", "Beta")); + let snapshot = LibrarySnapshot { + index: Arc::new(index), + scan: None, + }; + let mut state = LibraryViewState::default(); + let ctx = egui::Context::default(); + + let frame = |events: Vec| egui::RawInput { + screen_rect: Some(WINDOW), + events, + ..Default::default() + }; + + // Frame 0: find the search box by the hint text `render` really painted into it. + let mut intents = Vec::new(); + let output = ctx.run_ui(frame(Vec::new()), |ui| { + render(ui, &mut state, &snapshot, &mut intents); + }); + let hint = text_rect(&output, "Search name, author, gear...") + .expect("the search box paints its hint text while empty"); + + // Frame 1: click into it. A click alone changes no text, so nothing may be emitted yet. + let pos = hint.center(); + let mut intents = Vec::new(); + let _ = ctx.run_ui( + frame(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }, + ]), + |ui| render(ui, &mut state, &snapshot, &mut intents), + ); + assert!(intents.is_empty(), "a click into the box: {intents:?}"); + + // Frame 2: type. This is the frame the box's own `changed()` fires on. + let mut intents = Vec::new(); + let _ = ctx.run_ui(frame(vec![egui::Event::Text("Alpha".to_string())]), |ui| { + render(ui, &mut state, &snapshot, &mut intents) + }); + assert_eq!( + state.query_text, "Alpha", + "the keystrokes must still reach the view's own query" + ); + assert_eq!( + state.filtered_paths, + vec![PathBuf::from("a.nam")], + "and must still filter the list locally" + ); + assert!( + intents.is_empty(), + "typing is view-local and must dispatch nothing, got {intents:?}" + ); + } + + /// The rect of the one shape whose painted text is exactly `needle`, or `None`. + fn text_rect(output: &egui::FullOutput, needle: &str) -> Option { + fn walk(shape: &egui::Shape, needle: &str, out: &mut Vec) { + match shape { + egui::Shape::Text(text) if text.galley.text() == needle => { + out.push(text.visual_bounding_rect()); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, needle, out)), + _ => {} + } + } + let mut rects = Vec::new(); + for clipped in &output.shapes { + walk(&clipped.shape, needle, &mut rects); + } + rects.first().copied() + } + /// FR-UI-060's cheap regression guard, exercised against a real 10,000-file corpus /// (`namir_fixtures::library`, the same generator `namir-library`'s own NFR-PERF-060 benchmark /// uses) rather than a guess at what 10,000 rows looks like. Builds a real diff --git a/crates/namir-ui/src/meter.rs b/crates/namir-ui/src/meter.rs index b25dfde..a0f265c 100644 --- a/crates/namir-ui/src/meter.rs +++ b/crates/namir-ui/src/meter.rs @@ -26,18 +26,49 @@ pub fn normalize_db(db: f32) -> f32 { ((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) } +/// One meter reading as the text printed beside the bar. +/// +/// Above [`METER_FLOOR_DB`] this is the figure, to one decimal, in dBFS. **At or below the floor +/// it is stated as a bound, `<= -60.0 dBFS` (with a real U+2264), and not as a number** +/// (issue #105). +/// +/// Two reasons, one of which is the reported defect and one of which is the more general case: +/// +/// - [`MeterReading::SILENT`] is `f32::NEG_INFINITY` on both readings, and `{:.1}` renders that as +/// the literal `-inf dBFS`. That is correct arithmetic -- the dB value of an amplitude of +/// exactly zero is unbounded below -- and it is the label the screen carries before any audio +/// has arrived at all, so `-inf` is the *first* thing a user ever reads off this interface. No +/// physical meter shows it; it reads as a fault, or as a unit nobody recognises, rather than as +/// "silence". +/// - Below the floor the bar beside the text is already pinned empty by [`normalize_db`], so any +/// figure printed there claims a precision the meter is not displaying. Bounding the text at the +/// same number the bar bottoms out at keeps the two halves of one widget telling one story. +/// +/// The floor is spelled out rather than replaced by a dash or by the word "silent": the reader +/// keeps the unit and the scale, so the label above the floor and the label below it stay visibly +/// the same kind of value. A NaN reading -- already a host-side bug -- degrades to the same bound +/// [`normalize_db`] already degrades it to, rather than painting `NaN dBFS`. +pub fn format_db(db: f32) -> String { + if db.is_nan() || db <= METER_FLOOR_DB { + format!("\u{2264} {METER_FLOOR_DB:.1} dBFS") + } else { + format!("{db:.1} dBFS") + } +} + /// Renders one labelled meter bar for `reading`. pub fn render(ui: &mut Ui, label: &str, reading: MeterReading) { ui.horizontal(|ui| { ui.label(label); ui.add( ProgressBar::new(normalize_db(reading.peak_db)) - .text(format!("{:.1} dBFS", reading.peak_db)) + .text(format_db(reading.peak_db)) .desired_width(180.0), ) .on_hover_text(format!( - "Peak {:.1} dBFS, RMS {:.1} dBFS", - reading.peak_db, reading.rms_db + "Peak {}, RMS {}", + format_db(reading.peak_db), + format_db(reading.rms_db) )); }); } @@ -46,6 +77,73 @@ pub fn render(ui: &mut Ui, label: &str, reading: MeterReading) { mod tests { use super::*; + /// Every text `render` painted this frame at `window`, read off the shapes it actually + /// produced -- the same technique `app`'s and `notices`' tests use, for the same reason: what + /// a user reads is what was painted, not what a second copy of the formatting logic would say. + fn painted_texts(reading: MeterReading, window: egui::Rect) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.galley.text().to_string()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + let output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(window), + ..Default::default() + }, + |ui| render(ui, "Input", reading), + ); + let mut texts = Vec::new(); + for clipped in &output.shapes { + walk(&clipped.shape, &mut texts); + } + texts + } + + /// **Issue #105, through the real widget.** `MeterReading::SILENT` is `f32::NEG_INFINITY` on + /// both readings, and `{:.1}` renders that as the literal `-inf` -- which is the label the + /// screen carries before any audio has arrived at all, i.e. the first thing a user ever reads + /// off this interface. Asserted on what `render` painted rather than on [`format_db`] alone, + /// because the defect was in the bar's own `.text(..)`, not in a helper. + #[test] + fn a_silent_meter_never_paints_the_word_inf() { + let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 100.0)); + let texts = painted_texts(MeterReading::SILENT, window); + for text in &texts { + assert!( + !text.contains("inf") && !text.contains("NaN"), + "a silent meter painted {text:?}" + ); + } + assert!( + texts.iter().any(|t| t == &format_db(f32::NEG_INFINITY)), + "a silent meter must still state its floor, painted: {texts:?}" + ); + } + + /// The floor reads as a bound, not as a measurement: at or below [`METER_FLOOR_DB`] the bar is + /// already pinned empty (`normalize_db`), so a figure beside it would claim a precision the + /// meter is not showing. + #[test] + fn at_or_below_the_floor_the_reading_is_stated_as_a_bound() { + let bound = format_db(METER_FLOOR_DB); + assert_eq!(bound, "\u{2264} -60.0 dBFS"); + assert_eq!(format_db(f32::NEG_INFINITY), bound); + assert_eq!(format_db(METER_FLOOR_DB - 12.0), bound); + assert_eq!(format_db(f32::NAN), bound, "matches normalize_db's NaN arm"); + } + + /// Above the floor nothing changes: a real reading is still a number, to one decimal, in dBFS. + #[test] + fn above_the_floor_a_real_reading_is_unchanged() { + assert_eq!(format_db(-12.34), "-12.3 dBFS"); + assert_eq!(format_db(0.0), "0.0 dBFS"); + assert_eq!(format_db(3.0), "3.0 dBFS"); + } + #[test] fn silence_normalizes_to_zero() { assert_eq!(normalize_db(f32::NEG_INFINITY), 0.0); From 7378f71302d5e1b1e784e18bbd0e1ec2f6d78aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:15:19 +0000 Subject: [PATCH 24/44] Hoist the preset location rule into namir-platform Both shells were carrying a byte-for-byte identical presets.rs by deliberate agreement, each with a banner saying it belonged here. That is the shape of the bug AGENTS.md already records: namir-app and namir-clap each computing the library's default location independently is what let their wiring drift, and a scan against zero roots then erased the shared index. FR-STATE-030's "interchangeable between the two products" fails at discovery, not at the format, if the two look in different directories. D-13.2 puts filesystem locations here and nowhere else, so preset_dir, preset_dir_under, preset_path, sanitise_name and the listing live here now. The split is at the layering boundary, not an arbitrary one: list_preset_files returns (name, path) pairs because D-5.1 lets this crate depend on namir-core alone, so it cannot build namir-ui's PresetSummary. Each shell maps the pairs, which is the one line of the rule that is legitimately per-shell. preset_dir_under keeps the app's shape rather than the plugin's, so startup_probe's config-dir override still applies to a benchmark launch -- a probed run never opens a window, and taking the directory as a parameter keeps that true by construction rather than by argument. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-app/src/presets.rs | 207 +++------------------------ crates/namir-clap/src/presets.rs | 188 +++--------------------- crates/namir-platform/src/lib.rs | 1 + crates/namir-platform/src/presets.rs | 176 +++++++++++++++++++++++ 4 files changed, 209 insertions(+), 363 deletions(-) create mode 100644 crates/namir-platform/src/presets.rs diff --git a/crates/namir-app/src/presets.rs b/crates/namir-app/src/presets.rs index 6c98fdf..c8138f4 100644 --- a/crates/namir-app/src/presets.rs +++ b/crates/namir-app/src/presets.rs @@ -1,202 +1,27 @@ -//! FR-STATE-030's named-preset half, for the standalone application: where a `.namirpreset` lives, -//! how the set of them is listed for [`namir_ui::UiSnapshot::presets`], and the naming rule the two -//! file operations [`namir_ui::UiIntent::SavePreset`]/`RecallPreset` are held to. +//! FR-STATE-030's named-preset half for the standalone application: the one line of the preset +//! rule that is legitimately per-shell. //! -//! # ⚠ This resolution belongs in `namir-platform`, not here ⚠ -//! -//! FR-STATE-030's presets are "interchangeable between the two products", and interchangeability -//! fails at the *discovery* step — not at the format — if the two shells look in two different -//! directories. `namir-worker`'s [`namir_worker::library::LibraryService::open_default`] is this -//! workspace's own precedent and its own written warning: `namir-app` and `namir-clap` each -//! computing the library's default location independently is what let their library wiring drift -//! apart once already, and the fix was to make one function the only way either shell can ask. -//! -//! The same fix is owed here, and this module is **not** it: D-13.2 puts filesystem locations in -//! `namir-platform` ("Filesystem locations, config directories, log sinks … live in -//! `namir-platform` and nowhere else"), so [`preset_dir_under`] below should be a `preset_dir()` -//! beside `namir_platform::config_dir()`/`log_file_path()`, with `namir-clap` calling the same -//! function. It is here only because this change could not touch another crate. -//! -//! **`crates/namir-clap/src/presets.rs` is the other copy, and the two agree by construction of -//! this file**: the directory name (`Presets`, chosen to match `LibraryService::open_at`'s own -//! `/Library`), the extension, [`sanitise_name`]'s rejection set and -//! [`list_presets`]'s "regular files only, named by stem, sorted, unreadable directory is an empty -//! list" semantics are the same rule written twice. Hoisting it is a two-caller deletion; changing -//! either copy alone silently breaks FR-STATE-030's interchangeability claim at discovery. -//! -//! # Naming -//! -//! [`namir_ui::UiIntent::SavePreset`] carries "a name, not a path", already trimmed and non-empty, -//! and says in as many words that a name illegal as a filename is *the host's* to reject. This -//! module is that host: [`sanitise_name`] refuses anything that could escape the preset directory -//! or name something other than a plain file in it, and [`crate::host::AppHost`] reports the -//! refusal as an FR-UI-070 notice rather than writing somewhere the user did not ask for. +//! Where a preset lives, what it is called and which names are legal are +//! [`namir_platform::presets`]'s, so that the two products agree by construction rather than by +//! two copies happening to match — FR-STATE-030's "interchangeable between the two products" +//! fails at *discovery* if they do not, which is the same failure `LibraryService::open_default` +//! exists to prevent. What stays here is the mapping into [`namir_ui::PresetSummary`], which +//! `namir-platform` cannot build: D-5.1 lets it depend on `namir-core` and nothing else. -use std::path::{Path, PathBuf}; +use std::path::Path; use namir_ui::PresetSummary; -/// The extension `docs/04-state-and-preset-format.md` gives the preset document. -pub const PRESET_EXTENSION: &str = "namirpreset"; - -/// The subdirectory of the per-user configuration directory both products must agree on. -pub const PRESET_DIR_NAME: &str = "Presets"; - -/// The preset directory under an already-resolved configuration directory. -/// -/// Takes the configuration directory rather than resolving one, because this crate has two: -/// [`namir_platform::config_dir`], and [`crate::startup_probe`]'s override of it, which points a -/// NFR-PERF-030 measurement run at a directory the harness owns. A probed launch never opens a -/// window and so never lists or writes a preset, but taking the directory as a parameter is what -/// keeps that true by construction rather than by argument. -#[must_use] -pub fn preset_dir_under(config_dir: &Path) -> PathBuf { - config_dir.join(PRESET_DIR_NAME) -} +pub use namir_platform::presets::{preset_dir_under, preset_path}; -/// The file a preset called `name` is stored in, or `None` if `name` is not one this shell will -/// write — see [`sanitise_name`]. -#[must_use] -pub fn preset_path(dir: &Path, name: &str) -> Option { - Some(dir.join(format!("{}.{PRESET_EXTENSION}", sanitise_name(name)?))) -} - -/// The name, if it is one that can only ever name a plain file directly inside the preset -/// directory. +/// Every `.namirpreset` in `dir` as the interface's own summary, named by stem and sorted. /// -/// Rejected: anything empty once trimmed, anything containing a path separator of either platform -/// (so a name can never reach a sibling directory), anything that is `.` or `..`, anything with a -/// Windows drive prefix, and anything containing a character Windows refuses in a filename. The -/// last is checked on every platform on purpose: a preset saved on Linux under a name Windows -/// cannot represent would be a preset the other half of FR-STATE-030's interchangeability claim -/// cannot open. -#[must_use] -pub fn sanitise_name(name: &str) -> Option<&str> { - let name = name.trim(); - if name.is_empty() || name == "." || name == ".." { - return None; - } - if name.chars().any(|c| { - matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() - }) { - return None; - } - Some(name) -} - -/// Every `.namirpreset` directly inside `dir`, named by its file stem, sorted by name. -/// -/// Non-recursive, and a directory that does not exist (or cannot be read) is an empty list rather -/// than an error: "no presets saved yet" is the ordinary first-run state, and there is nothing for -/// a user to act on in being told about it. The empty list is what the UI renders as a disabled -/// recall control, which is what [`namir_ui::UiSnapshot::presets`] documents for "the host knows -/// of none". -/// -/// **Blocking:** this reads a directory, so it runs on [`crate::worker`]'s thread, never inside +/// **Blocking:** reads a directory, so it runs on [`crate::worker`]'s thread, never inside /// [`namir_ui::UiHost::snapshot`]. #[must_use] pub fn list_presets(dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { - return Vec::new(); - }; - let mut presets: Vec = entries - .flatten() - .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) - .map(|entry| entry.path()) - .filter(|path| { - path.extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case(PRESET_EXTENSION)) - }) - .filter_map(|path| { - let name = path.file_stem()?.to_string_lossy().into_owned(); - Some(PresetSummary { name, path }) - }) - .collect(); - // A deterministic order, so the list does not reshuffle between frames on a filesystem whose - // `read_dir` order is not stable. - presets.sort_by(|a, b| a.name.cmp(&b.name)); - presets -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "namir-app-presets-test-{name}-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - /// FR-STATE-030's "interchangeable between the two products" begins at discovery: this shell - /// and `namir-clap` must name the same directory under the same configuration root. Pinned on - /// the two constants that encode it, since the other copy is in a crate this test cannot see. - #[test] - fn the_preset_directory_sits_beside_the_library_under_the_shared_config_directory() { - assert_eq!(PRESET_DIR_NAME, "Presets"); - assert_eq!(PRESET_EXTENSION, "namirpreset"); - let config = Path::new("/somewhere/config"); - assert_eq!( - preset_dir_under(config), - config.join("Presets"), - "both shells must resolve presets under the one config directory they share, the way \ - LibraryService::open_at resolves /Library" - ); - } - - #[test] - fn a_name_that_could_escape_the_preset_directory_is_refused() { - for hostile in [ - "../evil", - "..\\evil", - "sub/dir", - "sub\\dir", - "C:evil", - "..", - ".", - " ", - "bad\u{0}name", - ] { - assert!( - sanitise_name(hostile).is_none(), - "{hostile:?} must not be accepted as a preset name" - ); - assert!(preset_path(Path::new("/presets"), hostile).is_none()); - } - assert_eq!(sanitise_name(" Crunch Rhythm "), Some("Crunch Rhythm")); - assert_eq!( - preset_path(Path::new("/presets"), " Crunch Rhythm "), - Some(PathBuf::from("/presets").join("Crunch Rhythm.namirpreset")) - ); - } - - #[test] - fn listing_finds_only_preset_files_and_names_them_by_stem() { - let dir = temp_dir("listing"); - std::fs::write(dir.join("Clean.namirpreset"), b"{}").unwrap(); - std::fs::write(dir.join("Lead.NAMIRPRESET"), b"{}").unwrap(); - std::fs::write(dir.join("notes.txt"), b"x").unwrap(); - std::fs::create_dir_all(dir.join("Nested.namirpreset")).unwrap(); - - let presets = list_presets(&dir); - let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); - assert_eq!( - names, - vec!["Clean", "Lead"], - "only regular .namirpreset files, named by stem, sorted" - ); - assert_eq!(presets[0].path, dir.join("Clean.namirpreset")); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_directory_that_does_not_exist_lists_nothing_rather_than_failing() { - let dir = temp_dir("absent").join("never-created"); - assert!(list_presets(&dir).is_empty()); - } + namir_platform::presets::list_preset_files(dir) + .into_iter() + .map(|(name, path)| PresetSummary { name, path }) + .collect() } diff --git a/crates/namir-clap/src/presets.rs b/crates/namir-clap/src/presets.rs index f0c32d0..b752cc1 100644 --- a/crates/namir-clap/src/presets.rs +++ b/crates/namir-clap/src/presets.rs @@ -1,181 +1,25 @@ -//! FR-STATE-030's named-preset half, for the plugin: where a `.namirpreset` lives, how the set of -//! them is listed for [`namir_ui::UiSnapshot::presets`], and the two file operations -//! [`namir_ui::UiIntent::SavePreset`]/[`RecallPreset`](namir_ui::UiIntent::RecallPreset) name. +//! FR-STATE-030's named-preset half for the plugin: the one line of the preset rule that is +//! legitimately per-shell. //! -//! # ⚠ This resolution belongs in `namir-platform`, not here ⚠ -//! -//! FR-STATE-030's presets are "interchangeable between the two products", and interchangeability -//! fails at the *discovery* step — not at the format — if the two shells look in two different -//! directories. `namir-worker`'s [`namir_worker::library::LibraryService::open_default`] is this -//! workspace's own precedent and its own written warning: `namir-clap` and `namir-app` each -//! computing the library's default location independently is what let their library wiring drift -//! apart once already (see `crate::shared`'s module doc comment), and the fix was to make one -//! function the only way either shell can ask. -//! -//! The same fix is owed here, and this module is **not** it: D-13.2 puts filesystem locations in -//! `namir-platform` ("Filesystem locations, config directories, log sinks … live in -//! `namir-platform` and nowhere else"), so [`preset_dir`] below should be a `preset_dir()` beside -//! `namir_platform::config_dir()`/`log_file_path()`, with `namir-app` calling the same function. -//! It is here only because this change could not touch another crate; the constant it encodes — -//! `/Presets`, chosen to match `LibraryService::open_at`'s own `/Library` -//! — is the thing to hoist, unchanged, so nothing moves under a user who already saved a preset. -//! -//! # Naming -//! -//! `namir_ui::UiIntent::SavePreset` carries "a name, not a path", already trimmed and non-empty, -//! and says in as many words that a name illegal as a filename is *the host's* to reject. This -//! module is that host: [`sanitise_name`] refuses anything that could escape the preset directory -//! or name something other than a plain file in it, and the caller reports the refusal as an -//! FR-UI-070 notice rather than writing somewhere the user did not ask for. +//! Where a preset lives, what it is called and which names are legal are +//! [`namir_platform::presets`]'s, so that this plugin and `namir-app` agree by construction rather +//! than by two copies happening to match — FR-STATE-030's "interchangeable between the two +//! products" fails at *discovery* if they do not. What stays here is the mapping into +//! [`namir_ui::PresetSummary`], which `namir-platform` cannot build: D-5.1 lets it depend on +//! `namir-core` and nothing else. -use std::path::{Path, PathBuf}; +use std::path::Path; use namir_ui::PresetSummary; -/// The extension `docs/04-state-and-preset-format.md` gives the preset document. -pub(crate) const PRESET_EXTENSION: &str = "namirpreset"; +pub(crate) use namir_platform::presets::{preset_dir, preset_path}; -/// The directory both products must agree on. `None` under exactly the conditions -/// [`namir_platform::config_dir`] returns `None` for — an environment with no per-user -/// configuration convention this workspace claims to know. +/// Every `.namirpreset` in `dir` as the interface's own summary, named by stem and sorted. /// -/// **See this module's doc comment**: this function's body is what belongs in `namir-platform`. -pub(crate) fn preset_dir() -> Option { - namir_platform::config_dir().map(|dir| dir.join("Presets")) -} - -/// The file a preset called `name` is stored in, or `None` if `name` is not one this shell will -/// write — see [`sanitise_name`]. -pub(crate) fn preset_path(dir: &Path, name: &str) -> Option { - Some(dir.join(format!("{}.{PRESET_EXTENSION}", sanitise_name(name)?))) -} - -/// The name, if it is one that can only ever name a plain file directly inside the preset -/// directory. -/// -/// Rejected: anything empty once trimmed, anything containing a path separator of either platform -/// (so a name can never reach a sibling directory), anything that is `.` or `..`, anything with a -/// Windows drive prefix, and anything containing a character Windows refuses in a filename. The -/// last is checked on every platform on purpose: a preset saved on Linux under a name Windows -/// cannot represent would be a preset the other half of FR-STATE-030's interchangeability claim -/// cannot open. -pub(crate) fn sanitise_name(name: &str) -> Option<&str> { - let name = name.trim(); - if name.is_empty() || name == "." || name == ".." { - return None; - } - if name.chars().any(|c| { - matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() - }) { - return None; - } - Some(name) -} - -/// Every `.namirpreset` directly inside `dir`, named by its file stem, sorted by name. -/// -/// Non-recursive, and a directory that does not exist (or cannot be read) is an empty list rather -/// than an error: "no presets saved yet" is the ordinary first-run state, and there is nothing for -/// a user to act on in being told about it. A real read failure is still visible — the caller logs -/// nothing here, but the empty list is what the UI renders as a disabled recall control, which is -/// what `namir_ui::UiSnapshot::presets` documents for "the host knows of none". +/// **Blocking:** reads a directory, so it runs on the worker pool, never inside a GUI frame. pub(crate) fn list_presets(dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { - return Vec::new(); - }; - let mut presets: Vec = entries - .flatten() - .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) - .map(|entry| entry.path()) - .filter(|path| { - path.extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case(PRESET_EXTENSION)) - }) - .filter_map(|path| { - let name = path.file_stem()?.to_string_lossy().into_owned(); - Some(PresetSummary { name, path }) - }) - .collect(); - // A deterministic order, so the list does not reshuffle between frames on a filesystem whose - // `read_dir` order is not stable. - presets.sort_by(|a, b| a.name.cmp(&b.name)); - presets -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "namir-clap-presets-test-{name}-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn the_preset_directory_sits_beside_the_library_under_the_shared_config_directory() { - // Skipped rather than failed where there is no per-user config directory at all -- the - // same degradation `config_dir` itself documents. - let Some(config) = namir_platform::config_dir() else { - return; - }; - let dir = preset_dir().expect("a preset dir exists wherever a config dir does"); - assert_eq!( - dir.parent(), - Some(config.as_path()), - "both shells must resolve presets under the one config directory they share, the way \ - LibraryService::open_at resolves /Library" - ); - } - - #[test] - fn a_name_that_could_escape_the_preset_directory_is_refused() { - for hostile in [ - "../evil", - "..\\evil", - "sub/dir", - "sub\\dir", - "C:evil", - "..", - ".", - " ", - "bad\u{0}name", - ] { - assert!( - sanitise_name(hostile).is_none(), - "{hostile:?} must not be accepted as a preset name" - ); - } - assert_eq!(sanitise_name(" Crunch Rhythm "), Some("Crunch Rhythm")); - } - - #[test] - fn listing_finds_only_preset_files_and_names_them_by_stem() { - let dir = temp_dir("listing"); - std::fs::write(dir.join("Clean.namirpreset"), b"{}").unwrap(); - std::fs::write(dir.join("Lead.NAMIRPRESET"), b"{}").unwrap(); - std::fs::write(dir.join("notes.txt"), b"x").unwrap(); - std::fs::create_dir_all(dir.join("Nested.namirpreset")).unwrap(); - - let presets = list_presets(&dir); - let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); - assert_eq!( - names, - vec!["Clean", "Lead"], - "only regular .namirpreset files, named by stem, sorted" - ); - assert_eq!(presets[0].path, dir.join("Clean.namirpreset")); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_directory_that_does_not_exist_lists_nothing_rather_than_failing() { - let dir = temp_dir("absent").join("never-created"); - assert!(list_presets(&dir).is_empty()); - } + namir_platform::presets::list_preset_files(dir) + .into_iter() + .map(|(name, path)| PresetSummary { name, path }) + .collect() } diff --git a/crates/namir-platform/src/lib.rs b/crates/namir-platform/src/lib.rs index 23dd905..451e0ec 100644 --- a/crates/namir-platform/src/lib.rs +++ b/crates/namir-platform/src/lib.rs @@ -37,6 +37,7 @@ mod denormal; pub mod error_codes; pub mod logging; mod paths; +pub mod presets; mod thread_priority; pub use clap_paths::{ClapInstallScope, clap_install_dir}; diff --git a/crates/namir-platform/src/presets.rs b/crates/namir-platform/src/presets.rs new file mode 100644 index 0000000..b2ad875 --- /dev/null +++ b/crates/namir-platform/src/presets.rs @@ -0,0 +1,176 @@ +//! Where a `.namirpreset` lives, and the naming rule both products are held to. +//! +//! FR-STATE-030's presets are "interchangeable between the two products", and interchangeability +//! fails at the **discovery** step — not at the format — if the two shells look in two different +//! directories. `namir-worker`'s `LibraryService::open_default` is this workspace's own precedent +//! and its own written warning: `namir-app` and `namir-clap` each computing the library's default +//! location independently is what let their library wiring drift apart once already, and the fix +//! was to make one function the only way either shell can ask. This module is that function for +//! presets, and D-13.2 is why it lives here: "filesystem locations, config directories, log sinks +//! … live in `namir-platform` and nowhere else". +//! +//! # What is here and what is not +//! +//! [`list_preset_files`] returns `(name, path)` pairs rather than the `PresetSummary` the interface +//! renders. D-5.1 lets this crate depend on `namir-core` and nothing else, so the shape the UI +//! seam names cannot be built here; each shell maps the pairs into it, which is the one line of +//! this rule that is legitimately per-shell. +//! +//! # Naming +//! +//! `UiIntent::SavePreset` carries "a name, not a path", already trimmed and non-empty, and says +//! that a name illegal as a filename is *the host's* to reject. [`sanitise_name`] is that rule, +//! shared so that a name one product accepts is never one the other refuses. + +use std::path::{Path, PathBuf}; + +/// The extension `docs/04-state-and-preset-format.md` gives the preset document. +pub const PRESET_EXTENSION: &str = "namirpreset"; + +/// The subdirectory of the per-user configuration directory both products must agree on. +/// +/// `Presets`, matching `LibraryService::open_at`'s own `/Library`. +pub const PRESET_DIR_NAME: &str = "Presets"; + +/// The preset directory under an already-resolved configuration directory. +/// +/// Takes the configuration directory rather than resolving one, because `namir-app` has two: +/// [`crate::config_dir`], and its `startup_probe` override, which points an NFR-PERF-030 +/// measurement run at a directory the harness owns. A probed launch never opens a window and so +/// never lists or writes a preset, but taking the directory as a parameter is what keeps that true +/// by construction rather than by argument. [`preset_dir`] is the ordinary form. +#[must_use] +pub fn preset_dir_under(config_dir: &Path) -> PathBuf { + config_dir.join(PRESET_DIR_NAME) +} + +/// The preset directory beneath this user's configuration directory, or `None` where +/// [`crate::config_dir`] resolves none. +#[must_use] +pub fn preset_dir() -> Option { + crate::config_dir().map(|dir| preset_dir_under(&dir)) +} + +/// The file a preset called `name` is stored in, or `None` if `name` is not one either product +/// will write — see [`sanitise_name`]. +#[must_use] +pub fn preset_path(dir: &Path, name: &str) -> Option { + Some(dir.join(format!("{}.{PRESET_EXTENSION}", sanitise_name(name)?))) +} + +/// The name, if it is one that can only ever name a plain file directly inside the preset +/// directory. +/// +/// Rejected: anything empty once trimmed, anything containing a path separator of either platform +/// (so a name can never reach a sibling directory), anything that is `.` or `..`, and anything +/// containing a character Windows refuses in a filename. The last is checked on every platform on +/// purpose: a preset saved on Linux under a name Windows cannot represent would be a preset the +/// other half of FR-STATE-030's interchangeability claim cannot open. +#[must_use] +pub fn sanitise_name(name: &str) -> Option<&str> { + let name = name.trim(); + if name.is_empty() || name == "." || name == ".." { + return None; + } + if name.chars().any(|c| { + matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() + }) { + return None; + } + Some(name) +} + +/// Every `.namirpreset` directly inside `dir` as a `(name, path)` pair, named by file stem, sorted +/// by name. +/// +/// Non-recursive, and a directory that does not exist (or cannot be read) is an empty list rather +/// than an error: "no presets saved yet" is the ordinary first-run state, and there is nothing for +/// a user to act on in being told about it. +/// +/// **Blocking:** this reads a directory, so it belongs on a worker thread, never inside a +/// `UiHost::snapshot` call. +#[must_use] +pub fn list_preset_files(dir: &Path) -> Vec<(String, PathBuf)> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut presets: Vec<(String, PathBuf)> = entries + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case(PRESET_EXTENSION)) + }) + .filter_map(|path| { + let name = path.file_stem()?.to_str()?.to_owned(); + Some((name, path)) + }) + .collect(); + presets.sort_by(|a, b| a.0.cmp(&b.0)); + presets +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "namir-platform-presets-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + /// FR-STATE-030's interchangeability begins at discovery, and this is the function that makes + /// the two products agree by construction rather than by two copies happening to match. The + /// assertion is on the composition, not just the constants: a shell that resolved + /// `/Presets` itself would still drift the day either half changed. + #[test] + fn the_preset_directory_is_one_rule_both_products_get_from_here() { + assert_eq!(PRESET_DIR_NAME, "Presets"); + assert_eq!(PRESET_EXTENSION, "namirpreset"); + let config = Path::new("/somewhere/config"); + assert_eq!(preset_dir_under(config), config.join("Presets")); + assert_eq!( + preset_path(&preset_dir_under(config), "Crunch"), + Some(config.join("Presets").join("Crunch.namirpreset")) + ); + } + + #[test] + fn a_name_that_could_escape_the_preset_directory_is_refused() { + for name in [ + "", " ", ".", "..", "a/b", "a\\b", "C:name", "a:b", "a*b", "a?b", "a\"b", "ab", "a|b", "a\u{0}b", + ] { + assert_eq!(sanitise_name(name), None, "{name:?} must be refused"); + assert_eq!(preset_path(Path::new("/presets"), name), None); + } + assert_eq!(sanitise_name(" Crunch "), Some("Crunch")); + } + + #[test] + fn listing_names_by_stem_sorted_and_ignores_everything_else() { + let dir = temp_dir("listing"); + for file in ["Beta.namirpreset", "alpha.namirpreset", "notes.txt"] { + std::fs::write(dir.join(file), b"{}").expect("write"); + } + std::fs::create_dir_all(dir.join("Nested.namirpreset")).expect("create dir"); + + let found = list_preset_files(&dir); + let names: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["Beta", "alpha"], "sorted by name, files only"); + assert_eq!(found[0].1, dir.join("Beta.namirpreset")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_unreadable_directory_lists_as_empty_rather_than_failing() { + assert!(list_preset_files(Path::new("/no/such/preset/directory")).is_empty()); + } +} From 5404f309b3af6ab74325828289b3cad2ad5b348d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:48:44 +0000 Subject: [PATCH 25/44] Bypass without prepare_crosscutting, and stop NaN poisoning the meter (#36, #129) #36: Chain::process read "if not bypassed OR not prepared, run every stage", so a chain that never called prepare_crosscutting processed the signal while reporting bypass. No production path reaches it -- both shells build through build_default_chain, whose last statement is that call -- so it was a latent trap guarded only by an ordering nothing checks. Fixed by passing the input through, not by a debug_assert: this codebase already argues at engine.rs:243 that an assert on the audio thread degrades in exactly the wrong direction, loud in the test build and absent in the release build a host runs. An unprepared bypass is now the input undelayed rather than the input delayed -- still the input, which is all bypass claims. prepare_crosscutting still gates the NaN scan, the ceiling and latency compensation, and each doc comment now says which. The issue's excerpt was stale (apply_bypass is gone) but the defect was intact in its new form. #129's other half: one non-finite sample left Meter::peak reading dead silence for ever. Guarded -- and the average is guarded on its result too, because the same poisoning is reachable from a finite sample: x*x overflows above ~1.8e19 and the next sample is NaN. A guard reading only x.is_finite() would have left that open. The clip latch is deliberately untouched: infinity still latches clip, NaN still does not, exactly as before. Removing poisoning must not also remove the one visible trace a blown-up sample leaves in a meter. #33 needed no work: M14 sealed ErrorCode with non_exhaustive and added the xtask error-catalogue check, including the site the issue names and a second one it did not. Verified clean rather than assumed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-dsp/src/meter.rs | 134 ++++++++++++++++++++++++++++--- crates/namir-engine/src/chain.rs | 129 ++++++++++++++++++++++------- 2 files changed, 226 insertions(+), 37 deletions(-) diff --git a/crates/namir-dsp/src/meter.rs b/crates/namir-dsp/src/meter.rs index 9491b22..efbfb76 100644 --- a/crates/namir-dsp/src/meter.rs +++ b/crates/namir-dsp/src/meter.rs @@ -47,21 +47,59 @@ impl Meter { /// Updates peak, average, peak-hold and clip state from `buf`. Read-only over `buf` — a /// meter observes, it does not shape the signal. + /// + /// # Why the level updates are guarded (issue #129) + /// + /// A meter is a *follower*: every reading is a function of its own previous value, so a single + /// value with no level to it does not produce one wrong frame, it produces wrong frames for + /// ever. That is what a non-finite sample used to do here. `NaN > self.peak` is false, so the + /// release branch computed `peak + coeff * (NaN - peak)` = NaN, and NaN compares false against + /// everything after it, so no later sample — however loud — could ever move `peak` again. + /// + /// `namir-core`'s `linear_to_db` fix for the same issue does not mask this and was never meant + /// to: it maps a NaN amplitude to the floor, which is the *readable* rendering of a poisoned + /// meter and the reason the failure was invisible. A meter frozen at −600 dB reads as dead + /// silence, which is precisely the condition a user consults a meter to rule out. + /// + /// So a sample that is not finite contributes to none of the three level readings; the follower + /// simply keeps releasing, and the next real sample moves it again. Two things this guard + /// deliberately does **not** do: + /// + /// - It does not touch the clip latch. That latch states "a sample reached or exceeded full + /// scale", and an infinite one did (`inf >= 1.0`); a NaN did not. Both keep the behaviour + /// they had, so the one visible trace a blown-up sample leaves in a meter survives the fix. + /// - It does not report the fault. Containing a non-finite sample is FR-CHAIN-080's job — + /// `namir_engine::Chain` silences the whole block and increments a counter the UI can read — + /// and a DSP primitive with no error channel inventing a second one would be the worse + /// design. This is only about not being poisoned by what the chain is already reporting. + /// + /// The average is guarded on its *result* rather than on `x`, because the same poisoning is + /// reachable from a perfectly finite sample: `x * x` overflows to infinity above a magnitude of + /// ~1.8e19, and `inf + coeff * (x2 - inf)` is NaN on the next sample. + /// + /// **RT-safe:** the guards are branches on values already in registers — no allocation, no + /// call, and the loop bound is still `buf.len()`. pub fn process(&mut self, buf: &[f32]) { for &x in buf { let abs_x = x.abs(); - // Fast attack (instantaneous jump to a new higher sample), slow exponential release. - if abs_x > self.peak { - self.peak = abs_x; - } else { - self.peak += self.release_coeff * (abs_x - self.peak); - } + if abs_x.is_finite() { + // Fast attack (instantaneous jump to a new higher sample), slow exponential + // release. + if abs_x > self.peak { + self.peak = abs_x; + } else { + self.peak += self.release_coeff * (abs_x - self.peak); + } - self.avg_sq += self.release_coeff * (x * x - self.avg_sq); + let avg_sq = self.avg_sq + self.release_coeff * (x * x - self.avg_sq); + if avg_sq.is_finite() { + self.avg_sq = avg_sq; + } - if self.peak > self.peak_hold { - self.peak_hold = self.peak; + if self.peak > self.peak_hold { + self.peak_hold = self.peak; + } } if abs_x >= 1.0 { @@ -213,6 +251,84 @@ mod tests { ); } + /// Issue #129's second half, the one `namir-core`'s `linear_to_db` fix does **not** mask: a + /// single non-finite sample used to poison `peak` (and `avg_sq`) permanently. `NaN > peak` is + /// false, so the release branch computed `peak + coeff * (NaN - peak)` = NaN, and every + /// subsequent sample kept it NaN; `linear_to_db(NaN)` is the floor, so the meter read **dead + /// silence forever** — the worst shape a wrong meter can take, since silence is exactly what a + /// user checks a meter to rule out. + /// + /// Committed red-first: before the guard, `peak_db()` after the recovery tone is the -600 dB + /// floor rather than a reading near the tone's own level. + #[test] + fn one_nan_sample_does_not_poison_the_meter_for_ever() { + let floor = linear_to_db(0.0); + for poison in [f32::NAN, -f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut meter = Meter::new(sr(48_000)); + meter.process(&[0.5f32; 64]); + let before = meter.peak_db(); + assert!(before > floor + 100.0, "{poison}: setup did not register"); + + meter.process(&[poison]); + assert!( + meter.peak_db().is_finite() && meter.average_db().is_finite(), + "{poison}: reading went non-finite immediately" + ); + + // A full second of real signal after the bad sample: a meter that recovers reads the + // tone, a poisoned one reads the floor no matter what it is fed. + meter.process(&[0.5f32; 48_000]); + assert!( + (meter.peak_db() - before).abs() < 1.0, + "{poison}: after one bad sample the meter reads {} dB against the {before} dB the \ + same signal read before it", + meter.peak_db() + ); + assert!( + meter.average_db() > floor + 100.0, + "{poison}: the average stayed poisoned at {} dB", + meter.average_db() + ); + assert!( + meter.peak_hold_db() > floor + 100.0, + "{poison}: the peak-hold stayed poisoned at {} dB", + meter.peak_hold_db() + ); + } + } + + /// The same poisoning reachable from a **finite** sample: `x * x` overflows to infinity for any + /// magnitude above ~1.8e19, so `avg_sq` went infinite and the very next sample turned it into + /// `inf + coeff * (x2 - inf)` = NaN. A guard that only reads `x.is_finite()` leaves this open, + /// which is why the average commits its update only when the result is itself finite. + #[test] + fn a_huge_finite_sample_does_not_poison_the_average() { + let mut meter = Meter::new(sr(48_000)); + meter.process(&[1e30f32]); + meter.process(&[0.5f32; 48_000]); + assert!( + meter.average_db().is_finite() && meter.average_db() > linear_to_db(0.0) + 100.0, + "the average reads {} dB after one 1e30 sample", + meter.average_db() + ); + } + + /// The clip latch is deliberately *not* part of the guard: it states "any sample reached or + /// exceeded full scale", and an infinite one did. Pinned so the guard above cannot quietly + /// take the one visible trace a blown-up sample leaves in a meter. (A NaN latches nothing — + /// `NaN >= 1.0` is false — which is also unchanged, and is FR-CHAIN-080's fault to report, + /// not this primitive's.) + #[test] + fn an_infinite_sample_still_latches_the_clip_indicator() { + let mut meter = Meter::new(sr(48_000)); + meter.process(&[f32::INFINITY]); + assert!(meter.clipped()); + + let mut meter = Meter::new(sr(48_000)); + meter.process(&[f32::NEG_INFINITY]); + assert!(meter.clipped()); + } + #[test] fn process_does_not_allocate() { let mut meter = Meter::new(sr(48_000)); diff --git a/crates/namir-engine/src/chain.rs b/crates/namir-engine/src/chain.rs index ef70103..91cc049 100644 --- a/crates/namir-engine/src/chain.rs +++ b/crates/namir-engine/src/chain.rs @@ -32,15 +32,22 @@ const OUTPUT_CEILING_DB_ID: ParamId = ParamId(OUTPUT_CEILING_DB.id.0); /// `global_bypass` and `output_ceiling_linear` are plain fields, not folded into /// `cross_cutting`, precisely so [`Chain::set_global_bypass`] and /// [`Chain::set_output_ceiling_db`] are callable in either order relative to -/// [`Chain::prepare_crosscutting`] without one silently no-oping — they just record intent. -/// Whether that intent has any effect on `process` depends only on whether `cross_cutting` is -/// `Some`, i.e. whether `prepare_crosscutting` was ever called. See `prepare_crosscutting`'s own -/// doc comment for why that call is opt-in rather than folded into `new`. +/// [`Chain::prepare_crosscutting`] without one silently no-oping — they just record intent. See +/// `prepare_crosscutting`'s own doc comment for why that call is opt-in rather than folded into +/// `new`. +/// +/// **What that call does and does not gate (issue #36).** It gates FR-CHAIN-080's NaN scan, +/// FR-CHAIN-090's ceiling, and FR-CHAIN-030's *latency compensation* — the three things that need +/// state allocated off the audio thread. It does **not** gate the bypass itself. It used to: a +/// chain with `global_bypass` set but no `cross_cutting` ran every stage anyway, which is a global +/// bypass that does not bypass, and the failure was indistinguishable from working because audio +/// kept flowing. `output_ceiling_linear` has no such trap — an unclamped block is a block, not a +/// contradiction in terms — so it stays gated. pub struct Chain { stages: Vec>, - /// FR-CHAIN-030: when `true` *and* `cross_cutting` is `Some`, `process` takes the bypass path - /// instead of running `stages`. RT-safe to flip (see `set_global_bypass`) since it is read, - /// never allocated, on the audio thread. + /// FR-CHAIN-030: when `true`, `process` routes the block to the output instead of running + /// `stages` — unconditionally, whether or not `cross_cutting` is `Some` (issue #36). RT-safe + /// to flip (see `set_global_bypass`) since it is read, never allocated, on the audio thread. global_bypass: bool, /// FR-CHAIN-090's ceiling, already converted to a linear multiplier (so `process` never calls /// `db_to_linear` itself — that conversion happens once, in `set_output_ceiling_db`, off the @@ -224,8 +231,10 @@ impl Chain { /// Wraps an already-`prepare`d stage list. Building that list is the caller's job; see this /// struct's doc comment. /// - /// Deliberately leaves `cross_cutting` at `None` — FR-CHAIN-030/080/090 stay inactive until - /// [`Chain::prepare_crosscutting`] is called explicitly. See that method's doc comment for + /// Deliberately leaves `cross_cutting` at `None` — FR-CHAIN-080/090, and FR-CHAIN-030's + /// latency compensation, stay inactive until [`Chain::prepare_crosscutting`] is called + /// explicitly (the bypass itself is not gated on it; see this struct's own doc comment and + /// issue #36). See that method's doc comment for /// why this constructor doesn't do it implicitly: this file's own 8 pre-existing tests (and /// any future test scaffolding built directly on `Chain::new`) rely on a raw, untouched /// `process` — only the real product path (`build_default_chain`, once wired) is expected to @@ -241,10 +250,11 @@ impl Chain { } /// Non-RT setup call that switches the chain into "cross-cutting active" mode: from this - /// call onward, `process` also applies FR-CHAIN-030 (global bypass, once - /// [`set_global_bypass`](Chain::set_global_bypass) turns it on), FR-CHAIN-080 (NaN/Inf -> - /// silence + fault flag), and FR-CHAIN-090 (output ceiling clamp). Before this call, `process` - /// behaves exactly as it always has — see `Chain::new`'s doc comment. + /// call onward, `process` also applies FR-CHAIN-030's *latency compensation* on the bypass + /// path, FR-CHAIN-080 (NaN/Inf -> silence + fault flag), and FR-CHAIN-090 (output ceiling + /// clamp). Before this call, `process` behaves exactly as it always has, save that global + /// bypass now bypasses rather than running every stage (issue #36) — see `Chain::new`'s doc + /// comment. /// /// May allocate (it is not run on the audio thread): it pre-sizes one delay ring per channel, /// each `self.latency_samples()` long, using this chain's *own* `latency_samples()` (computed @@ -295,11 +305,11 @@ impl Chain { /// else — so this may be called from the audio thread's own command-handling path as well as /// from setup code. /// - /// Only has any effect once [`prepare_crosscutting`](Chain::prepare_crosscutting) has been - /// called: with no delay ring built, there is nothing for `process` to route input through - /// besides the stages themselves, so `process` just runs them as it always has. No existing - /// test calls this — it is exercised only by this module's new cross-cutting tests, which do - /// call `prepare_crosscutting` first. + /// Takes effect immediately, on a prepared chain or an unprepared one (issue #36). What + /// [`prepare_crosscutting`](Chain::prepare_crosscutting) adds is the latency-compensation + /// delay: without it a bypassed block is the input undelayed, which is unity-gain passthrough + /// but not sample-aligned against the latency the chain reports. Until M14 the bypass was + /// gated on that call and an unprepared chain ran every stage while nominally bypassed. /// /// **D-10.4:** the product path no longer calls this directly — a `global.bypass` change now /// arrives as an ordinary [`ParamChange`] through [`Chain::apply`], exactly like every stage @@ -332,12 +342,12 @@ impl Chain { } /// Runs every stage in order, on the audio thread (RT) — unless global bypass (FR-CHAIN-030) - /// is active, in which case the bypass path runs instead. Either way, once cross-cutting is - /// active (`prepare_crosscutting` has been called), the block this produces is then scanned - /// for NaN/Inf (FR-CHAIN-080) and ceiling-clamped (FR-CHAIN-090) before returning. See - /// `prepare_crosscutting`'s doc comment for why a chain built via `Chain::new` and never - /// prepared for cross-cutting skips all of that and behaves exactly as before this feature - /// existed. + /// is active, in which case the block passes to the output unmodified instead. Either way, + /// once cross-cutting is active (`prepare_crosscutting` has been called), the block this + /// produces is then scanned for NaN/Inf (FR-CHAIN-080) and ceiling-clamped (FR-CHAIN-090) + /// before returning, and the bypass path is delayed by the chain's reported latency. See + /// `prepare_crosscutting`'s doc comment for what a chain built via `Chain::new` and never + /// prepared for cross-cutting skips — the bypass is not on that list (issue #36). pub fn process(&mut self, io: &mut StageIo<'_>) { // Read *this block's* latency rather than a figure cached at preparation (issue #58): // installing a model whose declared rate differs from the engine's raises it mid-session, @@ -347,14 +357,16 @@ impl Chain { let latency = self.latency_samples() as usize; let bypassed = self.global_bypass; - let prepared = self.cross_cutting.is_some(); if let Some(cross_cutting) = self.cross_cutting.as_mut() { // Runs on both paths — see `run_delay`'s doc comment (issue #59). cross_cutting.run_delay(io, latency, bypassed); } - if !bypassed || !prepared { - // No line to bypass through (prepare_crosscutting was never called): today's - // behaviour, unchanged. See set_global_bypass's doc comment. + // Bypass is not conditional on `cross_cutting` (issue #36). With no ring built there is + // nothing to compensate the chain's latency with, so an unprepared bypass is the input + // undelayed rather than the input delayed — but it is still the *input*, which is the + // whole of what "bypass" claims. Running every stage instead, as this used to when + // `prepare_crosscutting` had not been called, is the one reading the word cannot bear. + if !bypassed { for stage in &mut self.stages { stage.process(io); } @@ -1025,6 +1037,67 @@ mod tests { } } + /// **Issue #36.** `process` used to gate the bypass on `cross_cutting.is_some()` + /// (`if !bypassed || !prepared { run every stage }`), so a chain that never had + /// `prepare_crosscutting` called on it **ran the whole chain while nominally bypassed** — a + /// global bypass that does not bypass. Nothing detected it: audio keeps flowing, so the only + /// symptom is a bypass button that appears to do nothing, which a user attributes to their host + /// or their own routing. + /// + /// Both product shells reach `process` only through `build_default_chain`, whose last statement + /// before returning is `prepare_crosscutting`, so this was a latent trap rather than a shipped + /// defect — but "latent" was the only thing standing between the two, and it was a property of + /// a call ordering rather than of anything checked. Bypass now means bypass on both paths: + /// `prepare_crosscutting` adds FR-CHAIN-030's *latency compensation*, not the passthrough + /// itself, and an unprepared chain's `latency_samples()` is uncompensated exactly as an + /// unprepared chain gets no NaN scan and no ceiling. + /// + /// Committed red-first: before the fix the first assertion reads `db_to_linear(6.0)` (≈2.0) + /// against the input's 0.25 — the stage ran. + #[test] + fn global_bypass_bypasses_on_a_chain_that_never_prepared_crosscutting() { + let prep = FixedGainPrep { gain_db: 6.0 }; + let stage = prep.prepare(&ctx()).unwrap(); + let mut chain = Chain::new(vec![Box::new(stage)]); + assert_eq!( + chain.prepared_for(), + None, + "the whole point of this test is the chain `Chain::new` alone leaves behind" + ); + chain.set_global_bypass(true); + + let input = [0.25f32, -0.5, 0.75, -0.125]; + let mut buf = input; + { + let mut channels: [&mut [f32]; 1] = [&mut buf]; + let mut io = StageIo::new(&mut channels, 4); + audio_section(|| chain.process(&mut io)); + } + assert_eq!( + buf, input, + "a bypassed chain must route its input to its output unmodified, whether or not \ + `prepare_crosscutting` has run" + ); + + // The converse, so the fix cannot be "an unprepared chain never runs its stages": with + // bypass released the same chain gains by the same +6 dB it always did. + chain.set_global_bypass(false); + let mut buf = input; + { + let mut channels: [&mut [f32]; 1] = [&mut buf]; + let mut io = StageIo::new(&mut channels, 4); + audio_section(|| chain.process(&mut io)); + } + let gain = namir_core::db_to_linear(6.0); + for (out, inp) in buf.iter().zip(&input) { + assert!( + (out - inp * gain).abs() < 1e-4, + "releasing bypass on an unprepared chain must run the stages again: {out} vs {}", + inp * gain + ); + } + } + /// **Issue #61.** `scan_and_clamp` used to run in full on the bypass path, so FR-CHAIN-090's /// ceiling (default 0 dBFS) clipped a bypassed signal — and FR-CHAIN-030's own `Verify:` /// method, the null test, is simply false for any input above that ceiling. The two bypass From d236e0096e6ca259f566101019b8a3ed5a34698a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:51:13 +0000 Subject: [PATCH 26/44] A failable virtual device, and the stream stop it proved missing (#24, #88) Building FR-IO-070's named apparatus found the defect it exists to catch. The notice half already passed; the assertion that failed was that nothing stopped the stream, while DEVICE_LOST's catalogue text has told users "the stream was stopped" since M14. The RunningStreams move out of a run() local into AppHost -- the UI thread is the only one that both learns of the loss and may act on it -- and are dropped on a DEVICE_LOST classification and only that one, since cpal reports survivable conditions through the same callback. The premise is out of date on one point: the substring recovery for the observed unplug already shipped with #44. The real gap was a level up, and the pinned fork's own source is the evidence -- wasapi/mod.rs:72 maps AUDCLNT_E_RESOURCES_INVALIDATED, the exact observed code, onto ErrorKind::StreamInvalidated, a kind Namir's match never named. cpal had classified it; Namir had not read the classification. The case no substring can reach is in the same file: default_device_change_error returns a bare StreamInvalidated with no message, no OS number and no marker. The tag moves onto the new test but is NOT promoted: "allow the user to select another device" is spanned only by a restart-mediated substitute, because no in-session device chooser exists in either shell (#26). Its uncovered: field now says that instead of describing an apparatus that no longer fails to exist. #88's owed half: app.rs, audio_io.rs and convert.rs join AUDIO_THREAD_MODULES. The gate went red on exactly the two predicted main-thread logging calls, which move to a new diagnostics.rs -- the standalone's shared.rs. Nine modules now covered, plus two guard tests so the hand-maintained list cannot silently shrink. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-app/src/app.rs | 31 ++- crates/namir-app/src/audio_io.rs | 81 +++++- crates/namir-app/src/device_state.rs | 16 +- crates/namir-app/src/diagnostics.rs | 63 +++++ crates/namir-app/src/host.rs | 258 +++++++++++++++++- crates/namir-app/src/lib.rs | 4 + crates/namir-app/src/stream.rs | 122 ++++++++- docs/manual-tests/fr-io-070-device-removal.md | 66 +++++ xtask/src/rt_logging.rs | 55 ++++ 9 files changed, 671 insertions(+), 25 deletions(-) create mode 100644 crates/namir-app/src/diagnostics.rs diff --git a/crates/namir-app/src/app.rs b/crates/namir-app/src/app.rs index decb857..227c1ea 100644 --- a/crates/namir-app/src/app.rs +++ b/crates/namir-app/src/app.rs @@ -200,7 +200,7 @@ const STREAM_FAILURE_RING_SLOTS: usize = 16; /// `Xrun` is counted rather than pushed, exactly as before — [`crate::xrun::XrunCounter::record`] /// is a single relaxed atomic increment and belongs on the callback thread, and routing it through /// the ring would let a burst of dropouts evict the device-loss report behind it. -fn stream_failure_sink( +pub(crate) fn stream_failure_sink( xruns: Arc, mut failures: rtrb::Producer, ) -> impl FnMut(StreamFailure) + Send + 'static { @@ -229,13 +229,17 @@ pub fn run() { // verbosity field, and M9b does not add one — the plugin is environment-variable-only by // decision (roadmap §15 item 8) and giving the app a second, divergent control was ruled out of // this round. `NAMIR_LOG` therefore governs both products identically. The seam is already - // there for the day a settings field arrives: `logging::init` takes the level as a parameter - // precisely so `namir-platform` need not know what `AppSettings` is. + // there for the day a settings field arrives: the platform initialiser takes the level as a + // parameter precisely so `namir-platform` need not know what `AppSettings` is. // // Before `resolve_config_dir` because the log's own location is `namir_platform:: // log_file_path`, which is independent of the app's config directory and of // `startup_probe`'s override of it — a probed launch logs to the same place a real one does. - namir_platform::logging::init(None); + // + // Through `crate::diagnostics` rather than `namir-platform` directly: this file is on + // FR-ERR-030's audio-thread list (it owns `stream_failure_sink`), so it may not name the + // logger even for a main-thread call. See that module's doc comment. + crate::diagnostics::install(); let config_dir = resolve_config_dir(); @@ -482,7 +486,13 @@ pub fn run() { failed_output_name, )); - let _running = match running { + // Handed to `AppHost` rather than kept in a local (issue #24): FR-IO-070 requires the stream + // to be stopped cleanly when a device is lost, and this function is about to block inside + // `namir_ui::open_blocking` for the whole life of the window. The UI thread is the only one + // that both learns of the loss (it drains the failure rings) and may act on it -- see + // `AppHost::hold_streams`. The host drops the path when the window closes, which is where + // this local used to drop it. + match running { Ok(running) => { // Issue #76: D-13.2's elevation outcome is produced inside the first output callback // and cannot be reported from there (see `stream::ThreadPriorityReport`), so the @@ -498,7 +508,7 @@ pub fn run() { // measurement run. startup_probe::audible(library_index_entries, default_state_params); eprintln!("namir: audio stream started"); - Some(running) + host.hold_streams(running); } Err(e) => { // The detail is carried on the marker, not left to the notice alone: a probed @@ -508,16 +518,17 @@ pub fn run() { &e.to_string(), ); host.report(crate::error_codes::DEVICE_OPEN_FAILED, e.to_string()); - None + // Not held: a path that never started is dropped here, which stops the half + // of it that did open (FR-IO-070's "stop the stream cleanly" for the + // failed-to-start case, and `RunningStreams`' own drop contract). } } } Err(e) => { startup_probe::not_audible(startup_probe::REASON_STREAM_NOT_STARTED, &e.to_string()); host.report(crate::error_codes::DEVICE_OPEN_FAILED, e.to_string()); - None } - }; + } // NFR-PERF-030: a measurement run has nothing left to do — its marker is out — and returning // here is what makes the process exit instead of blocking in `open_blocking` below. Before the @@ -562,7 +573,7 @@ pub fn run() { // file that silently failed to save is precisely the "why did it forget my device again?" // report a log exists to answer — and is now the record it always should have been. if let Err(w) = settings::save(&settings::settings_path(dir), &settings) { - namir_platform::logging::record(w.code, &w.detail); + crate::diagnostics::record(w.code, &w.detail); } } } diff --git a/crates/namir-app/src/audio_io.rs b/crates/namir-app/src/audio_io.rs index f4246e4..930a135 100644 --- a/crates/namir-app/src/audio_io.rs +++ b/crates/namir-app/src/audio_io.rs @@ -761,11 +761,33 @@ mod cpal_impl { /// carrying WASAPI's `AUDCLNT_E_RESOURCES_INVALIDATED`. So the message is read before giving /// up on it — see [`super::classifies_as_device_loss`] for the marker list, the transcript it /// came from, and what this recovery does and does not claim. - fn to_stream_failure(error: cpal::Error) -> StreamFailure { + /// + /// **`StreamInvalidated` is matched too, added for issue #24, and it is what should have + /// caught that unplug in the first place.** M14 read the transcript's *message* and concluded + /// `cpal` had not classified the condition; the fork's own source says otherwise. Its WASAPI + /// `From` maps `AUDCLNT_E_RESOURCES_INVALIDATED` — the exact code the + /// unplug produced — onto [`cpal::ErrorKind::StreamInvalidated`], which this `match` did not + /// name, so the failure reached the `_` arm and was rescued only because that message happened + /// to carry the raw OS number. + /// + /// Two reasons to match the kind rather than leave the substring to do it. The kind is what the + /// backend actually *said*, so it survives a reworded message — the failure mode + /// [`super::DEVICE_LOSS_MARKERS`]' own doc admits it cannot survive. And there is a case the + /// substring provably cannot reach: the fork's `default_device_change_error` returns a bare + /// `ErrorKind::StreamInvalidated` with no message at all, whose `Display` is the kind's own + /// prose ("The stream configuration is no longer valid and must be rebuilt.") — no OS number, + /// no marker, and therefore a device loss reported as a generic + /// [`crate::error_codes::STREAM_FAILED`] until this arm existed. + /// + /// What is deliberately *not* folded in here: `ErrorKind::DeviceChanged`, which the fork + /// documents as "the stream remains active and no rebuild is required" — a reroute is not a + /// loss, and reporting it as one would stop a stream that is still working (see + /// [`crate::host::AppHost`], which stops the streams on exactly this classification). + pub(super) fn to_stream_failure(error: cpal::Error) -> StreamFailure { match error.kind() { - cpal::ErrorKind::DeviceNotAvailable | cpal::ErrorKind::HostUnavailable => { - StreamFailure::DeviceLost - } + cpal::ErrorKind::DeviceNotAvailable + | cpal::ErrorKind::HostUnavailable + | cpal::ErrorKind::StreamInvalidated => StreamFailure::DeviceLost, cpal::ErrorKind::Xrun => StreamFailure::Xrun, _ => { // `InlineDetail::from_display`, not `error.to_string()` (issue #88): this runs on @@ -1237,7 +1259,7 @@ mod cpal_impl { mod tests { use super::cpal_impl::{ acceptable_formats, exclusive_outcome, preferred_format, scratch_samples, - to_supported_configs, wasapi_options, + to_stream_failure, to_supported_configs, wasapi_options, }; use super::*; @@ -1313,6 +1335,55 @@ mod tests { } } + /// **Issue #24: the classification is widened at the `ErrorKind` it should have read all + /// along.** The M14 recovery above reads the backend's *message*, and it only rescued the + /// 2026-08-27 unplug because that message happened to carry the raw OS number. The fork itself + /// says what the condition was: `crates/.../cpal/src/host/wasapi/mod.rs` maps + /// `AUDCLNT_E_RESOURCES_INVALIDATED` — the exact code that unplug produced — onto + /// [`cpal::ErrorKind::StreamInvalidated`], which `to_stream_failure` did not match, so the + /// failure fell through to the `_` arm and was saved by a substring. + /// + /// The case the substring cannot save is in the same file: `default_device_change_error` + /// returns `ErrorKind::StreamInvalidated` with **no message at all**, so `Display` renders the + /// kind's own prose ("The stream configuration is no longer valid and must be rebuilt.") — no + /// OS number, no marker, and until this test a reported-as-`STREAM_FAILED` device loss. + #[test] + fn a_stream_invalidated_by_the_backend_is_a_device_loss() { + for error in [ + cpal::Error::new(cpal::ErrorKind::StreamInvalidated), + cpal::Error::with_message( + cpal::ErrorKind::StreamInvalidated, + "OS Error -2004287450 (FormatMessageW() returned error 317)", + ), + ] { + assert_eq!( + to_stream_failure(error.clone()), + StreamFailure::DeviceLost, + "{error}" + ); + } + } + + /// The two arms that were already right, kept beside the new one so a future edit to the + /// `match` has to keep all three: an xrun is an xrun, and an error that names no device and is + /// classified as nothing in particular stays [`StreamFailure::Other`] rather than being + /// promoted. + #[test] + fn the_other_stream_failure_classifications_are_unchanged() { + assert_eq!( + to_stream_failure(cpal::Error::new(cpal::ErrorKind::DeviceNotAvailable)), + StreamFailure::DeviceLost + ); + assert_eq!( + to_stream_failure(cpal::Error::new(cpal::ErrorKind::Xrun)), + StreamFailure::Xrun + ); + assert!(matches!( + to_stream_failure(cpal::Error::new(cpal::ErrorKind::UnsupportedConfig)), + StreamFailure::Other(_) + )); + } + /// The safe direction: an error that says nothing about a device stays unclassified, so it is /// reported as a stream failure rather than as an invented device removal. This is exactly /// what the pre-M14 code could not do, since it chose from the stream's direction alone. diff --git a/crates/namir-app/src/device_state.rs b/crates/namir-app/src/device_state.rs index c42e307..ecf808f 100644 --- a/crates/namir-app/src/device_state.rs +++ b/crates/namir-app/src/device_state.rs @@ -333,12 +333,16 @@ mod tests { /// FR-IO-070's non-hardware-dependent half: a device that cannot be opened at all (here, /// none present) is handled by returning `None` rather than panicking, which is what lets /// `crate::app::run` fall back to `open_window_without_audio` instead of crashing or hanging. - // trace-partial: FR-IO-070 - // uncovered: FR-IO-070 — the method's named apparatus, a virtual device that can be made to - // uncovered: fail on demand, does not exist and the tagged test opens no device, its whole body - // uncovered: asserting that selecting from an empty slice is None, so device removal while in - // uncovered: use, "stop the stream cleanly" and "allow the user to select another device" are - // uncovered: all unexercised; closes M8 + /// + /// **FR-IO-070's tag moved off this test at issue #24, and this is the note that says where.** + /// It carried the requirement's only annotation while asserting nothing more than that + /// selecting from an empty slice is `None` — no device opened, nothing stopped, no failure + /// injected — which is what the partial's own `uncovered:` field said in as many words. The + /// requirement's stated apparatus now exists (`crate::stream::FakeBackend` can be made to fail + /// on demand), so the tag lives on the test that uses it: + /// `crate::host`'s `a_device_lost_mid_stream_is_reported_and_stops_both_streams_cleanly`. + /// This test keeps its own value — it is the branch `open_window_without_audio` depends on — + /// and simply no longer claims to be FR-IO-070's evidence. #[test] fn no_devices_at_all_yields_none() { assert!(select_device(&[], Some("Anything")).is_none()); diff --git a/crates/namir-app/src/diagnostics.rs b/crates/namir-app/src/diagnostics.rs new file mode 100644 index 0000000..58da039 --- /dev/null +++ b/crates/namir-app/src/diagnostics.rs @@ -0,0 +1,63 @@ +//! The two direct `namir_platform::logging` calls [`crate::app`] used to make, moved here so that +//! module can go on FR-ERR-030's audio-thread list. +//! +//! # Why this module exists +//! +//! `xtask rt-logging` forbids a module that carries audio-thread code from *naming* +//! `namir-platform`'s logger, and applies the ban at **file** granularity — see +//! `xtask/src/rt_logging.rs`'s own module doc for why a line-based scanner cannot do better and why +//! the resulting over-approximation is the honest direction to err in. Three modules in this crate +//! were outside that list while carrying callback code: +//! +//! - [`crate::app`] owns `stream_failure_sink`, the closure `cpal` invokes on the stream's own +//! error-callback thread (issue #88 is what made that closure allocation-free; nothing made the +//! file *covered*). +//! - [`crate::audio_io`] wraps every `cpal` callback and classifies a failure inside the error one +//! (`to_stream_failure`). +//! - `crate::audio_io::convert` converts sample formats inside the two data callbacks. +//! +//! The last two name nothing forbidden and went on the list unchanged. [`crate::app`] could not: +//! it makes two entirely legitimate **main-thread** logger calls — installing the process logger as +//! the first statement of `run`, and recording a settings save that failed *after* the window has +//! closed, where there is no FR-UI-070 notice list left to push onto. Listing the file without +//! moving them would have failed the gate on two calls that break nothing. +//! +//! # The escape hatch, which is the house pattern rather than a workaround +//! +//! `rt_logging.rs`'s module doc names it: `namir-clap`'s `audio.rs` is on the list and its +//! `activate()` — CLAP's `[main-thread]` — reports through `shared.rs`'s `push_notice`, so it is +//! `shared.rs`, not `audio.rs`, that names the logger. This module is that `shared.rs` for the +//! standalone. +//! +//! **It is not a thread-safety mechanism and must not be read as one.** Calling into here from an +//! audio callback would be exactly as illegal as calling the logger directly, and the static check +//! could not see it (`rt_logging.rs`'s residual blind spot 1: the ban is on naming the logger, not +//! on reaching it). What this file buys is that the *name* lives somewhere the callbacks do not, so +//! a future logger call added to [`crate::app`] is a build failure rather than a silent +//! RT-violation. Every function below is main-thread-only, and both of today's callers are on the +//! main thread before the window opens or after it has closed. + +use namir_core::ErrorCode; + +/// FR-ERR-010: installs the process-global logger, once, before anything can report. +/// +/// Called as the first statement of [`crate::app::run`] — see that call site for why the persisted +/// level is `None` and why this happens before the configuration directory is even resolved. +/// Idempotent (`namir_platform::logging::init`'s own contract), so a second call is harmless. +/// +/// **Main thread only.** See this module's doc comment. +pub fn install() { + namir_platform::logging::init(None); +} + +/// FR-ERR-010: writes one log record for a condition that has no notice to carry it. +/// +/// The ordinary path for a diagnostic in this crate is [`crate::host::AppHost`]'s `push_notice`, +/// which writes the record *and* queues the FR-UI-070 notice from one function so the two cannot +/// drift apart. This exists for the one condition that reaches neither: a settings save that fails +/// at the foot of [`crate::app::run`], with the window already closed and the host already dropped. +/// +/// **Main thread only.** See this module's doc comment. +pub fn record(code: ErrorCode, detail: &str) { + namir_platform::logging::record(code, detail); +} diff --git a/crates/namir-app/src/host.rs b/crates/namir-app/src/host.rs index 6c06531..03ed398 100644 --- a/crates/namir-app/src/host.rs +++ b/crates/namir-app/src/host.rs @@ -45,7 +45,7 @@ use namir_worker::library::LibraryService; use crate::audio_io::StreamFailure; use crate::instance::SharedInstance; -use crate::stream::{Direction, ThreadPriorityReport}; +use crate::stream::{Direction, RunningStreams, ThreadPriorityReport}; use crate::worker::{AppCommand, AppEvent, LoadOutcomeSummary, WorkerHandle}; /// This crate's own catalogue entries for the notices [`AppHost`] itself synthesises (as opposed @@ -320,6 +320,10 @@ pub struct AppHost { /// FR-IO-070's stream-failure reports, when this host is driving a real duplex path. `None` /// on `crate::app`'s `open_window_without_audio` path, where there is no stream to fail. stream_failures: Option, + /// The running duplex path itself, so FR-IO-070's "stop the stream cleanly" has something to + /// stop (issue #24). `None` before [`AppHost::hold_streams`] is called, on the + /// `open_window_without_audio` path, and again after a device loss has stopped it. + streams: Option, /// D-13.2's thread-elevation outcome, posted by the output callback and reported from here /// (issue #76). Cleared once reported, so the notice is written once per session rather than /// once per frame. @@ -358,6 +362,7 @@ impl AppHost { presets: Vec::new(), presets_listed_at: None, stream_failures: None, + streams: None, thread_priority: None, notices: Vec::new(), next_notice_id: AtomicU64::new(1), @@ -371,6 +376,23 @@ impl AppHost { self.stream_failures = Some(watch); } + /// Takes ownership of the running duplex path, so FR-IO-070's "stop the stream cleanly" is + /// something this host can actually do (issue #24). + /// + /// **Why the host and not [`crate::app::run`].** The failure is *detected* on a `cpal` error + /// thread, which may not stop anything (it is inside the stream it would be closing, and + /// NFR-RT-010 forbids the blocking work either way), and it is *reported* here, on the UI + /// thread, one frame later. `run` is meanwhile blocked inside `namir_ui::open_blocking` for the + /// whole life of the window and cannot react to anything. So the only thread that both learns + /// of the loss and is allowed to act on it is this one — which is why the streams live here + /// rather than in a `run` local, as they did until this change. + /// + /// Called once, after `RunningStreams::play`, so the elevation watch and the first callback + /// are already in place; a session with no audio device never calls it. + pub fn hold_streams(&mut self, streams: RunningStreams) { + self.streams = Some(streams); + } + /// Points this host at FR-STATE-030's preset directory (`/Presets`, see /// [`crate::presets`]). Called by [`crate::app::run`] once, with the configuration directory /// that launch actually resolved. A host never given one still runs: `SavePreset` reports @@ -455,6 +477,26 @@ impl AppHost { self.stream_failures = Some(watch); } + /// FR-IO-070's "stop the stream cleanly", on a device loss and on nothing else (issue #24). + /// + /// Dropping is the stop: [`crate::audio_io::AudioStream`]'s own contract is that dropping + /// stops the stream, [`RunningStreams`] is built on it, and unlike `pause()` it cannot fail — + /// which matters here, because the device this is stopping has just gone away, so a `pause` + /// against it is as likely to error as to succeed and there would be nothing useful to do with + /// that error. Taking the field also makes the stop idempotent: a second report from the other + /// direction's ring, or a second frame, finds `None` and does nothing. + /// + /// **Only on `DEVICE_LOST`.** [`crate::error_codes::STREAM_FAILED`] covers everything a + /// backend reported that was *not* classified as a removal, and some of those are survivable — + /// `cpal`'s own `ErrorKind` includes `RealtimeDenied` ("audio will still play") and + /// `DeviceChanged` ("the stream remains active and no rebuild is required"). Stopping on those + /// would turn a warning into a silent session. A loss is different in kind: the endpoint is + /// gone, the callbacks are running against nothing, and `DEVICE_LOST`'s own remedy already + /// tells the user that audio does not resume by itself. + fn stop_streams(&mut self) { + drop(self.streams.take()); + } + /// Queues one FR-UI-070 notice **and writes the matching FR-ERR-010 log record**. /// /// Wired here rather than at each of the ten call sites (`SCAN_SAVE_FAILED`, @@ -545,6 +587,9 @@ impl AppHost { // The *classification* picks the entry (issue #44); the direction is carried in // `detail`, which `crate::app`'s callback builds naming both it and the device. let code = local_error_codes::stream_failure_code(&failure); + if code.id == crate::error_codes::DEVICE_LOST.id { + self.stop_streams(); + } self.push_notice(code, detail); } } @@ -1199,6 +1244,217 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Opens the whole duplex path over [`crate::stream::FakeBackend`] and hands both ends to + /// `host`: the real [`crate::app::stream_failure_sink`] closures on the callback side, the + /// [`StreamFailureWatch`] and the [`RunningStreams`] themselves on this side. Everything below + /// the backend is the production code path; only the device is virtual. + fn open_fake_duplex(host: &mut AppHost, backend: &crate::stream::FakeBackend) { + let xruns = Arc::new(crate::xrun::XrunCounter::new()); + let (input_tx, input_rx) = rtrb::RingBuffer::new(8); + let (output_tx, output_rx) = rtrb::RingBuffer::new(8); + let running = crate::stream::open( + crate::stream::fake_duplex_setup(backend, BLOCK), + crate::stream::default_test_engine(BLOCK), + Arc::clone(&xruns), + crate::app::stream_failure_sink(Arc::clone(&xruns), input_tx), + crate::app::stream_failure_sink(xruns, output_tx), + ) + .expect("the fake backend opens unless it was told to fail"); + running.play().unwrap(); + host.watch_stream_failures(StreamFailureWatch::new( + input_rx, + output_rx, + "Line (AudioBox 22VSL)".to_string(), + "Speakers (AudioBox 22VSL)".to_string(), + )); + host.hold_streams(running); + } + + /// **FR-IO-070 through its own stated apparatus (issue #24, §22 R-5).** The requirement's + /// method is *"I with a virtual device that can be made to fail on demand"*, and until this + /// test no such device existed: the tagged artifact asserted that selecting from an empty + /// slice is `None`, and the only evidence of a real removal was a manual document. + /// + /// What runs here is the production path end to end, with nothing but the device faked. A + /// [`crate::stream::FakeBackend`] stream is opened by `crate::stream::open`, played, and driven + /// for a few blocks so the failure is genuinely *mid-stream*; then the error callback `cpal` + /// itself would invoke — captured by the fake since issue #88 — is fired on the output + /// direction with the 2026-08-27 transcript verbatim. + /// + /// It is fired as [`StreamFailure::Other`], not `DeviceLost`, deliberately: that is the shape + /// the real unplug arrived in, and it is what makes this exercise + /// `crate::audio_io::classifies_as_device_loss` rather than a pre-classified value a test + /// handed itself. + /// + /// Three of the requirement's four clauses are asserted: no crash or hang (the test completes), + /// the condition is reported (one `DEVICE_LOST` notice naming the side and the device), and the + /// stream is stopped cleanly (both directions' streams dropped exactly once, from the UI + /// thread, and not before the report). The fourth is `select_device`'s re-selection, in the + /// test below. + // trace-partial: FR-IO-070 + // uncovered: FR-IO-070 — "allow the user to select another device" is spanned only by the + // uncovered: restart-mediated substitute below (`device_state::select_device` picking a + // uncovered: replacement on the next launch); no in-session device chooser exists in either + // uncovered: shell, so the clause as written is unimplemented (issue #26, roadmap §15 item 16) + // uncovered: and no test can reach it. The failable device is also virtual, so what a real + // uncovered: removal makes the OS and cpal do stays evidenced only by + // uncovered: docs/manual-tests/fr-io-070-device-removal.md, whose steps 1 and 3 are still + // uncovered: NOT EXECUTED; closes M8 + #[test] + fn a_device_lost_mid_stream_is_reported_and_stops_both_streams_cleanly() { + let dir = temp_dir("device_lost_mid_stream"); + let (mut host, _engine) = build_host(&dir); + let backend = crate::stream::FakeBackend::new(); + open_fake_duplex(&mut host, &backend); + + assert_eq!(backend.input_stream.plays(), 1); + assert_eq!(backend.output_stream.plays(), 1); + + // Mid-stream, not at open: audio is flowing before anything fails, which is the condition + // FR-IO-070 names ("device removal **while in use**"). + let mut output_cb = backend.output_data.lock().unwrap().take().unwrap(); + let mut out = [0.0f32; BLOCK * 2]; + for _ in 0..4 { + output_cb(&mut out); + } + assert_eq!( + backend.output_stream.stops(), + 0, + "nothing has failed yet, so nothing may have been stopped" + ); + + // The transcript from the 2026-08-27 unplug, verbatim, arriving the way it really did -- + // as an `Other` carrying an OS error whose own message formatting had failed. + let mut output_err = backend.output_error.lock().unwrap().take().unwrap(); + output_err(StreamFailure::Other(crate::audio_io::InlineDetail::from( + "OS Error -2004287450 (FormatMessageW() returned error 317) (os error -2004287450)", + ))); + + let notices = host.snapshot().notices; + assert_eq!(notices.len(), 1, "{notices:?}"); + assert_eq!(notices[0].code.id, crate::error_codes::DEVICE_LOST.id); + assert!( + notices[0].detail.contains("output"), + "{}", + notices[0].detail + ); + assert!( + notices[0].detail.contains("Speakers (AudioBox 22VSL)"), + "{}", + notices[0].detail + ); + + // "stop the stream cleanly": both directions, exactly once each. `DEVICE_LOST`'s own + // catalogue text has claimed "the stream was stopped" since M14; until issue #24 nothing + // stopped it, and the notice was telling the user something untrue. + assert_eq!( + backend.output_stream.stops(), + 1, + "the failing direction's stream must be stopped" + ); + assert_eq!( + backend.input_stream.stops(), + 1, + "the other direction goes with it: half a duplex path is not a working session" + ); + assert_eq!( + backend.output_stream.pauses(), + 0, + "the stop is a drop, not a pause: pausing an endpoint that has just gone away is as \ + likely to error as to succeed, and a paused stream is still an open device" + ); + + // Idempotent: a second frame, or a second report from the direction still holding a full + // ring, must not double-stop or re-report a path that is already gone. + assert!(host.snapshot().notices.len() <= 1); + assert_eq!(backend.output_stream.stops(), 1); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The same apparatus for FR-IO-070's *other* first-sentence half — "a device failing to open" + /// — which `docs/manual-tests/fr-io-070-device-removal.md` records as step 1, **NOT EXECUTED**, + /// because inducing it needs a device that can be made to refuse an open. + /// + /// The output direction is told to fail, so the input stream has already been built when the + /// open gives up. What is asserted is the teardown: `crate::stream::open` must stop the half it + /// did open rather than leaking a live capture stream on a session that has no audio, and the + /// caller gets a reportable error rather than a panic. + #[test] + fn a_device_that_fails_to_open_reports_and_leaves_no_half_open_stream() { + let dir = temp_dir("device_open_failure"); + let (mut host, _engine) = build_host(&dir); + let backend = crate::stream::FakeBackend::new().failing_to_open(Direction::Output); + + let xruns = Arc::new(crate::xrun::XrunCounter::new()); + let (input_tx, _input_rx) = rtrb::RingBuffer::new(8); + let (output_tx, _output_rx) = rtrb::RingBuffer::new(8); + let opened = crate::stream::open( + crate::stream::fake_duplex_setup(&backend, BLOCK), + crate::stream::default_test_engine(BLOCK), + Arc::clone(&xruns), + crate::app::stream_failure_sink(Arc::clone(&xruns), input_tx), + crate::app::stream_failure_sink(xruns, output_tx), + ); + let error = opened.err().expect("the output open was told to fail"); + + assert_eq!( + backend.stream_log(Direction::Input).stops(), + 1, + "the input stream built before the failure must be stopped, not leaked" + ); + assert_eq!(backend.stream_log(Direction::Output).stops(), 0); + assert!( + backend.output_data.lock().unwrap().is_none(), + "a refused open must not have kept the callbacks it was handed" + ); + + // What `crate::app::run` does with that error, and the notice a user actually sees. + host.report(crate::error_codes::DEVICE_OPEN_FAILED, error.to_string()); + let notices = host.snapshot().notices; + assert_eq!(notices.len(), 1, "{notices:?}"); + assert_eq!( + notices[0].code.id, + crate::error_codes::DEVICE_OPEN_FAILED.id + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// FR-IO-070's third clause, as far as anything in this build can reach it: after the loss, + /// picking up another device. + /// + /// **This is the restart-mediated substitute, not the clause as written**, and the tag above + /// says so. No device-selection surface exists in either shell (issue #26), so what a user can + /// actually do after the notice is close Namir and launch it again — at which point + /// `device_state::select_device` finds the remembered device gone and degrades to another one, + /// reporting `REMEMBERED_DEVICE_UNAVAILABLE` (FR-IO-080). Asserted here rather than assumed, + /// because it is the only continuation path the product has and nothing else tests it against + /// a device that was lost *while in use*. + #[test] + fn after_a_loss_the_next_launch_selects_another_device() { + let lost = "Speakers (AudioBox 22VSL)"; + let remaining = [ + crate::audio_io::DeviceInfo { + name: "Speakers (Realtek)".to_string(), + is_default: true, + }, + crate::audio_io::DeviceInfo { + name: "Headphones".to_string(), + is_default: false, + }, + ]; + + let selection = crate::device_state::select_device(&remaining, Some(lost)) + .expect("another device is present, so the session has somewhere to go"); + assert_eq!(selection.device.name, "Speakers (Realtek)"); + assert_eq!( + selection.fell_back_from.as_deref(), + Some(lost), + "the substitution has to be reportable, not silent" + ); + } + /// A host with no streams behind it (`crate::app`'s `open_window_without_audio`) never calls /// [`AppHost::watch_stream_failures`], and snapshotting must not care. #[test] diff --git a/crates/namir-app/src/lib.rs b/crates/namir-app/src/lib.rs index 31d6038..0c7d08c 100644 --- a/crates/namir-app/src/lib.rs +++ b/crates/namir-app/src/lib.rs @@ -30,6 +30,9 @@ //! //! - [`error_codes`] — this crate's own `ErrorCode` catalogue (D-16.1), for the FR-IO failure modes //! no existing crate's catalogue names (device open failure, xrun, stream loss). +//! - [`diagnostics`] — the only module in this crate outside [`host`] that names `namir-platform`'s +//! logger, so that [`app`] and [`audio_io`] can go on FR-ERR-030's audio-thread list. The same +//! `audio.rs` -> `shared.rs` split `namir-clap` already uses; see its own doc comment. //! - [`audio_io`] — D-13.1's trait plus the real `cpal` implementation. //! - [`device_state`] — FR-IO-010/040/080's pure selection logic: which device, sample rate and //! buffer size to use, given what the system reports and what was remembered. @@ -64,6 +67,7 @@ pub mod app; pub mod audio_io; pub mod bridge; pub mod device_state; +pub mod diagnostics; pub mod error_codes; pub mod host; pub mod instance; diff --git a/crates/namir-app/src/stream.rs b/crates/namir-app/src/stream.rs index c85a0f5..de5c29c 100644 --- a/crates/namir-app/src/stream.rs +++ b/crates/namir-app/src/stream.rs @@ -46,6 +46,8 @@ //! `docs/manual-tests/fr-io-090-channel-mapping.md`. use std::sync::Arc; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering}; use std::time::Duration; @@ -459,6 +461,27 @@ fn build_output( /// `pub(crate)`, for the same reason `namir_ui::host::RecordingHost` is: [`crate::app`]'s tests /// need an [`AudioBackend`] too, and a second, separately-drifting fake is worse than one shared /// one. +/// +/// # It is also FR-IO-070's failable virtual device (issue #24, §22 **R-5**) +/// +/// FR-IO-070's stated method is *"I with a virtual device that can be made to fail on demand"*, and +/// for two milestones no such device existed — the requirement's own apparatus was missing, which +/// is what issue #24 is about. It is this type, because this is where D-13.1's Namir-owned trait +/// already puts the seam: making a fake backend fail needs no OS device manipulation, no +/// `#[cfg(target_os)]` (which D-5.1 forbids outside `namir-platform` anyway) and no hardware, so +/// the method runs on a headless CI container exactly as it would on the reference machine. +/// +/// Two kinds of failure, matching the two halves of the requirement's first sentence: +/// +/// - **A device that fails to open** — [`FakeBackend::failing_to_open`], which makes that +/// direction's `build_*_stream` return [`AudioIoError::OpenFailed`]. +/// - **A device that fails while in use** — [`FakeBackend::input_error`]/ +/// [`FakeBackend::output_error`], the error callbacks captured since issue #88, fired by the +/// test at whatever point in the stream's life it chooses. +/// +/// What a stream then *did* is readable from [`FakeBackend::input_stream`]/ +/// [`FakeBackend::output_stream`]: FR-IO-070's "stop the stream cleanly" is not observable from a +/// backend that only records the callbacks it was handed. #[cfg(test)] pub(crate) struct FakeBackend { /// The input callback the last `build_input_stream` captured, for a test to drive directly. @@ -471,6 +494,16 @@ pub(crate) struct FakeBackend { pub(crate) input_error: std::sync::Mutex>, /// As [`FakeBackend::input_error`], for the playback direction. pub(crate) output_error: std::sync::Mutex>, + /// What the capture direction's stream was told to do, and whether it has been stopped — + /// FR-IO-070's "stop the stream cleanly" needs an observable, and the callbacks above are not + /// one. Shared with the [`FakeStream`] handed back by `build_input_stream`, so it survives that + /// stream being dropped, which is precisely the event it has to record. + pub(crate) input_stream: Arc, + /// As [`FakeBackend::input_stream`], for the playback direction. + pub(crate) output_stream: Arc, + /// Directions whose `build_*_stream` fails outright rather than returning a stream — the + /// open-failure half of FR-IO-070's fault injection. See [`FakeBackend::failing_to_open`]. + open_failures: Vec, /// Which device names answer [`ExclusiveModeOutcome::Engaged`] to /// `supports_exclusive`. Every other name answers `Unsupported` — what the real /// [`crate::audio_io::CpalBackend`] answers for any device with no exclusive-capable WASAPI @@ -492,6 +525,9 @@ impl FakeBackend { output_data: std::sync::Mutex::new(None), input_error: std::sync::Mutex::new(None), output_error: std::sync::Mutex::new(None), + input_stream: Arc::new(FakeStreamLog::default()), + output_stream: Arc::new(FakeStreamLog::default()), + open_failures: Vec::new(), exclusive_devices: Vec::new(), asked_share_modes: std::sync::Mutex::new(Vec::new()), } @@ -504,6 +540,37 @@ impl FakeBackend { self } + /// Makes `direction`'s `build_*_stream` fail rather than hand back a stream — FR-IO-070's + /// "a device failing to open". The message is the shape a real backend's is: something the + /// user can be shown, carried on [`AudioIoError::OpenFailed`]. + pub(crate) fn failing_to_open(mut self, direction: Direction) -> Self { + self.open_failures.push(direction); + self + } + + /// The log for one direction, so a test can name the direction it is asserting about rather + /// than remembering which field is which. + pub(crate) fn stream_log(&self, direction: Direction) -> &Arc { + match direction { + Direction::Input => &self.input_stream, + Direction::Output => &self.output_stream, + } + } + + /// `Err` when this direction was told to fail its open, `Ok(())` otherwise. + fn open_outcome(&self, direction: Direction) -> Result<(), AudioIoError> { + if self.open_failures.contains(&direction) { + let side = match direction { + Direction::Input => "input", + Direction::Output => "output", + }; + return Err(AudioIoError::OpenFailed(format!( + "the fake {side} device was told to fail on demand" + ))); + } + Ok(()) + } + /// The share mode `direction`'s stream was actually opened with, or `None` if that direction /// was never opened. pub(crate) fn share_mode_asked_for(&self, direction: Direction) -> Option { @@ -516,19 +583,59 @@ impl FakeBackend { } } +/// What one [`FakeStream`] was told to do, and whether it has been stopped. +/// +/// `stops` counts **drops**, not `pause` calls, because dropping is what stopping a stream *is* in +/// this crate: [`AudioStream`]'s own doc comment makes "dropping this stops the stream" the +/// contract every real implementation relies on rather than re-implements, and +/// [`RunningStreams`]'s drop is the mechanism [`crate::host::AppHost`] uses to honour FR-IO-070's +/// "stop the stream cleanly". Counting rather than flagging so a double stop is visible as a +/// count of 2 rather than indistinguishable from a single one. +#[cfg(test)] +#[derive(Default)] +pub(crate) struct FakeStreamLog { + plays: AtomicUsize, + pauses: AtomicUsize, + stops: AtomicUsize, +} + #[cfg(test)] -struct FakeStream; +impl FakeStreamLog { + pub(crate) fn plays(&self) -> usize { + self.plays.load(Ordering::Relaxed) + } + pub(crate) fn pauses(&self) -> usize { + self.pauses.load(Ordering::Relaxed) + } + pub(crate) fn stops(&self) -> usize { + self.stops.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +struct FakeStream { + log: Arc, +} #[cfg(test)] impl AudioStream for FakeStream { fn play(&self) -> Result<(), AudioIoError> { + self.log.plays.fetch_add(1, Ordering::Relaxed); Ok(()) } fn pause(&self) -> Result<(), AudioIoError> { + self.log.pauses.fetch_add(1, Ordering::Relaxed); Ok(()) } } +#[cfg(test)] +impl Drop for FakeStream { + fn drop(&mut self) { + self.log.stops.fetch_add(1, Ordering::Relaxed); + } +} + #[cfg(test)] pub(crate) type InputCallback = Box; #[cfg(test)] @@ -601,9 +708,15 @@ impl AudioBackend for FakeBackend { .lock() .unwrap() .push((Direction::Input, params.share_mode)); + // Before the callbacks are stored: a real backend that refuses the open never received + // them either, and a test asserting the teardown must not find a live callback behind a + // failed open. + self.open_outcome(Direction::Input)?; *self.input_data.lock().unwrap() = Some(on_data); *self.input_error.lock().unwrap() = Some(on_error); - Ok(Box::new(FakeStream)) + Ok(Box::new(FakeStream { + log: Arc::clone(&self.input_stream), + })) } fn build_output_stream( &self, @@ -618,9 +731,12 @@ impl AudioBackend for FakeBackend { .lock() .unwrap() .push((Direction::Output, params.share_mode)); + self.open_outcome(Direction::Output)?; *self.output_data.lock().unwrap() = Some(on_data); *self.output_error.lock().unwrap() = Some(on_error); - Ok(Box::new(FakeStream)) + Ok(Box::new(FakeStream { + log: Arc::clone(&self.output_stream), + })) } } diff --git a/docs/manual-tests/fr-io-070-device-removal.md b/docs/manual-tests/fr-io-070-device-removal.md index 45eb0c1..f2de22c 100644 --- a/docs/manual-tests/fr-io-070-device-removal.md +++ b/docs/manual-tests/fr-io-070-device-removal.md @@ -133,3 +133,69 @@ device (R-5's residual risk, unchanged) and step 3 because the capability it exe FR-IO-070 as a whole is therefore **not met**: the requirement's "allow the user to select another device" clause has no implementation, and its "report the condition" clause reports a condition that names neither the device nor the side. + +--- + +## Appended 2026-08-29 — the failable device now exists, and what it does and does not replace + +Recorded as an addition, not a re-verdict: **no verdict line above is edited**, and no manual step +was re-run this session. What changed is the apparatus, in response to GitHub issue #24 ("FR-IO-070's +stated verification apparatus — a failable virtual device — does not exist and has no owner"). + +**Route 1 of that issue is built.** `crates/namir-app/src/stream.rs`'s `FakeBackend` — already the +crate's no-hardware backend, and already capturing each direction's `cpal` error callback since +issue #88 — can now be made to fail on demand in both of the shapes FR-IO-070's first sentence +names: `FakeBackend::failing_to_open(direction)` refuses an open, and the captured +`input_error`/`output_error` callbacks let a test induce a failure *while the stream is in use*. +What a stream then did is readable from `FakeStreamLog` (plays, pauses and — the observable that +mattered — stops, counted as drops, since dropping is what stopping a stream is in this crate). + +This makes the requirement's stated method (`I` with a virtual device that can be made to fail on +demand) literally executable, on a headless container, on every merge. It needs no OS device +manipulation and no `#[cfg(target_os)]`, which D-5.1 forbids outside `namir-platform` anyway. + +**What is now automated, and where.** All three tests are in `crates/namir-app/src/host.rs`: + +1. `a_device_lost_mid_stream_is_reported_and_stops_both_streams_cleanly` — opens the duplex path + through the production `crate::stream::open`, plays it, drives four output callbacks so the + failure is genuinely mid-stream, then fires the output error callback with **this document's own + step-2 transcript verbatim**, as `StreamFailure::Other`, which is the shape it really arrived in. + Asserts: no crash or hang; exactly one `app.audio_io.device_lost` notice naming the side *and* + the device; and both directions' streams stopped exactly once, after the report and not before. +2. `a_device_that_fails_to_open_reports_and_leaves_no_half_open_stream` — step 1's condition, at + last inducible. The output open is refused after the input stream has been built, and what is + asserted is the teardown: the half-open capture stream is stopped rather than leaked, the + callbacks a refused open was handed are not retained, and the caller gets a reportable + `app.audio_io.device_open_failed` rather than a panic. +3. `after_a_loss_the_next_launch_selects_another_device` — the restart-mediated continuation. + +**Two behaviour changes came out of writing them**, both recorded here because this document's step +2 is the evidence for both: + +- `to_stream_failure` now maps `cpal::ErrorKind::StreamInvalidated` to `StreamFailure::DeviceLost`. + The note above says the unplug produced an error `cpal` had not classified. Reading the pinned + fork's own source says otherwise: its WASAPI `From` maps + `AUDCLNT_E_RESOURCES_INVALIDATED` — precisely the code in the transcript — onto + `ErrorKind::StreamInvalidated`, a kind Namir's `match` did not name. M14's message-substring + recovery rescued this particular case only because the message happened to carry the raw OS + number; the fork's `default_device_change_error` returns the same kind with *no message at all*, + which no substring can reach. +- **The stream is now actually stopped.** `app.audio_io.device_lost`'s catalogue text has said "the + stream was stopped" since M14 while nothing stopped it. `AppHost` owns the `RunningStreams` and + drops them on a device-loss classification — and on that classification only, since `cpal` + reports survivable conditions (`RealtimeDenied`, `DeviceChanged`) through the same callback. + +**What this does not replace.** Three things stay this document's, and step 1's and step 3's +**NOT EXECUTED** stand: + +- The virtual device is virtual. It proves Namir's response to a failure report; it proves nothing + about *what a real OS and a real driver do* on a physical removal — which is R-5's actual subject, + and why step 2's transcript above is still the only evidence of that and still worth re-running on + hardware. +- Step 1's real form (a real device refusing a real open — exclusive contention, a disabled + endpoint) is untouched: what is automated is Namir's teardown given a refusal, not any OS's + refusal. +- Step 3 is **unbuilt, not merely unexecuted**. There is still no device-selection surface in either + shell (issue #26, roadmap §15 item 16), so FR-IO-070's third clause has no implementation for any + test to reach. Test 3 above asserts the restart-mediated substitute and says so; the requirement's + `// trace-partial:` tag names this clause as its remaining gap and is deliberately not promoted. diff --git a/xtask/src/rt_logging.rs b/xtask/src/rt_logging.rs index ffa4ba7..307c9fa 100644 --- a/xtask/src/rt_logging.rs +++ b/xtask/src/rt_logging.rs @@ -108,6 +108,19 @@ pub const AUDIO_THREAD_MODULES: &[(&str, &str)] = &[ "crates/namir-app/src/xrun.rs", "is incremented from the output callback (`XrunCounter::record`)", ), + ( + "crates/namir-app/src/app.rs", + "owns `stream_failure_sink`, the closure `cpal` runs on the stream's own error thread", + ), + ( + "crates/namir-app/src/audio_io.rs", + "wraps every `cpal` callback, and classifies a failure inside the error one \ + (`to_stream_failure`)", + ), + ( + "crates/namir-app/src/audio_io/convert.rs", + "converts every sample format inside the two data callbacks", + ), ]; /// The identifiers an audio-thread module may not name. Whole-identifier matches, so a path is @@ -206,6 +219,48 @@ mod tests { assert_eq!(scan_logger_names(source), vec![(1, "record_verbose")]); } + /// **The list must not silently shrink.** `AUDIO_THREAD_MODULES` is hand-maintained (residual + /// blind spot 2), and its worst failure mode is not a false alarm but a quiet un-covering: a + /// module dropped from it stops being checked and nothing says so. `crate::main` already turns + /// an *unreadable* listed file into a violation; this pins the entries themselves, both shells' + /// callback-carrying modules together, so removing one is a test failure rather than a diff + /// nobody reads. + /// + /// The three `namir-app` entries beyond `stream.rs`/`bridge.rs`/`xrun.rs` were added at issue + /// #24's follow-up and are the reason this test exists: `app.rs` owns `stream_failure_sink`, + /// the closure `cpal` runs on the stream's own error thread, and it was outside the list for + /// as long as that closure has existed. + #[test] + fn every_module_known_to_carry_callback_code_is_listed() { + for expected in [ + "crates/namir-clap/src/audio.rs", + "crates/namir-clap/src/params_ext.rs", + "crates/namir-clap/src/param_mirror.rs", + "crates/namir-app/src/stream.rs", + "crates/namir-app/src/bridge.rs", + "crates/namir-app/src/xrun.rs", + "crates/namir-app/src/app.rs", + "crates/namir-app/src/audio_io.rs", + "crates/namir-app/src/audio_io/convert.rs", + ] { + assert!( + AUDIO_THREAD_MODULES.iter().any(|(rel, _)| *rel == expected), + "{expected} carries audio-thread code and must stay on FR-ERR-030's list" + ); + } + } + + /// Every entry carries a reason, and no path is listed twice -- a duplicate would report the + /// same violation twice and make the count meaningless. + #[test] + fn the_listed_modules_are_unique_and_each_says_why_it_is_listed() { + let mut seen = std::collections::BTreeSet::new(); + for (rel, why) in AUDIO_THREAD_MODULES { + assert!(seen.insert(*rel), "{rel} is listed twice"); + assert!(!why.trim().is_empty(), "{rel} is listed with no reason"); + } + } + #[test] fn a_comment_mentioning_the_logger_is_not_flagged() { // Load-bearing: this file's own module doc, and the doc comments the listed modules carry, From 1b632d48c148b3f5a1a8b6f0580f251770bcdfc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:54:31 +0000 Subject: [PATCH 27/44] Probe that block splitting changes nothing, and what it caught (#30) #30's sample-accuracy clause was already closed at M14: 9dbbe9d splits the block at each automation event's own frame. Verified rather than assumed -- reverting audio.rs to the pre-M14 shape turns two clap_host_automation tests red, one at six offsets including both boundaries. The design is right for a reason specific to this chain, worth recording: global.bypass is the parameter FR-CLAP-060 is about, and it is stepped. It routes to set_global_bypass, a bool, so there is no ramp to carry an offset into -- ramp-offset smoothing could not deliver the one parameter the requirement names. What was never asserted is the issue's own caveat, that sub-blocks must not reintroduce the starvation M9b fixed. No existing test could: the FR-CLAP-070 schedule runs with nothing loaded, so neither the resampler nor the convolver is in that comparison. This probe runs a full chain -- 44.1 kHz model in a 48 kHz engine, live resampler, 2048-tap IR -- whole-block against split, and measures bit-exact. A negative control confirms it would notice a single spliced sample. The probe was red on first run, and the cause was not the split. On a first load, a stage's output is bit-exactly the dry input for the whole 960-sample equal-power handover crossfade, and the wet signal appears only at the start of the block the fade completes in -- frame 512, 768, 896, 959 for block sizes 512, 256, 64, 1, on both NamStage and IrStage. So the fade FR-NAM-070/FR-IR-060 specify is inaudible on a first load, and what a user hears instead starts at a block-quantised instant: up to ~85 ms of jitter at 4096 frames, the same defect shape #30 describes for automation. Recorded in the test's doc comment; it wants its own issue. Still unmet, and not promoted: FR-CLAP-060's click-free limb. set_global_bypass flips a bool with no crossfade where FR-CHAIN-020 fades over 15 ms. The fix belongs in chain.rs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-engine/src/chain_probes.rs | 234 ++++++++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/crates/namir-engine/src/chain_probes.rs b/crates/namir-engine/src/chain_probes.rs index 76f402a..f621497 100644 --- a/crates/namir-engine/src/chain_probes.rs +++ b/crates/namir-engine/src/chain_probes.rs @@ -23,6 +23,7 @@ use crate::chain::Chain; use crate::param::ParamChange; use crate::prepare::PrepareContext; use crate::probe::{self, BLOCK, SR}; +use crate::rt_harness::audio_section; use crate::stage::{Stage, StagePrep}; use crate::stage_io::StageIo; use crate::stages::{self, build_default_chain}; @@ -829,3 +830,236 @@ fn bypass_compensation_tracks_the_latency_a_resampled_model_adds_at_runtime() { not tracking a latency that changed after `prepare_crosscutting` ran" ); } + +// --------------------------------------------------------------------------------------------- +// Issue #30 — the sub-block split the CLAP processor performs at every automation offset. +// --------------------------------------------------------------------------------------------- + +/// The block size both runs of the split probe declare to [`PrepareContext`] — so every stage's +/// scratch, and the IR convolver's whole partition schedule, are sized identically in both. Only +/// the *division* of those frames into `Chain::process` calls differs, which is exactly what the +/// split under test is. +const SPLIT_BLOCK: usize = 512; + +/// The offsets a block is cut at, cycled one per block. Deliberately the set +/// `namir-clap/tests/clap_host_automation.rs` places its automation event at, including both +/// boundaries a splitter can get wrong — `1`, one frame before the rest, and `SPLIT_BLOCK - 1`, +/// one frame after it — plus a mid-block value and offsets that are multiples of nothing in +/// particular. +/// +/// `0` is not among them: `namir-clap/src/audio.rs` skips a zero-length leading segment, so a +/// block cut at 0 is not cut at all and would contribute a block identical to the reference run's. +const SPLIT_OFFSETS: [usize; 5] = [1, 37, 256, 300, SPLIT_BLOCK - 1]; + +/// Runs `input` through `chain` in [`SPLIT_BLOCK`]-frame blocks, each divided into **two** +/// `Chain::process` calls at `SPLIT_OFFSETS[i % 5]` — the shape `namir-clap/src/audio.rs`'s event +/// split produces for a block carrying one automation point. +/// +/// Deliberately not a [`probe`] helper: that module's own doc comment asks a generator with one +/// caller to stay next to that caller until it has two. Like [`probe::run`], only `Chain::process` +/// is inside [`audio_section`], so every run doubles as NFR-RT-010 evidence that the split path +/// allocates nothing. +fn run_split(chain: &mut Chain, input: &[Vec], frames: usize) -> Vec> { + let mut out: Vec> = input.iter().map(|_| Vec::with_capacity(frames)).collect(); + let mut scratch: Vec> = input.iter().map(|_| vec![0.0f32; SPLIT_BLOCK]).collect(); + + let mut offset = 0; + let mut index = 0; + while offset < frames { + let whole = SPLIT_BLOCK.min(frames - offset); + let cut = SPLIT_OFFSETS[index % SPLIT_OFFSETS.len()].min(whole); + for n in [cut, whole - cut] { + if n == 0 { + continue; + } + for (channel, buf) in input.iter().zip(scratch.iter_mut()) { + buf[..n].copy_from_slice(&channel[offset..offset + n]); + } + { + let mut refs: Vec<&mut [f32]> = scratch.iter_mut().map(|b| &mut b[..n]).collect(); + let mut io = StageIo::new(&mut refs, n); + audio_section(|| chain.process(&mut io)); + } + for (buf, channel) in scratch.iter().zip(out.iter_mut()) { + channel.extend_from_slice(&buf[..n]); + } + offset += n; + } + index += 1; + } + out +} + +/// Frames of settling run through both chains, in whole [`SPLIT_BLOCK`] blocks, before either is +/// measured. **Load-bearing, and not a way of avoiding an inconvenient result** — see +/// [`splitting_a_block_the_way_host_automation_does_changes_nothing`]'s own doc comment for the +/// transient this excludes and for why it is a different question from the one that test asks. +/// 8 192 frames is 171 ms at 48 kHz, an order of magnitude past the 20 ms handover crossfade and +/// the 15 ms per-stage bypass blend that make it up. +const SPLIT_SETTLE_FRAMES: usize = 8_192; + +/// The loaded, settled chain both runs of the split probe drive, built the product way. +fn split_probe_chain(ctx: &PrepareContext) -> Chain { + let mut chain = build_default_chain(ctx).unwrap(); + // Well below the probe's own level, so the gate is open throughout and its envelope never + // becomes the thing being compared. + probe::set_param(&mut chain, gate::THRESHOLD_DB.id, -70.0); + // A model *declaring* 44.1 kHz in a 48 kHz engine: the one configuration that engages + // `stages/nam.rs`'s `SlotResampler`, which is the machinery issue #30 named. + probe::load_nam( + &mut chain, + probe::nam_model(WaveNetShape::Nano, 43, 44_100), + ctx, + ); + // A real IR, so `namir-ir`'s partitioned convolver — whose partition schedule is staggered in + // multiples of the *declared* block size — is inside the comparison rather than passed through. + probe::load_ir(&mut chain, probe::mono_ir(7, 2_048, SR, SPLIT_BLOCK), ctx); + let settle = probe::duplicated( + &probe::sine(SPLIT_SETTLE_FRAMES, 440.0, SR, 0.05), + ctx.channel_config().output_channels() as usize, + ); + let _ = probe::run(&mut chain, &settle, SPLIT_BLOCK); + chain +} + +/// **Issue #30's own caveat, asserted.** `namir-clap/src/audio.rs` now splits every block at each +/// automation event's `header().time()` (M14), so from the engine's side a host that automates +/// anything is a host that hands the same frames over in a *different division*. The issue named +/// the risk that carries — sub-blocks "must not reintroduce the starvation M9b just fixed", +/// `SlotResampler`'s output-FIFO priming being what makes its delay a property of the stream +/// rather than of the block-size history — and nothing checked it. +/// +/// Nothing could have. `namir-clap/tests/clap_host_block_sizes.rs` drives FR-CLAP-070's randomised +/// schedule through the real vtable but with **nothing loaded**, so neither the resampler nor the +/// convolver is in that comparison at all; `stages/nam.rs`'s own +/// `resampled_path_runs_many_varying_blocks_without_allocating_or_panicking` asserts finiteness, +/// which is what its own name says. This is the gap between the two, at the configuration where +/// both pieces of machinery are live. +/// +/// The assertion is that the division is *invisible*: one run in whole [`SPLIT_BLOCK`] blocks, one +/// with every block cut in two at a [`SPLIT_OFFSETS`] offset, **no parameter changed in either**, +/// compared sample for sample. Both chains are built from the same seeds and settled identically, +/// so the comparison isolates the division and nothing else. +/// +/// **Measured: 0 on both channels** — the two runs agree bitwise. The bound is nonetheless stated +/// as [`SPLIT_TOLERANCE`] rather than `==`, for the reason `clap_host_block_sizes.rs` gives for +/// the same choice: the first stage whose summation order legitimately depends on the block length +/// would fail an equality assertion for something that is not a defect. A starved resampler +/// splices whole samples of silence and lands three orders above the bound; the observed maximum +/// is carried into the failure message so a drift from 0 is legible rather than absorbed. +/// +/// # The transient this deliberately does not measure, and why it is a different question +/// +/// [`SPLIT_SETTLE_FRAMES`] is not padding. Inside the ~20 ms after a resource is installed, this +/// chain's output *does* depend on the block division: measured at 1.3e-2 (Nam) and 7.2e-2 (Ir) +/// against settled peaks of ~1.2e-1 and ~5.1e-1, decaying to 1.9e-4 and 9.3e-4 over the +/// following 4 000 frames. That is **not** the split's doing and not new — it reproduces exactly under +/// [`probe::run`] alone at 512 against 256, 128 and 64 frames, with no sub-block anywhere — and +/// its mechanism is upstream of this file. On a *first* load, both stages' output stays +/// **bit-exactly the dry input** for the whole 960-sample equal-power handover crossfade, and the +/// wet signal first appears at the start of the block the fade completes in: measured at frame +/// 512, 768, 896 and 959 for block sizes 512, 256, 64 and 1, identically for Nam and for Ir. So +/// what a first load actually sounds like is the 15 ms per-stage bypass blend starting at a +/// block-quantised instant, with the equal-power fade masked behind it. Recorded here because +/// this is the probe that found it; it belongs to the handover path (`stages/nam.rs`, +/// `stages/ir.rs`), not to issue #30, and is reported rather than fixed here. +#[test] +fn splitting_a_block_the_way_host_automation_does_changes_nothing() { + const FRAMES: usize = 16_384; + /// Two orders above f32 accumulation over this signal and three below any real + /// block-dependency defect — see this test's own doc comment. + const SPLIT_TOLERANCE: f32 = 1e-6; + + let ctx = probe::ctx_at(SR, SPLIT_BLOCK, ChannelConfig::Stereo); + let signal = probe::chirp(FRAMES, 200.0, 6_000.0, SR, 0.05); + let input = probe::duplicated(&signal, 2); + + let mut whole_chain = split_probe_chain(&ctx); + let whole = probe::run(&mut whole_chain, &input, SPLIT_BLOCK); + let reported = whole_chain.latency_samples(); + + let mut split_chain = split_probe_chain(&ctx); + let split = run_split(&mut split_chain, &input, FRAMES); + + assert!( + reported > 0, + "the resampler never engaged, so this probe drove the one path it was written for as a \ + plain passthrough" + ); + assert_eq!( + split_chain.latency_samples(), + reported, + "the two runs report different latencies, so they are not the same chain" + ); + + for (channel, (whole, split)) in whole.iter().zip(split.iter()).enumerate() { + assert_eq!( + whole.len(), + FRAMES, + "channel {channel}: short reference run" + ); + assert_eq!(split.len(), FRAMES, "channel {channel}: short split run"); + + // Non-vacuous: two silent buffers would compare equal and prove nothing. + let level = probe::peak(whole); + assert!( + level > 1e-3, + "channel {channel}: the reference run produced no signal ({level:e}) to compare \ + against" + ); + + let (at, difference) = whole + .iter() + .zip(split.iter()) + .map(|(a, b)| (a - b).abs()) + .enumerate() + .fold( + (0usize, 0.0f32), + |acc, (i, d)| { + if d > acc.1 { (i, d) } else { acc } + }, + ); + assert!( + difference <= SPLIT_TOLERANCE, + "channel {channel}: cutting each block in two moved the output by {difference:e} at \ + frame {at}, against a peak of {level:e}. The division of a block into `process` \ + calls is host-driven — every automation event splits one — so a stage sensitive to \ + it renders differently depending on what the user automates" + ); + } +} + +/// The negative control for the probe above, which would otherwise be satisfiable by a comparison +/// too blunt to see anything. +/// +/// The same two runs, with the split run compared against the reference **shifted by one sample** — +/// the smallest displacement a starved resampler produces, and well inside the 32 to 63 spliced +/// samples M9b actually measured. If the comparison could not tell an aligned signal from a +/// one-sample-shifted one, the bound above would hold on a chain that had lost samples. +#[test] +fn the_split_probe_would_notice_a_single_spliced_sample() { + const FRAMES: usize = 4_096; + + let ctx = probe::ctx_at(SR, SPLIT_BLOCK, ChannelConfig::Stereo); + let signal = probe::chirp(FRAMES, 200.0, 6_000.0, SR, 0.05); + let input = probe::duplicated(&signal, 2); + + let mut whole_chain = split_probe_chain(&ctx); + let whole = probe::run(&mut whole_chain, &input, SPLIT_BLOCK); + + let mut split_chain = split_probe_chain(&ctx); + let split = run_split(&mut split_chain, &input, FRAMES); + + let shifted = whole[0] + .iter() + .zip(split[0].iter().skip(1)) + .map(|(a, b)| (a - b).abs()) + .fold(0.0f32, f32::max); + let level = probe::peak(&whole[0]); + assert!( + shifted > level * 0.01, + "shifting the comparison by one sample moves it only {shifted:e} against a peak of \ + {level:e}, so the equality the probe above asserts cannot tell an aligned run from one \ + that spliced a sample of silence into the stream" + ); +} From 0b3e3885e80c0914b73c44377b9ddd1c62f408b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:02:02 +0000 Subject: [PATCH 28/44] Read compound Verify methods, and build FR-STATE-040's missing half (#27) The gate could only ever see one Verify code, so a compound method was structurally invisible: FR-STATE-040 read fully covered on its manual document while the S half had no artifact at all. The parser now reads a code set, and the method text is the marker's line plus its continuations -- required, not cosmetic, since NFR-RT-010's second code sits on a wrapped line. build_report looks up each evidence class separately (manual document for M, source annotation for U/I/G/B/S) and requires all of them. Eight compound Musts, not the six the issue lists: it missed FR-CHAIN-020, FR-IN-020, FR-ERR-070 and NFR-RT-010. The grammar is deliberately narrow. NFR-PERF-010's "B, as a CI regression gate" and NFR-RT-020's "S plus code review" stay single-code -- reading a qualifier as a second code would invent codes the FRS never wrote. Two demotions, both correct and neither papered over. FR-STATE-040 is re-covered by the schema validator built here. FR-IN-020 is now uncovered because no manual document exists for its display half -- which its own uncovered: field has said in prose all along. The tool now agrees with the tag's author. The validator restates section 3-7 from the prose rather than delegating to the reader, which would agree with it by construction. It already disagrees in one real place: 7.3 says a stored library_relative is always /-separated, while RelPath::parse accepts and normalises a backslash. Asserted as a deliberate divergence. Two tests parse 7.1's and 7.2's own field tables and assert they name exactly what the validator checks, which closes the drift residue a hand-written schema check otherwise carries. Still owed, outside this change: a CI step running xtask schema, with the README line xtask ci-commands requires in the same commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-state/src/document.rs | 11 + crates/namir-state/src/lib.rs | 9 +- crates/namir-state/src/schema.rs | 787 +++++++++++++++++++++++++++++ crates/namir-state/tests/schema.rs | 308 +++++++++++ xtask/src/main.rs | 75 ++- xtask/src/preset.rs | 8 + xtask/src/schema.rs | 172 +++++++ xtask/src/traceability.rs | 490 ++++++++++++++---- 8 files changed, 1752 insertions(+), 108 deletions(-) create mode 100644 crates/namir-state/src/schema.rs create mode 100644 crates/namir-state/tests/schema.rs create mode 100644 xtask/src/schema.rs diff --git a/crates/namir-state/src/document.rs b/crates/namir-state/src/document.rs index a093b87..6ad969c 100644 --- a/crates/namir-state/src/document.rs +++ b/crates/namir-state/src/document.rs @@ -167,6 +167,17 @@ impl Document { self.root.get(key)?.as_object() } + /// The raw value of a top-level key, whatever its JSON type — unlike [`Self::section`], which + /// collapses "absent" and "present but not an object" into one `None`. [`crate::schema`] is + /// the caller that needs them apart, and it is the caller `section`'s own doc comment + /// anticipated: an absent `parameters` key is the documented, legal minimal document (§3 of + /// `docs/04-state-and-preset-format.md`), where a `parameters` key holding a string is a + /// schema violation to report. Every other caller in this crate is loading tolerantly and + /// genuinely does treat the two the same. + pub(crate) fn top_level(&self, key: &str) -> Option<&Value> { + self.root.get(key) + } + /// Replaces (or creates) a named top-level section wholesale, discarding whatever was there /// before. Correct only when there is nothing worth keeping — building a section from a /// document that started empty ([`Self::empty`], [`State::into_document`](crate::State)) is diff --git a/crates/namir-state/src/lib.rs b/crates/namir-state/src/lib.rs index 186f16d..6d0bc93 100644 --- a/crates/namir-state/src/lib.rs +++ b/crates/namir-state/src/lib.rs @@ -20,7 +20,10 @@ //! - FR-STATE-020 — [`ParamValues`]'s complete-array-over-`REGISTRY` shape, so an absent //! parameter's default cannot fail to apply. //! - FR-STATE-040 — the JSON format itself: pretty-printed, sorted, and, via [`reference::RelPath`], -//! free of platform-specific path syntax in what it stores. +//! free of platform-specific path syntax in what it stores; plus, since M15, the **schema check** +//! the second half of that requirement's compound `*Verify:*` method names — [`schema`], a +//! validator for §§3–7 of `docs/04-state-and-preset-format.md` written independently of this +//! crate's own reader (issue #27). //! - FR-STATE-070 — [`resolve::candidates`]/[`resolve::FileResolver`]/[`resolve::resolve`]: the //! three-step resolution order as data plus the port a resolving crate implements, and — since //! issue #113 — §7.4 of `docs/04-state-and-preset-format.md`'s fourth step, the embedded copy, @@ -50,6 +53,7 @@ mod migrate; mod params; mod reference; mod resolve; +mod schema; mod state; pub use document::{Document, FORMAT_VERSION, MAX_DOCUMENT_BYTES}; @@ -60,4 +64,7 @@ pub use resolve::{ Candidate, FileResolver, MissingFile, Resolution, ResolvedFile, ResolvedVia, candidates, resolve, }; +pub use schema::{ + EMBEDDED_FIELDS, FILE_REFERENCE_FIELDS, SchemaViolation, Severity, validate, validate_bytes, +}; pub use state::State; diff --git a/crates/namir-state/src/schema.rs b/crates/namir-state/src/schema.rs new file mode 100644 index 0000000..2045004 --- /dev/null +++ b/crates/namir-state/src/schema.rs @@ -0,0 +1,787 @@ +//! FR-STATE-040's `S` half: the **schema check** its `*Verify:*` line names, over the format +//! `docs/04-state-and-preset-format.md` §§3–7 documents. +//! +//! # Why this module exists +//! +//! FR-STATE-040's method is compound — `M plus S (schema check)` — and until M15 only the `M` half +//! existed. `xtask traceability` kept the first code of a `*Verify:*` line and no more, so the +//! requirement resolved from its manual-test document alone and read fully covered while the `S` +//! half was executed by nothing anywhere in the tree (issue #27; FRS §5.9's +//! `*Consequence (added M14, 2026-08-12)*` note is the decision that this is built rather than the +//! FRS line narrowed, and it is that note that nominates §§3–7 as the schema, the FRS itself not +//! saying what the schema is). +//! +//! # What it checks, and against what +//! +//! §§3–7 of the format document, clause by clause: §3's top-level structure, §4's `format_version`, +//! §5's legacy `global` section, §6's `parameters`, and §7's `references` including §7.1's file +//! reference shape, §7.2's `embedded` and §7.3's `library_relative` syntax. §2 (encoding and byte +//! ceiling) is enforced by [`Document::parse`] before this module ever sees a document, and §8 +//! (unknown-field preservation) is a property of a load-modify-save *cycle* rather than of a +//! document, which is why FR-STATE-010's round-trip test is where that lives. +//! +//! **The rules are restated here from the prose, deliberately, rather than delegated to the +//! reader's own parsing code.** A validator built out of `FileRef::from_value` and +//! `RelPath::parse` would check the reader against itself and agree by construction — the same +//! objection D-23.1's second question makes to a `Verify: G` satisfied by a second in-house +//! implementation. Restating them independently is what lets this module disagree with the reader, +//! and it already does in one place: §7.3 says a stored `library_relative` is "always +//! `/`-separated ... regardless of which platform wrote it", while [`crate::RelPath::parse`] +//! accepts a backslash-separated string and normalises it. The reader is right to be tolerant and +//! the document is right about what conforms, so a stored backslash is reported here as a +//! [`Severity::Recovered`] violation rather than silently blessed. +//! +//! # Severity: what the *reader* does, not how bad it is +//! +//! [`Severity::Rejected`] means the format document says a reader refuses the whole document over +//! this — §4's `format_version` is the only such clause, "the one thing this format treats as +//! fatal rather than tolerated". Everything else is [`Severity::Recovered`]: the value is off-schema +//! and the documented reader behaviour is to carry on with a default, a clamp, or the reference +//! treated as absent (D-11.2's tolerant deserialisation). A `Recovered` violation is still a +//! violation — it is exactly the class a hand-editor (FR-STATE-040's whole point) produces and +//! never hears about otherwise, because tolerant loading is silent by design. + +use serde_json::Value; + +use crate::document::Document; +use crate::reference::MAX_EMBEDDED_BYTES; + +/// What the documented reader does with a document carrying this violation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + /// The format document says the whole document is refused (§4 only). + Rejected, + /// The value is off-schema and the reader recovers locally — a default, a clamp, or the + /// reference treated as absent. + Recovered, +} + +/// One clause of §§3–7 that a document does not satisfy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SchemaViolation { + /// Where, as a JSON-pointer-style path from the document root (`/references/nam/hash`). + /// Built for a human reading a validation report; not parsed by anything. + pub pointer: String, + /// The section of `docs/04-state-and-preset-format.md` the violated clause is in (`"7.1"`), + /// so a report says which prose to go and read. + pub section: &'static str, + /// What is wrong, in the format document's own terms. + pub message: String, + /// What the documented reader does about it. + pub severity: Severity, +} + +impl std::fmt::Display for SchemaViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (§{}): {}", self.pointer, self.section, self.message) + } +} + +/// §7.1's file-reference fields and whether each is required, and §7.2's `embedded` fields the +/// same — the inventories [`validate`] checks against, made public so that the transcription can +/// be held up against the format document's own tables rather than trusted. +/// +/// This is the one residue a hand-written schema check has that a generated one would not: these +/// rules are prose in `docs/04-state-and-preset-format.md` and code here, and nothing about the +/// two makes them agree. `tests/schema.rs` parses the `| Field | Required | ... |` tables of §7.1 +/// and §7.2 and asserts that they name exactly these fields with exactly these required flags, so +/// a field added to the format document without being added here fails a test rather than +/// silently leaving a clause unchecked. +pub const FILE_REFERENCE_FIELDS: &[(&str, bool)] = &[ + ("hash", true), + ("library_relative", false), + ("absolute", false), + ("display_name", false), + ("embedded", false), +]; + +/// §7.2's `embedded` fields. See [`FILE_REFERENCE_FIELDS`]. +pub const EMBEDDED_FIELDS: &[(&str, bool)] = + &[("encoding", true), ("media_type", false), ("data", true)]; + +/// Every §§3–7 clause `document` violates, in document order (which is sorted-key order — see +/// [`Document`]'s own note on why this workspace never enables `serde_json`'s `preserve_order`). +/// An empty result is the document conforming. +/// +/// Deliberately a **list**, not a `Result`: a hand-edited preset with three mistakes in it should +/// report three, the way `xtask identity` and `xtask bundle` report lists for the same reason. And +/// deliberately total — it never stops early, so a `Rejected` `format_version` does not hide a +/// malformed reference underneath it. +pub fn validate(document: &Document) -> Vec { + let mut out = Vec::new(); + check_format_version(document, &mut out); + check_object_shaped_sections(document, &mut out); + check_legacy_global(document, &mut out); + check_parameters(document, &mut out); + check_references(document, &mut out); + out +} + +/// [`validate`] over raw bytes: §2's byte ceiling and "the top level is a JSON object" are +/// [`Document::parse`]'s, and are reported as the `Err` they already are rather than restated as +/// violations — a document that is not JSON has no schema to check. +pub fn validate_bytes(bytes: &[u8]) -> Result, crate::StateError> { + Ok(validate(&Document::parse(bytes)?)) +} + +fn violation( + pointer: &str, + section: &'static str, + severity: Severity, + message: impl Into, +) -> SchemaViolation { + SchemaViolation { + pointer: pointer.to_string(), + section, + message: message.into(), + severity, + } +} + +/// §4: "An unsigned integer, required. | Absent, or present but not an integer | **Rejected +/// outright**". The one `Rejected` clause in the format. +fn check_format_version(document: &Document, out: &mut Vec) { + match document.top_level("format_version") { + None => out.push(violation( + "/format_version", + "4", + Severity::Rejected, + "required, and absent -- there is no defensible default for \"which schema is this\"", + )), + Some(value) if value.as_u64().is_none() => out.push(violation( + "/format_version", + "4", + Severity::Rejected, + format!( + "must be an unsigned integer, found {}", + json_type_name(value) + ), + )), + Some(_) => {} + } + // A version *greater* than this build's is explicitly not a violation: §4 loads it tolerantly + // with a warning, and §8's unknown-field preservation is what makes that safe. The corpus's + // `future-version.namirpreset` is that case, and it conforms. +} + +/// §3: each of the three keys this build owns, plus §5's legacy `global`, is an object when it is +/// present at all. Every one of them is optional — §3's minimal document is `{"format_version": +/// 1}` and nothing else — and any *other* top-level key is legal by §3's own sentence ("A document +/// may carry other top-level keys ... This build preserves them byte-identically"), so unknown +/// keys are not checked here and must not be. +fn check_object_shaped_sections(document: &Document, out: &mut Vec) { + for (key, section) in [("parameters", "6"), ("references", "7"), ("global", "5")] { + if let Some(value) = document.top_level(key) + && !value.is_object() + { + out.push(violation( + &format!("/{key}"), + section, + Severity::Recovered, + format!( + "must be a JSON object, found {} -- a reader reads nothing out of it and \ + applies defaults", + json_type_name(value) + ), + )); + } + } +} + +/// §5's legacy shape, `"global": { "bypass": false, "output_ceiling_db": 0.0 }`. A current writer +/// never emits it and a current reader still accepts it, so a document carrying one is conforming +/// — but its two fields have documented types, and "the field is ... wrongly-typed" is a case §5 +/// itself calls out as falling back to the default. Any *other* key inside `global` is a §8 +/// unrecognised key: preserved, not applied, not a violation. +fn check_legacy_global(document: &Document, out: &mut Vec) { + let Some(global) = document.top_level("global").and_then(Value::as_object) else { + return; + }; + if let Some(value) = global.get("bypass") + && !value.is_boolean() + { + out.push(violation( + "/global/bypass", + "5", + Severity::Recovered, + format!( + "legacy section: must be a boolean, found {} -- falls back to `false`", + json_type_name(value) + ), + )); + } + if let Some(value) = global.get("output_ceiling_db") + && !value.is_number() + { + out.push(violation( + "/global/output_ceiling_db", + "5", + Severity::Recovered, + format!( + "legacy section: must be a number, found {} -- falls back to `0.0`", + json_type_name(value) + ), + )); + } +} + +/// §6: "A flat JSON object mapping a **stable string key** ... to a number." +/// +/// Only the *value* type is checked. An unrecognised key is explicitly legal ("preserved ... but +/// not applied to anything"), and an out-of-range value is explicitly legal too ("clamped into +/// range" — a conforming document, a value a reader adjusts). What is not legal is a value that is +/// not a number at all, which §6 says "resets that one parameter to its default": the reader +/// recovers, and the document is still off-schema. +fn check_parameters(document: &Document, out: &mut Vec) { + let Some(parameters) = document.top_level("parameters").and_then(Value::as_object) else { + return; + }; + for (key, value) in parameters { + if !value.is_number() { + out.push(violation( + &format!("/parameters/{key}"), + "6", + Severity::Recovered, + format!( + "must be a number in the parameter's own physical unit, found {} -- resets \ + this one parameter to its default", + json_type_name(value) + ), + )); + } + } +} + +/// §7: up to two keys, `nam` and `ir`, each a §7.1 file reference. +/// +/// A key beside those two is **not** reported. §7's own sentence says "up to two keys", but +/// `Document::remove_from_section`'s contract is explicit that "an unrecognised key sitting +/// alongside `nam`/`ir` inside `references` survives a save that clears one of them" — that is +/// §8's second guarantee, and a carrier this build deliberately keeps. Reporting it would be +/// reporting a document for using a facility the format promises it. +fn check_references(document: &Document, out: &mut Vec) { + let Some(references) = document.top_level("references").and_then(Value::as_object) else { + return; + }; + for slot in ["ir", "nam"] { + let Some(value) = references.get(slot) else { + continue; + }; + let pointer = format!("/references/{slot}"); + let Some(reference) = value.as_object() else { + out.push(violation( + &pointer, + "7.1", + Severity::Recovered, + format!( + "must be a file reference object, found {} -- the stage loads empty", + json_type_name(value) + ), + )); + continue; + }; + check_hash(&pointer, reference.get("hash"), out); + check_library_relative(&pointer, reference.get("library_relative"), out); + for (field, section) in [("absolute", "7.1"), ("display_name", "7.1")] { + if let Some(value) = reference.get(field) + && !value.is_string() + { + out.push(violation( + &format!("{pointer}/{field}"), + section, + Severity::Recovered, + format!("must be a string, found {}", json_type_name(value)), + )); + } + } + check_embedded(&pointer, reference.get("embedded"), out); + } +} + +/// §7.1: `hash` is the one **required** field of a file reference — "string, 64 lowercase hex +/// characters", the reference's identity (P7). "If `hash` is missing, or is present but is not a +/// well-formed 64-hex-character string, the whole reference is malformed: this build's reader +/// treats it as absent ... with a warning, rather than failing the whole document over one bad +/// reference." +fn check_hash(pointer: &str, value: Option<&Value>, out: &mut Vec) { + let where_ = format!("{pointer}/hash"); + let Some(value) = value else { + out.push(violation( + &where_, + "7.1", + Severity::Recovered, + "required, and absent -- the whole reference is malformed and the stage loads empty", + )); + return; + }; + let Some(text) = value.as_str() else { + out.push(violation( + &where_, + "7.1", + Severity::Recovered, + format!( + "must be a 64-character lowercase hex string, found {}", + json_type_name(value) + ), + )); + return; + }; + // Restated from §7.1 rather than delegated to `ContentHash`'s own parser, so that this check + // can disagree with the reader instead of agreeing with it by construction. + let well_formed = text.len() == 64 + && text + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + if !well_formed { + out.push(violation( + &where_, + "7.1", + Severity::Recovered, + format!( + "must be exactly 64 lowercase hex characters, found {} character(s) -- the whole \ + reference is malformed and the stage loads empty", + text.chars().count() + ), + )); + } +} + +/// §7.3's four rules for a stored `library_relative`, plus §7.3's opening sentence. +/// +/// The opening sentence — "Always `/`-separated in the stored document, regardless of which +/// platform wrote it" — is the clause this module and [`crate::RelPath::parse`] disagree about, +/// and the disagreement is deliberate (see this module's header). A backslash in a stored path is +/// off-schema; the reader normalises it and carries on, so the severity is `Recovered`. +fn check_library_relative(pointer: &str, value: Option<&Value>, out: &mut Vec) { + let Some(value) = value else { + return; + }; + let where_ = format!("{pointer}/library_relative"); + let Some(text) = value.as_str() else { + out.push(violation( + &where_, + "7.3", + Severity::Recovered, + format!( + "must be a `/`-separated string, found {}", + json_type_name(value) + ), + )); + return; + }; + let mut say = |message: &str| { + out.push(violation(&where_, "7.3", Severity::Recovered, message)); + }; + if text.is_empty() { + say("must be non-empty"); + return; + } + if text.contains('\\') { + say( + "must be `/`-separated in the stored document regardless of which platform wrote it; \ + a `\\` is platform syntax the format never stores (this build's reader normalises it \ + anyway)", + ); + } + if text.starts_with('/') { + say("must not be rooted -- an absolute path belongs in `absolute` instead"); + } + if text.len() >= 2 && text.as_bytes()[1] == b':' { + say("must not be drive-prefixed -- an absolute path belongs in `absolute` instead"); + } + for segment in text.split(['/', '\\']) { + match segment { + "" => say("must contain no empty segment"), + "." => say("must contain no `.` segment"), + ".." => say("must contain no `..` segment -- no traversal"), + _ => {} + } + } +} + +/// §7.2: `encoding` is required and is always `"base64"` ("Any other value is rejected"), `data` +/// is required and is the base64 text, `media_type` is optional and informational. The encoded +/// text is bounded by §2's ceiling "checked against the encoded string's own length, before any +/// base64 decoding happens". +fn check_embedded(pointer: &str, value: Option<&Value>, out: &mut Vec) { + let Some(value) = value else { + return; + }; + let where_ = format!("{pointer}/embedded"); + let Some(embedded) = value.as_object() else { + out.push(violation( + &where_, + "7.2", + Severity::Recovered, + format!("must be an object, found {}", json_type_name(value)), + )); + return; + }; + + match embedded.get("encoding") { + None => out.push(violation( + &format!("{where_}/encoding"), + "7.2", + Severity::Recovered, + "required, and absent -- `\"base64\"` is the only encoding this format defines", + )), + Some(Value::String(text)) if text == "base64" => {} + Some(Value::String(text)) => out.push(violation( + &format!("{where_}/encoding"), + "7.2", + Severity::Recovered, + format!( + "`\"{text}\"` is rejected -- `\"base64\"` is the only encoding this format defines" + ), + )), + Some(other) => out.push(violation( + &format!("{where_}/encoding"), + "7.2", + Severity::Recovered, + format!( + "must be the string `\"base64\"`, found {}", + json_type_name(other) + ), + )), + } + + match embedded.get("data") { + None => out.push(violation( + &format!("{where_}/data"), + "7.2", + Severity::Recovered, + "required, and absent -- an `embedded` block with no data carries nothing", + )), + Some(Value::String(text)) => { + if text.len() > MAX_EMBEDDED_BYTES { + out.push(violation( + &format!("{where_}/data"), + "7.2", + Severity::Recovered, + format!( + "is {} encoded bytes, over the {} MB ceiling §2 puts on the whole \ + document -- checked on the encoded length, before any decoding", + text.len(), + MAX_EMBEDDED_BYTES / (1024 * 1024) + ), + )); + } else if !is_standard_base64(text) { + out.push(violation( + &format!("{where_}/data"), + "7.2", + Severity::Recovered, + "must be base64 in the standard alphabet, with padding", + )); + } + } + Some(other) => out.push(violation( + &format!("{where_}/data"), + "7.2", + Severity::Recovered, + format!("must be a base64 string, found {}", json_type_name(other)), + )), + } + + if let Some(value) = embedded.get("media_type") + && !value.is_string() + { + out.push(violation( + &format!("{where_}/media_type"), + "7.2", + Severity::Recovered, + format!( + "must be a string when present, found {} -- informational only", + json_type_name(value) + ), + )); + } +} + +/// §7.2's "standard alphabet, with padding", spelled out here for the same reason §7.1's hex rule +/// is: a check that called the crate's own decoder would agree with the decoder rather than with +/// the document. Length a multiple of four, `=` only as one or two trailing characters, every +/// other character in `A-Za-z0-9+/`. +fn is_standard_base64(text: &str) -> bool { + if !text.len().is_multiple_of(4) { + return false; + } + let body = text.trim_end_matches('='); + if text.len() - body.len() > 2 { + return false; + } + body.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') +} + +fn json_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "an array", + Value::Object(_) => "an object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn validate_text(text: &str) -> Vec { + validate_bytes(text.as_bytes()).expect("the fixture is a JSON object within §2's ceiling") + } + + fn pointers(violations: &[SchemaViolation]) -> Vec<&str> { + violations.iter().map(|v| v.pointer.as_str()).collect() + } + + #[test] + fn the_minimal_document_of_section_3_conforms() { + assert_eq!(validate_text(r#"{"format_version": 1}"#), Vec::new()); + } + + #[test] + fn a_document_carrying_every_section_conforms() { + let violations = validate_text( + r#"{ + "format_version": 1, + "global": { "bypass": true, "output_ceiling_db": -6.0 }, + "parameters": { "trim.gain_db": 2.5, "global.bypass": 0.0 }, + "references": { + "nam": { + "hash": "dc57749e025523f24f989853b68405829607c4c84942579df0c3368694a531e3", + "library_relative": "marshall/plexi.nam", + "absolute": "C:\\Users\\erwan\\Models\\plexi.nam", + "display_name": "plexi.nam", + "embedded": { + "encoding": "base64", + "media_type": "application/vnd.namir.nam+json", + "data": "eyJmYWtlIjoxfQ==" + } + } + } + }"#, + ); + assert_eq!(violations, Vec::new()); + } + + /// §3's "A document may carry other top-level keys ... preserved byte-identically", and §6's + /// "A key this reader does not recognise is preserved ... but not applied". Neither is a + /// violation, and a validator that reported them would be arguing with the format. + #[test] + fn unrecognised_keys_are_not_violations_at_either_level() { + let violations = validate_text( + r#"{ + "format_version": 1, + "future_top_level_section": { "nested": { "deeper": [1, 2, 3] } }, + "parameters": { "comp.ratio": 4.0 }, + "references": { "future_slot": { "whatever": true } } + }"#, + ); + assert_eq!(violations, Vec::new()); + } + + /// §4's table, both rows of it: absent and present-but-not-an-integer, and both `Rejected` -- + /// the only clause in §§3–7 that is. + #[test] + fn a_missing_or_non_integer_format_version_is_the_one_rejecting_clause() { + for text in [ + r#"{"parameters": {}}"#, + r#"{"format_version": "1"}"#, + r#"{"format_version": 1.5}"#, + r#"{"format_version": -1}"#, + ] { + let violations = validate_text(text); + assert_eq!(pointers(&violations), ["/format_version"], "{text}"); + assert_eq!(violations[0].severity, Severity::Rejected, "{text}"); + } + } + + /// §4: "Greater than this build's version | Loaded **tolerantly**, with a warning." A newer + /// document conforms; the corpus's `future-version.namirpreset` is this case. + #[test] + fn a_newer_format_version_conforms() { + assert_eq!(validate_text(r#"{"format_version": 2}"#), Vec::new()); + } + + #[test] + fn a_section_that_is_not_an_object_is_reported_per_section() { + let violations = validate_text( + r#"{"format_version": 1, "parameters": 3, "references": "none", "global": []}"#, + ); + assert_eq!( + pointers(&violations), + ["/parameters", "/references", "/global"] + ); + assert!(violations.iter().all(|v| v.severity == Severity::Recovered)); + } + + /// §6: the value type, and only the value type. An out-of-range value is a *conforming* + /// document the reader clamps, so it must not be reported. + #[test] + fn a_parameter_value_must_be_a_number_and_may_be_out_of_range() { + let violations = validate_text( + r#"{"format_version": 1, + "parameters": {"a.out_of_range": 1e9, "b.string": "0.5", "c.null": null}}"#, + ); + assert_eq!( + pointers(&violations), + ["/parameters/b.string", "/parameters/c.null"] + ); + } + + #[test] + fn the_legacy_global_sections_two_fields_have_documented_types() { + let violations = validate_text( + r#"{"format_version": 1, "global": {"bypass": 1, "output_ceiling_db": "0"}}"#, + ); + assert_eq!( + pointers(&violations), + ["/global/bypass", "/global/output_ceiling_db"] + ); + } + + /// §7.1's one required field, in all three of its failing shapes. + #[test] + fn a_reference_without_a_well_formed_hash_is_malformed() { + for (text, expected) in [ + (r#"{"library_relative": "a/b.nam"}"#, "required, and absent"), + (r#"{"hash": 7}"#, "found a number"), + (r#"{"hash": "abc"}"#, "found 3 character(s)"), + ( + // Uppercase hex: 64 characters, and §7.1 says lowercase. + r#"{"hash": "DC57749E025523F24F989853B68405829607C4C84942579DF0C3368694A531E3"}"#, + "64 lowercase hex characters", + ), + ] { + let violations = validate_text(&format!( + r#"{{"format_version": 1, "references": {{"nam": {text}}}}}"# + )); + assert_eq!(pointers(&violations), ["/references/nam/hash"], "{text}"); + assert!( + violations[0].message.contains(expected), + "{text}: {}", + violations[0] + ); + } + } + + #[test] + fn a_reference_that_is_not_an_object_is_reported_once() { + let violations = + validate_text(r#"{"format_version": 1, "references": {"ir": "1960a.wav"}}"#); + assert_eq!(pointers(&violations), ["/references/ir"]); + } + + /// §7.3's four rules. Each fixture carries a well-formed hash so the only thing under test is + /// the path. + #[test] + fn library_relative_follows_section_7_3s_path_syntax() { + const HASH: &str = "dc57749e025523f24f989853b68405829607c4c84942579df0c3368694a531e3"; + for (path, expected) in [ + ("", "non-empty"), + ("/cabs/1960a.wav", "must not be rooted"), + ("C:/cabs/1960a.wav", "drive-prefixed"), + ("cabs//1960a.wav", "no empty segment"), + ("cabs/./1960a.wav", "no `.` segment"), + ("../1960a.wav", "no `..` segment"), + ("cabs\\1960a.wav", "`/`-separated in the stored document"), + ] { + let violations = validate_text(&format!( + r#"{{"format_version": 1, "references": {{"ir": + {{"hash": "{HASH}", "library_relative": "{}"}}}}}}"#, + path.replace('\\', "\\\\") + )); + assert!( + violations + .iter() + .any(|v| v.pointer == "/references/ir/library_relative" + && v.message.contains(expected)), + "{path}: {violations:?}" + ); + } + } + + /// The disagreement with [`crate::RelPath::parse`] this module's header records, asserted so + /// it stays deliberate: the reader accepts the backslash form and this check does not. + #[test] + fn a_backslash_path_is_off_schema_even_though_the_reader_accepts_it() { + assert!(crate::RelPath::parse("cabs\\1960a.wav").is_ok()); + const HASH: &str = "dc57749e025523f24f989853b68405829607c4c84942579df0c3368694a531e3"; + let violations = validate_text(&format!( + r#"{{"format_version": 1, "references": {{"ir": + {{"hash": "{HASH}", "library_relative": "cabs\\1960a.wav"}}}}}}"# + )); + assert_eq!(pointers(&violations), ["/references/ir/library_relative"]); + } + + /// §7.2, every clause of its own table. + #[test] + fn embedded_follows_section_7_2() { + const HASH: &str = "dc57749e025523f24f989853b68405829607c4c84942579df0c3368694a531e3"; + for (embedded, expected_pointer, expected) in [ + (r#"7"#, "/references/nam/embedded", "must be an object"), + ( + r#"{"data": "eyJhIjoxfQ=="}"#, + "/references/nam/embedded/encoding", + "required, and absent", + ), + ( + r#"{"encoding": "hex", "data": "eyJhIjoxfQ=="}"#, + "/references/nam/embedded/encoding", + "is rejected", + ), + ( + r#"{"encoding": "base64"}"#, + "/references/nam/embedded/data", + "required, and absent", + ), + ( + r#"{"encoding": "base64", "data": "not valid base64!"}"#, + "/references/nam/embedded/data", + "standard alphabet", + ), + ( + r#"{"encoding": "base64", "data": "eyJhIjoxfQ==", "media_type": 3}"#, + "/references/nam/embedded/media_type", + "must be a string when present", + ), + ] { + let violations = validate_text(&format!( + r#"{{"format_version": 1, "references": {{"nam": + {{"hash": "{HASH}", "embedded": {embedded}}}}}}}"# + )); + assert!( + violations + .iter() + .any(|v| v.pointer == expected_pointer && v.message.contains(expected)), + "{embedded}: {violations:?}" + ); + } + } + + /// Every violation is reported, not the first one: a hand-edited preset with three mistakes + /// should say three things, and a `Rejected` `format_version` must not mask what is under it. + #[test] + fn validation_is_total_rather_than_stopping_at_the_first_violation() { + let violations = validate_text( + r#"{"parameters": {"trim.gain_db": "loud"}, + "references": {"ir": {"hash": "short"}}}"#, + ); + assert_eq!( + pointers(&violations), + [ + "/format_version", + "/parameters/trim.gain_db", + "/references/ir/hash" + ] + ); + } + + #[test] + fn a_document_that_is_not_json_has_no_schema_to_check() { + assert!(validate_bytes(b"not json at all").is_err()); + assert!(validate_bytes(b"[1, 2, 3]").is_err()); + } +} diff --git a/crates/namir-state/tests/schema.rs b/crates/namir-state/tests/schema.rs new file mode 100644 index 0000000..817c9a3 --- /dev/null +++ b/crates/namir-state/tests/schema.rs @@ -0,0 +1,308 @@ +//! FR-STATE-040's `S` half, asserted where it can be asserted against real documents: the +//! checked-in corpus, everything this build's writer produces, and the format document's own field +//! tables. +//! +//! `namir_state::schema`'s own unit tests cover each clause of §§3–7 of +//! `docs/04-state-and-preset-format.md` one at a time, against fixtures written to break exactly +//! one of them. This file is the other half of the evidence, and the one that could fail without +//! anyone editing the validator: it runs the same check over documents nobody wrote for it — the +//! six hand-authored corpus files, and the bytes `State::write`/`State::write_onto` actually +//! produce for a range of states. +//! +//! # Why a test here reads a document in `docs/` +//! +//! [`the_file_reference_table_of_section_7_1_names_exactly_the_fields_the_validator_checks`] and +//! its `embedded` twin parse `docs/04-state-and-preset-format.md` itself. That is deliberate and +//! is the same shape as `corpus.rs` reading `tests/corpus/`: the artifact under test is a +//! checked-in file, and the thing worth failing on is the two drifting apart. A schema check +//! transcribed from prose is only as good as the transcription, and these two tests are what make +//! "someone adds a sixth field to §7.1" a red test rather than a clause silently unchecked. It +//! costs this crate no dependency and no non-dev code — the path is resolved from +//! `CARGO_MANIFEST_DIR`, and nothing in `src/` reads a file at all (D-5.1's "never touches a +//! filesystem" is a statement about the crate's own code, which this does not change). + +use std::path::{Path, PathBuf}; + +use namir_core::ContentHash; +use namir_state::{ + Document, EMBEDDED_FIELDS, FILE_REFERENCE_FIELDS, FileRef, RelPath, SchemaViolation, State, +}; + +/// The same list `corpus.rs` keeps, and kept separately on purpose: if a corpus file is added +/// there and not here, `every_corpus_document_is_checked` fails rather than this file quietly +/// checking a smaller set than the one the crate promises to keep loadable. +const CORPUS: &[&str] = &[ + "unreleased-v1/full.namirpreset", + "unreleased-v1/minimal.namirpreset", + "unreleased-v1/unknown-fields.namirpreset", + "unreleased-v1/future-version.namirpreset", + "unreleased-v1/legacy-global-section.namirpreset", + "unreleased-v1/references.namirpreset", +]; + +fn crate_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() +} + +fn read_corpus_file(relative: &str) -> Vec { + let path = crate_dir().join("tests/corpus").join(relative); + std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +fn format_document() -> String { + let path = crate_dir().join("../../docs/04-state-and-preset-format.md"); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +fn describe(violations: &[SchemaViolation]) -> String { + violations + .iter() + .map(|v| format!("\n {v}")) + .collect::() +} + +fn a_reference(name: &str, relative: &str, embedded: bool) -> FileRef { + FileRef { + hash: ContentHash::of(name.as_bytes()), + library_relative: Some(RelPath::parse(relative).expect("well-formed")), + absolute: Some(format!("C:\\Users\\erwan\\{name}")), + display_name: name.to_string(), + embedded: embedded.then(|| namir_state::EmbeddedRef { + media_type: "application/vnd.namir.nam+json".to_string(), + data: br#"{"fake":"bytes"}"#.to_vec(), + }), + } +} + +/// Every state shape this build can save, as documents. Not a sample: the four axes are the four +/// things §§3–7 says a document may or may not carry — parameters at defaults or moved, each +/// reference slot filled or empty, an embedded copy present or not, and a legacy `global` section +/// underneath (which `write_onto` preserves per §5 and §8, so the saved document still has to +/// conform with one in it). +fn documents_this_build_writes() -> Vec<(String, Document)> { + let mut out = Vec::new(); + + out.push(("defaults".to_string(), State::defaults().into_document())); + + let mut moved = State::defaults(); + for descriptor in namir_params::REGISTRY { + // Every parameter set away from its default at once, so no key of `parameters` is missing + // from the document under test: §6's rules are per-key and this is the shape that puts + // every key in front of them. + // `set` clamps into the descriptor's own range (and to the last index of a stepped + // parameter), so one out-of-range value moves every kind of parameter off its default + // without this test needing to know any parameter's range. + moved + .params + .set(descriptor.key, 1.0e9) + .expect("REGISTRY's own key is a real parameter key"); + } + out.push(("every parameter moved".to_string(), moved.into_document())); + + for (label, nam, ir) in [ + ("nam only", true, false), + ("ir only", false, true), + ("both references", true, true), + ] { + for embedded in [false, true] { + let mut state = State::defaults(); + if nam { + state.nam = Some(a_reference("plexi.nam", "marshall/plexi.nam", embedded)); + } + if ir { + state.ir = Some(a_reference("1960a.wav", "cabs/1960a.wav", embedded)); + } + out.push(( + format!("{label}, embedded: {embedded}"), + state.into_document(), + )); + } + } + + // A save *onto* a pre-M6 document, which keeps the legacy `global` section §5 documents and + // §8 promises to preserve. The one saved shape that carries a section a current writer never + // emits, and therefore the one a writer-only conformance test would never look at. + let legacy = Document::parse(&read_corpus_file( + "unreleased-v1/legacy-global-section.namirpreset", + )) + .expect("a corpus document parses"); + let mut state = State::defaults(); + state.set_output_ceiling_db(-2.0); + out.push(( + "saved onto a legacy `global` document".to_string(), + state.write_onto(&legacy), + )); + + // And a save onto the unknown-fields document, so §8's preserved top-level section is present + // in a document under test: §3 says other top-level keys are legal, and a validator that had + // quietly started reporting them would fail here. + let unknown = Document::parse(&read_corpus_file( + "unreleased-v1/unknown-fields.namirpreset", + )) + .expect("a corpus document parses"); + out.push(( + "saved onto a document with an unknown top-level section".to_string(), + State::defaults().write_onto(&unknown), + )); + + out +} + +/// FR-STATE-040's `*Verify:*` line is `M plus S (schema check)`, and this is the `S`: every +/// document this build writes, and every hand-authored document it promises to read, conforms to +/// the format `docs/04-state-and-preset-format.md` §§3–7 documents — checked by +/// `namir_state::validate`, which restates those sections independently of the reader rather than +/// calling it (see `src/schema.rs`'s header). The `M` half is +/// `docs/manual-tests/fr-state-040-diffability-and-hand-editability.md`, which is what D-18.6 +/// makes the traced artifact for that code and is unaffected by this file. +// trace: FR-STATE-040 +#[test] +fn every_document_this_build_writes_conforms_to_the_documented_format() { + for (label, document) in documents_this_build_writes() { + let violations = namir_state::validate(&document); + assert!( + violations.is_empty(), + "{label}: the document this build writes does not conform to \ + docs/04-state-and-preset-format.md §§3–7:{}\n{}", + describe(&violations), + String::from_utf8_lossy(&document.to_pretty_bytes()) + ); + } +} + +/// The other direction, and the stronger claim of the two: bytes nobody generated from this +/// crate's own writer. A round-trip conformance test alone cannot catch a validator that has +/// drifted into agreeing with the writer, because the two would simply agree with each other — +/// which is `corpus.rs`'s own argument for keeping a hand-authored corpus at all. +// trace: FR-STATE-040 +#[test] +fn every_corpus_document_conforms_to_the_documented_format() { + for relative in CORPUS { + let bytes = read_corpus_file(relative); + let violations = namir_state::validate_bytes(&bytes) + .unwrap_or_else(|e| panic!("{relative}: does not parse at all: {e}")); + assert!( + violations.is_empty(), + "{relative}: does not conform to docs/04-state-and-preset-format.md §§3–7:{}", + describe(&violations) + ); + } +} + +/// Without this, a corpus file added to `corpus.rs`'s manifest and not to this file's would be +/// schema-checked by nothing, and both files would stay green — the same failure +/// `the_corpus_directory_contains_exactly_the_manifest_no_more_no_less` exists to prevent one +/// level down. +#[test] +fn every_corpus_document_is_checked() { + let dir = crate_dir().join("tests/corpus/unreleased-v1"); + let mut found: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("{}: {e}", dir.display())) + .flatten() + .map(|e| format!("unreleased-v1/{}", e.file_name().to_string_lossy())) + .collect(); + found.sort(); + let mut listed: Vec = CORPUS.iter().map(|s| (*s).to_string()).collect(); + listed.sort(); + assert_eq!(found, listed); +} + +/// Reads a `| Field | Required | ... |` table out of the format document and returns +/// `(field, required)` in the table's own order. The tables are the format document's own +/// statement of a shape, so parsing them is reading the specification, not guessing at it. +fn field_table(section_heading: &str) -> Vec<(String, bool)> { + let text = format_document(); + let start = text.find(section_heading).unwrap_or_else(|| { + panic!("{section_heading} is no longer a heading in the format document") + }); + let rest = &text[start + section_heading.len()..]; + let end = rest.find("\n### ").unwrap_or(rest.len()); + + rest[..end] + .lines() + .filter(|line| line.starts_with('|')) + .filter_map(|line| { + let cells: Vec<&str> = line.trim_matches('|').split('|').map(str::trim).collect(); + let field = cells.first()?.trim_matches('`'); + // `starts_with`, not equality: §7.1 writes `display_name`'s cell as + // "no (empty string if absent)" and `hash`'s as "**yes**". The header row's + // "Required" and the `|---|` separator match neither and are skipped. + let required = cells.get(1)?.trim_matches('*'); + if required.starts_with("yes") { + Some((field.to_string(), true)) + } else if required.starts_with("no") { + Some((field.to_string(), false)) + } else { + None + } + }) + .collect() +} + +/// §7.1's table against [`FILE_REFERENCE_FIELDS`]. What this catches is the residue a +/// prose-transcribed schema check has and a generated one would not: a field added to the format +/// document, or a `Required` flag changed there, with the validator left as it was. +// trace: FR-STATE-040 +#[test] +fn the_file_reference_table_of_section_7_1_names_exactly_the_fields_the_validator_checks() { + let documented = field_table("### 7.1 File reference shape"); + let checked: Vec<(String, bool)> = FILE_REFERENCE_FIELDS + .iter() + .map(|(name, required)| ((*name).to_string(), *required)) + .collect(); + assert_eq!(documented, checked); +} + +/// §7.2's table against [`EMBEDDED_FIELDS`]. See the test above. +// trace: FR-STATE-040 +#[test] +fn the_embedded_table_of_section_7_2_names_exactly_the_fields_the_validator_checks() { + let documented = field_table("### 7.2 `embedded` (FR-STATE-080)"); + let checked: Vec<(String, bool)> = EMBEDDED_FIELDS + .iter() + .map(|(name, required)| ((*name).to_string(), *required)) + .collect(); + assert_eq!(documented, checked); +} + +/// The check has to be able to fail, and on a document of exactly the kind FR-STATE-040 exists to +/// make possible: one a human hand-edited. Four independent mistakes, four reported clauses, and +/// the document still *loads* — which is the point of the severity distinction. Tolerant loading +/// is silent by design, so without a schema check none of these four would ever be told to anyone. +#[test] +fn a_hand_edited_document_reports_every_clause_it_breaks_and_still_loads() { + let hand_edited = br#"{ + "format_version": 1, + "parameters": { "trim.gain_db": "3 dB please" }, + "references": { + "nam": { + "hash": "not-a-hash", + "library_relative": "../escape/plexi.nam", + "embedded": { "encoding": "gzip", "data": "AAAA" } + } + } + }"#; + + let violations = namir_state::validate_bytes(hand_edited).expect("it is still valid JSON"); + let pointers: Vec<&str> = violations.iter().map(|v| v.pointer.as_str()).collect(); + assert_eq!( + pointers, + [ + "/parameters/trim.gain_db", + "/references/nam/hash", + "/references/nam/library_relative", + "/references/nam/embedded/encoding", + ], + "{}", + describe(&violations) + ); + + // Every one of them is `Recovered`, and the document really does load: D-11.2's tolerance is + // what makes a schema check worth having rather than redundant with the reader. + assert!( + violations + .iter() + .all(|v| v.severity == namir_state::Severity::Recovered) + ); + assert!(State::read(hand_edited).is_ok()); +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0d9d7fc..da30f3c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -34,6 +34,7 @@ mod preset; #[cfg_attr(not(test), allow(dead_code))] mod release_workflow; mod rt_logging; +mod schema; mod traceability; use std::collections::HashMap; @@ -710,9 +711,9 @@ fn traceability_outcome(root: &Path, write: bool, allow_uncovered: bool) -> Trac // per annotation. Only Musts appear in `requirements`, so an id that is not one is simply // absent and passes the guard -- this tool has never restricted what a tag may *name*, only // what a tag *means*. - let verify_codes: HashMap<&str, char> = requirements + let verify_codes: HashMap<&str, &[char]> = requirements .iter() - .map(|req| (req.id.as_str(), req.verify)) + .map(|req| (req.id.as_str(), req.verify.as_slice())) .collect(); let mut source_hits: HashMap> = HashMap::new(); @@ -766,7 +767,12 @@ fn traceability_outcome(root: &Path, write: bool, allow_uncovered: bool) -> Trac } } for req in &requirements { - if req.verify == 'M' + // A requirement whose method names no source-class code at all (`Verify: M` alone, + // `Verify: Process`) has nothing for a test-function name to stand for. One whose + // method names a source-class code *among others* does -- FR-STATE-040's `M plus S` + // is owed a source annotation as well as its manual document (issue #27), so the + // fn-name fallback has to be offered it. + if !traceability::resolves_through_partials(&req.verify) || source_hits.contains_key(&req.id) || partial_hits.contains_key(&req.id) { @@ -859,7 +865,12 @@ fn traceability_outcome(root: &Path, write: bool, allow_uncovered: bool) -> Trac for req in &report.missing { println!( "{}", - uncovered_line(req, &owners, report.manual_unexecuted.get(&req.id)) + uncovered_line( + req, + &owners, + report.manual_unexecuted.get(&req.id), + report.missing_codes.get(&req.id), + ) ); } // Mandatory rather than decorative: without it a derived label reads as a curated ownership @@ -906,11 +917,28 @@ fn uncovered_line( req: &traceability::Requirement, owners: &HashMap, manual: Option<&(String, String)>, + unresolved: Option<&Vec>, ) -> String { let owner = owners .get(&req.id) .map_or(milestones::UNATTRIBUTED, String::as_str); - let mut line = format!(" - {} (Verify: {}) [{owner}]", req.id, req.verify); + let mut line = format!( + " - {} (Verify: {}) [{owner}]", + req.id, + traceability::render_verify_codes(&req.verify) + ); + // Issue #27: for a compound method, which half is missing is the whole of what the reader + // needs -- FR-STATE-040's manual document exists and passes, and it is the `S` its method also + // names that nothing executes. Printed only for a compound method: for a single-code one the + // codes repeat what the `(Verify: ...)` field already said. + if req.verify.len() > 1 + && let Some(codes) = unresolved + { + line.push_str(&format!( + " -- no evidence for the {} half of its compound method", + traceability::render_verify_codes(codes) + )); + } if let Some((file, reason)) = manual { line.push_str(&format!(" -- docs/manual-tests/{file} {reason}")); } @@ -1057,7 +1085,7 @@ fn check_section_table(requirements: &[traceability::Requirement], roadmap_text: fn print_usage() { println!( - "usage: cargo run -p xtask -- |nam-parity --model --input --reference |bundle [--target ] [--check|--plan|--inspect ]>" + "usage: cargo run -p xtask -- |nam-parity --model --input --reference |bundle [--target ] [--check|--plan|--inspect ]>" ); } @@ -1072,6 +1100,9 @@ fn main() { Some("network-free") => run_network_free(&root), Some("error-catalogue") => run_error_catalogue(&root), Some("ci-commands") => run_ci_commands(&root), + // FR-STATE-040's `S` half (issue #27). See `schema.rs`'s header for why the check lives in + // `namir-state` and this is only its build-time face. + Some("schema") => schema::run(&root, &args[1..]), Some("params-lock") => { let write = args.iter().skip(1).any(|a| a == "--write"); run_params_lock(&root, write) @@ -1591,13 +1622,13 @@ mod tests { fn an_uncovered_line_carries_its_derived_milestone() { let req = traceability::Requirement { id: "FR-CFG-020".into(), - verify: 'G', + verify: vec!['G'], section: "4".into(), }; let mut owners = HashMap::new(); owners.insert("FR-CFG-020".to_string(), "M9".to_string()); assert_eq!( - uncovered_line(&req, &owners, None), + uncovered_line(&req, &owners, None, None), " - FR-CFG-020 (Verify: G) [M9]" ); } @@ -1609,7 +1640,7 @@ mod tests { // EXECUTED" are different pieces of work. let req = traceability::Requirement { id: "FR-UI-020".into(), - verify: 'M', + verify: vec!['M'], section: "5.13".into(), }; let manual = ( @@ -1617,7 +1648,7 @@ mod tests { "records `NOT EXECUTED.`".to_string(), ); assert_eq!( - uncovered_line(&req, &HashMap::new(), Some(&manual)), + uncovered_line(&req, &HashMap::new(), Some(&manual), Some(&vec!['M'])), " - FR-UI-020 (Verify: M) [unattributed] -- \ docs/manual-tests/fr-ui-020-single-screen-elements.md records `NOT EXECUTED.`" ); @@ -1627,15 +1658,32 @@ mod tests { fn an_uncovered_line_with_no_owner_says_so_rather_than_guessing() { let req = traceability::Requirement { id: "FR-XXXX-010".into(), - verify: 'U', + verify: vec!['U'], section: "9.9".into(), }; assert_eq!( - uncovered_line(&req, &HashMap::new(), None), + uncovered_line(&req, &HashMap::new(), None, None), " - FR-XXXX-010 (Verify: U) [unattributed]" ); } + /// Issue #27: for a compound method the reader needs to know *which* half resolved to nothing, + /// because the other half's evidence exists and is what made the row read green before. This is + /// FR-STATE-040's own shape: a manual document that passes, and an `S` nothing executes. + #[test] + fn an_uncovered_compound_must_names_the_half_with_no_evidence() { + let req = traceability::Requirement { + id: "FR-STATE-040".into(), + verify: vec!['M', 'S'], + section: "5.9".into(), + }; + assert_eq!( + uncovered_line(&req, &HashMap::new(), None, Some(&vec!['S'])), + " - FR-STATE-040 (Verify: M+S) [unattributed] -- no evidence for the S half of its \ + compound method" + ); + } + #[test] fn a_partial_line_carries_the_milestone_its_own_uncovered_field_declares() { // The FR-LIB-020 text D-23.1 prescribes (`03-implementation-roadmap.md:2387-2388`). Unlike @@ -1994,7 +2042,7 @@ mod tests { // rather than being dropped -- it is still someone recording a gap. let requirements = vec![traceability::Requirement { id: "FR-CHAIN-010".into(), - verify: 'U', + verify: vec!['U'], section: "5.1".into(), }]; let mut partial_hits = HashMap::new(); @@ -2009,6 +2057,7 @@ mod tests { } let report = traceability::Report { missing: Vec::new(), + missing_codes: HashMap::new(), manual_hits: HashMap::new(), manual_unexecuted: HashMap::new(), source_hits: HashMap::new(), diff --git a/xtask/src/preset.rs b/xtask/src/preset.rs index 47c9c88..d6178bc 100644 --- a/xtask/src/preset.rs +++ b/xtask/src/preset.rs @@ -59,6 +59,14 @@ fn sample_state() -> State { /// parameters/references it found, plus any warnings — the read half of FR-STATE-040's manual /// test (docs/manual-tests/): a document hand-edited after `preset` wrote it must still load, and /// the edit must actually take effect. +/// The sample document's bytes, so `xtask schema`'s default document set can include the one +/// document this tool itself produces -- the artifact `docs/manual-tests/`'s FR-STATE-040 script +/// hands a human to inspect and hand-edit, which had better conform to the format that script is +/// demonstrating. +pub fn sample_bytes() -> Vec { + sample_state().write() +} + pub fn run(args: &[String]) -> bool { if args.first().map(String::as_str) == Some("--verify") { let Some(path_str) = args.get(1) else { diff --git a/xtask/src/schema.rs b/xtask/src/schema.rs new file mode 100644 index 0000000..cfa7080 --- /dev/null +++ b/xtask/src/schema.rs @@ -0,0 +1,172 @@ +//! `cargo run -p xtask -- schema [path...]`: FR-STATE-040's `S` half as a **build-time check**, +//! over `docs/04-state-and-preset-format.md` §§3–7. +//! +//! FR-STATE-040's `*Verify:*` line is `M plus S (schema check)`. Its `M` half is +//! `docs/manual-tests/fr-state-040-diffability-and-hand-editability.md`; its `S` half did not +//! exist until M15, and `xtask traceability` could not say so because it kept only the first code +//! of a compound method (issue #27). The check itself is [`namir_state::validate`], which restates +//! §§3–7 independently of this project's own reader — see that module's header for why a validator +//! built out of the reader's own parsing code would agree with it by construction and prove +//! nothing. +//! +//! This subcommand is the *build-time* shape of that check, which is what FRS §1.5's `S` names +//! ("static analysis or build-time check"), and the shape every other `Verify: S` requirement in +//! this repository takes: `layering`, `rt-logging`, `params-lock`, `assets`. `namir-state`'s own +//! `tests/schema.rs` runs the same validator over the same corpus on every `cargo test +//! --workspace`; this is the form a human, a manual-test script, or a CI step can invoke directly, +//! and the form that can be pointed at a real preset file a user is having trouble with. +//! +//! Reports a **list**, like `identity` and `bundle`: one document's malformed reference must not +//! hide another's missing `format_version`. + +use std::path::{Path, PathBuf}; + +use namir_state::{Severity, validate_bytes}; + +/// The documents checked when no path is given: every hand-authored document in `namir-state`'s +/// checked-in corpus, plus the sample `xtask preset` itself writes. +/// +/// The corpus is the interesting half. Those six files were written by hand against the format +/// document, not produced by this build's writer, so they are the ones that can disagree with the +/// validator — a check run only over bytes this project's own writer produced would have the +/// writer and the validator agreeing with each other, which is `corpus.rs`'s own argument for +/// keeping a hand-authored corpus at all. +pub const CORPUS_DIR: &str = "crates/namir-state/tests/corpus"; + +/// One document that failed the check, and every clause of §§3–7 it breaks. +struct Report { + label: String, + violations: Vec, +} + +/// Runs the subcommand. `args` is everything after `schema` on the command line: zero or more +/// paths to `.namirpreset`/state documents. With none, the default set above is checked. +/// +/// The tag below is the `S` half of FR-STATE-040's compound method, and it is a **plain** tag +/// under D-23.1 for the `S` half specifically: the check exists, executes §§3–7 clause by clause +/// (`namir_state::schema`'s unit tests, one fixture per clause), spans the documents this build +/// writes and the hand-authored corpus (`namir-state/tests/schema.rs`), and asserts rather than +/// prints. The `M` half is unaffected and is still traced by +/// `docs/manual-tests/fr-state-040-diffability-and-hand-editability.md`, per D-18.6 — with the +/// parser change of issue #27, the requirement now needs both and resolves only when both are +/// there. +// trace: FR-STATE-040 +pub fn run(root: &Path, args: &[String]) -> bool { + let targets: Vec<(String, Vec)> = if args.is_empty() { + match default_targets(root) { + Ok(targets) => targets, + Err(e) => { + println!("schema: could not assemble the default document set: {e}"); + return false; + } + } + } else { + let mut out = Vec::new(); + for path in args { + match std::fs::read(path) { + Ok(bytes) => out.push((path.clone(), bytes)), + Err(e) => { + println!("schema: could not read {path}: {e}"); + return false; + } + } + } + out + }; + + let mut reports = Vec::new(); + for (label, bytes) in &targets { + match validate_bytes(bytes) { + // Not a schema violation but a document that has no schema to check: §2's byte ceiling, + // or bytes that are not a JSON object at all. Reported as its own failing document + // rather than as a violation list, because there are no clauses to list. + Err(e) => reports.push(Report { + label: label.clone(), + violations: vec![format!("does not parse as a state document at all: {e}")], + }), + Ok(violations) if violations.is_empty() => {} + Ok(violations) => reports.push(Report { + label: label.clone(), + violations: violations + .iter() + .map(|v| { + let severity = match v.severity { + Severity::Rejected => "rejected", + Severity::Recovered => "recovered", + }; + format!("{v} [{severity}]") + }) + .collect(), + }), + } + } + + if reports.is_empty() { + println!( + "schema: clean ({} document(s) conform to docs/04-state-and-preset-format.md §§3-7)", + targets.len() + ); + return true; + } + + println!( + "schema: {} of {} document(s) do not conform to docs/04-state-and-preset-format.md §§3-7 \ + (FR-STATE-040):", + reports.len(), + targets.len() + ); + for report in &reports { + println!(" {}:", report.label); + for violation in &report.violations { + println!(" - {violation}"); + } + } + false +} + +fn default_targets(root: &Path) -> Result)>, String> { + let mut paths: Vec = Vec::new(); + let corpus = root.join(CORPUS_DIR); + collect_documents(&corpus, &mut paths)?; + // Sorted: `read_dir`'s order is filesystem-dependent, and a check that lists its findings in a + // different order on each platform is unreadable in a CI log diff -- the same reasoning + // `traceability`'s manual-test read already records. + paths.sort(); + if paths.is_empty() { + return Err(format!("{} holds no documents", corpus.display())); + } + + let mut out: Vec<(String, Vec)> = Vec::new(); + for path in paths { + let label = path + .strip_prefix(root) + .unwrap_or(&path) + .display() + .to_string(); + let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; + out.push((label, bytes)); + } + out.push(( + "xtask preset (generated sample)".to_string(), + crate::preset::sample_bytes(), + )); + Ok(out) +} + +/// Every file under `dir`, recursively. Deliberately not filtered by extension: the corpus's own +/// manifest test is what says which files belong there, and a check that silently skipped a file +/// whose extension it did not recognise would be the "quietly checking a smaller set" failure this +/// repository has already been bitten by more than once. +fn collect_documents(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; + let path = entry.path(); + if path.is_dir() { + collect_documents(&path, out)?; + } else { + out.push(path); + } + } + Ok(()) +} diff --git a/xtask/src/traceability.rs b/xtask/src/traceability.rs index 5afed49..dca1b4c 100644 --- a/xtask/src/traceability.rs +++ b/xtask/src/traceability.rs @@ -51,16 +51,23 @@ use std::collections::HashMap; -/// One `Must`-priority requirement parsed from the FRS, paired with its `*Verify:*` code -/// (`U`/`I`/`G`/`B`/`S`/`M` per FRS §1.5) and the number of the FRS heading in force at its own -/// line (`"4"`, `"5.1"`, ...; empty when no numbered heading preceded it). D-23.2 derives §14's -/// Must-count denominators from that section number, so it is parsed here rather than guessed -/// from the id's area token -- `## 4. Product configurations` carries no `(CFG)` suffix where -/// every `### 5.x`/`### 6.x` heading does. +/// One `Must`-priority requirement parsed from the FRS, paired with its `*Verify:*` codes +/// (`U`/`I`/`G`/`B`/`S`/`M` per FRS §1.5, plus `P` for that section's `Process`) and the number of +/// the FRS heading in force at its own line (`"4"`, `"5.1"`, ...; empty when no numbered heading +/// preceded it). D-23.2 derives §14's Must-count denominators from that section number, so it is +/// parsed here rather than guessed from the id's area token -- `## 4. Product configurations` +/// carries no `(CFG)` suffix where every `### 5.x`/`### 6.x` heading does. +/// +/// `verify` is a **set**, in the order the FRS states it, and never empty (issue #27). A method +/// may state more than one code -- `M plus S (schema check)`, `U per stage; I for click-freedom`, +/// `S — ... — plus I under a stress test` -- and every code it states has to resolve before the +/// requirement is covered. Keeping only the first, which this module did until issue #27, is what +/// let FR-STATE-040 read fully covered from a manual document while the schema check its method +/// also names existed nowhere. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Requirement { pub id: String, - pub verify: char, + pub verify: Vec, pub section: String, } @@ -87,8 +94,25 @@ pub fn parse_must_requirements(frs_text: &str) -> Result, Strin if extract_must_id(lines[j]).is_some() { break; } - if let Some(v) = extract_verify_code(lines[j]) { - verify = Some(v); + if let Some(head) = verify_line_text(lines[j]) { + // The method may wrap across lines, and NFR-RT-010's second code is on a + // wrapped one -- so the text this parses is the marker's line plus every + // continuation, not the marker's line alone. + let mut text = head.to_string(); + let mut k = j + 1; + while k < lines.len() && is_verify_continuation(lines[k]) { + text.push(' '); + text.push_str(lines[k].trim()); + k += 1; + } + let codes = parse_verify_codes(&text); + if codes.is_empty() { + return Err(format!( + "the *Verify:* line for {id} states no recognisable code -- expected \ + one or more of U/I/G/B/S/M/Process, found `{text}`" + )); + } + verify = Some(codes); break; } j += 1; @@ -168,15 +192,132 @@ fn heading_section_number(line: &str) -> Option { Some(run.to_string()) } -/// `"*Verify:* U — measure ..."` -> `Some('U')`. -fn extract_verify_code(line: &str) -> Option { +/// The method text of a `*Verify:*` line, if the line carries one. +/// +/// A line that merely *names* the marker in prose is not one, and the FRS has several ("It does +/// not touch the `*Verify:*` line above, which stands ..."): the text after the marker must open +/// with a code token, which a backtick or an em dash does not. +fn verify_line_text(line: &str) -> Option<&str> { const MARKER: &str = "*Verify:*"; let idx = line.find(MARKER)?; - line[idx + MARKER.len()..] - .trim_start() + let text = line[idx + MARKER.len()..].trim_start(); + clause_code(text)?; + Some(text) +} + +/// Whether `line` continues the `*Verify:*` method text above it. +/// +/// The FRS wraps a long method across lines, and **NFR-RT-010's second code sits on one of them**: +/// `*Verify:* S — an allocation-detecting harness fails any test that allocates on the audio +/// thread —` / `plus I under a stress test with concurrent model loading, preset recall and +/// library scanning.` Reading only the marker's own line -- which is what this module did until +/// issue #27 -- loses that `I` in exactly the way the issue describes, so the continuation is part +/// of the method, not prose after it. +/// +/// A continuation is any non-blank line that does not open a new block: a heading (`#`), or an +/// italic paragraph (`*Rationale:*`, `*Consequence ...*`, and `**FR-... (Must)**`, all of which +/// open with `*`). Every real continuation in the FRS today opens with an ordinary word. +fn is_verify_continuation(line: &str) -> bool { + !line.trim().is_empty() && !line.starts_with('*') && !line.starts_with('#') +} + +/// Every `*Verify:*` code a method states, in the order it states them, deduplicated. Never empty +/// for a text [`verify_line_text`] accepted. +/// +/// **This returns a set because eight of the FRS's 130 Musts state a compound method** and this +/// module kept only the first code of one until issue #27 -- so `docs/03-test-plan.md` stated a +/// weaker bar than the FRS for every one of them, and FR-STATE-040 (`M plus S (schema check)`) +/// read fully covered on its manual document alone while the `S` half was executed by nothing. +/// +/// The grammar is deliberately narrow. The method text is split on `;` and on the word `plus` +/// ([`split_verify_clauses`]), and a clause contributes a code only when it **opens** with one: +/// a bare `U`/`I`/`G`/`B`/`S`/`M`, or the word `Process`. A clause opening with anything else is a +/// qualifier on the code before it, not a second method -- NFR-PERF-010's "B, as a CI regression +/// gate" states one code, and NFR-RT-020's "S plus code review" states one code plus a review +/// obligation that is not a `Verify:` code at all. That second case is a real limit worth stating +/// rather than hiding: the FRS spells review `Process`, and this parser reads codes, never prose, +/// so "code review" is recorded nowhere. Nothing mechanical would change if it were -- `Process` +/// is by definition verified by review and commit order, with no artifact a build can inspect -- +/// but the plan's `Verify` column would say so, and today it does not. +fn parse_verify_codes(text: &str) -> Vec { + let mut out = Vec::new(); + for clause in split_verify_clauses(text) { + if let Some(code) = clause_code(clause) + && !out.contains(&code) + { + out.push(code); + } + } + out +} + +/// The clauses of a method text: the text split on `;` and on the standalone word `plus`, which +/// are the two connectives the FRS actually uses to join one method to another (`U per stage; I +/// for click-freedom`, `M plus S (schema check)`). A comma is deliberately **not** a separator: +/// NFR-PERF-010's "B, as a CI regression gate" is one method with a qualifier, and splitting on +/// commas would invite every such qualifier to be read as a clause and rejected one word at a +/// time. +fn split_verify_clauses(text: &str) -> Vec<&str> { + const PLUS: &str = "plus"; + let mut out = Vec::new(); + let mut start = 0; + let mut resume = 0; + for (i, c) in text.char_indices() { + if i < resume { + continue; + } + if c == ';' { + out.push(&text[start..i]); + start = i + 1; + } else if text[i..].starts_with(PLUS) && is_whole_word(text, i, PLUS.len()) { + out.push(&text[start..i]); + start = i + PLUS.len(); + resume = start; + } + } + out.push(&text[start..]); + out +} + +/// Whether the `len`-byte run at `at` is a whole word: not a suffix of `surplus`, not a prefix of +/// `plush`. +fn is_whole_word(text: &str, at: usize, len: usize) -> bool { + let before_ok = text[..at] + .chars() + .next_back() + .is_none_or(|c| !c.is_alphanumeric()); + let after_ok = text[at + len..] .chars() .next() - .filter(|c| c.is_ascii_alphabetic()) + .is_none_or(|c| !c.is_alphanumeric()); + before_ok && after_ok +} + +/// The `*Verify:*` code a clause opens with, if any. `Process` is folded to `'P'`, the single +/// character the rest of this module keys on (and which no `*Verify:*` line in the FRS spells -- +/// [`check_partial_verify_code`] restores the FRS's own spelling when it has to name it). +fn clause_code(clause: &str) -> Option { + let token = clause.split_whitespace().next()?; + let token = token.trim_matches(|c: char| !c.is_ascii_alphanumeric()); + match token { + "U" | "I" | "G" | "B" | "S" | "M" => token.chars().next(), + "Process" => Some('P'), + _ => None, + } +} + +/// The `Verify` column of the generated plan, and of every message that names a requirement's +/// method: the codes joined with `+`. A single-code requirement renders exactly as it did before +/// issue #27 (`U`), so only the eight compound rows move. +pub fn render_verify_codes(codes: &[char]) -> String { + let mut out = String::new(); + for code in codes { + if !out.is_empty() { + out.push('+'); + } + out.push(*code); + } + out } /// Both comment-prefix spellings a `trace:` annotation may use: `// trace:` in `.rs` source, @@ -614,23 +755,33 @@ pub fn manual_test_prefix(id: &str) -> String { /// resolves by its own method either way and nothing their author wrote is lost. A `trace-partial:` /// is different in kind: its `uncovered:` field is mandatory, names a gap and a due date, and exists /// for no purpose other than to be rendered. -pub fn check_partial_verify_code(id: &str, verify: char, line: usize) -> Result<(), String> { +pub fn check_partial_verify_code(id: &str, verify: &[char], line: usize) -> Result<(), String> { + if resolves_through_partials(verify) { + return Ok(()); + } let (code, reason) = match verify { - // The FRS spells this code `Process`, not `P`; `extract_verify_code` keeps only the first + // The FRS spells this code `Process`, not `P`; `parse_verify_codes` folds it to one // character, so the spelling is restored here rather than printing a letter no `*Verify:*` // line in the FRS actually carries. - 'M' => ( - "M", + ['M'] => ( + "M".to_string(), "a `Verify: M` Must is verified by a written manual-test script under \ docs/manual-tests/, which no source or configuration file is or can be part of -- \ record the unspanned member in that document instead", ), - 'P' => ( - "Process", + ['P'] => ( + "Process".to_string(), "a `Verify: Process` Must is verified by review and commit order, with no artifact a \ build can inspect, so there is nothing for a partial to be partial about", ), - _ => return Ok(()), + // Only reachable for a compound method made of `M` and `Process` alone, which the FRS + // does not carry today. Refused for the union of both reasons rather than left to the + // arms above, which would panic-by-omission on a method the FRS is free to grow. + _ => ( + render_verify_codes(verify).replace('P', "Process"), + "every code this method states is verified off this repository's source -- a manual \ + script, or review and commit order -- so no source annotation can be part of it", + ), }; Err(format!( "{line}: `trace-partial: {id}` names a `Verify: {code}` requirement -- D-23.1 asserts \ @@ -652,11 +803,14 @@ pub struct PartialHit { /// source-file hits. `source_hits`/`manual_hits` are `id -> [crate name]` / `id -> filename` for /// requirements that *are* covered, kept for `render_test_plan`; `partial_hits` is the same for /// requirements covered only in part (D-23.1); `manual_unexecuted` is `id -> (filename, the -/// document's own verdict)` for a `Verify: M` Must whose script exists but has not been run -/// (issue #34); `missing` is every Must id this run found no coverage for at all, the -/// `manual_unexecuted` ids included. +/// document's own verdict)` for a Must stating `M` whose script exists but has not been run +/// (issue #34); `missing` is every Must this run found no coverage for at all, the +/// `manual_unexecuted` ids included; and `missing_codes` is `id -> the codes of that Must's own +/// method which resolved to nothing`, which for a compound method is what says *which half* is +/// missing (issue #27) -- keyed by exactly the ids in `missing`, and absent for every other. pub struct Report { pub missing: Vec, + pub missing_codes: HashMap>, pub manual_hits: HashMap, pub manual_unexecuted: HashMap, pub source_hits: HashMap>, @@ -1004,8 +1158,15 @@ pub fn build_report( // invariant is stated here rather than re-checked here because this function has no line or // file to name in an error, and a coverage reconciler is the wrong place to diagnose a // malformed input. + let mut missing_codes: HashMap> = HashMap::new(); + for req in requirements { - if req.verify == 'M' { + // Each *class* of evidence a method names is looked up on its own, and every class it + // names must resolve (issue #27). Before that change this was an `if`/`else if` chain on + // one code, so a compound method's second class was never looked up at all. + let mut unresolved: Vec = Vec::new(); + + if needs_manual_document(&req.verify) { let prefix = format!("{}-", manual_test_prefix(&req.id)); match manual_test_docs.iter().find(|(name, content)| { name.to_lowercase().starts_with(&prefix) @@ -1022,26 +1183,36 @@ pub fn build_report( } Some(reason) => { manual_unexecuted.insert(req.id.clone(), (file.clone(), reason)); - missing.push(req.clone()); + unresolved.push('M'); } }, - None => missing.push(req.clone()), + None => unresolved.push('M'), } - } else if req.verify == 'P' { - // Process-verified: by definition, verified by review/commit order, not by any - // artifact this check can inspect. Nothing to look up; never "missing". - } else if !source_hits.contains_key(&req.id) && !partial_hits.contains_key(&req.id) { + } + + // `Verify: Process` is by definition verified by review and commit order, not by any + // artifact this check can inspect. Nothing to look up; never "missing". + if resolves_through_partials(&req.verify) + && !source_hits.contains_key(&req.id) + && !partial_hits.contains_key(&req.id) + { // D-23.1: a `trace-partial` counts as coverage for the ordinary run. It must -- // FR-NAM-030 is knowingly half-met until M10 Phase 4, and a gate that cannot go green // is the red-check-nobody-can-act-on problem M7 marked this check informational over. // The teeth are elsewhere: D-18.5's zero-uncovered half becomes required at M13's // close-out, and D-23.2 rules that a Partial is not Done for M8's exit checklist. + unresolved.extend(req.verify.iter().filter(|c| !matches!(c, 'M' | 'P'))); + } + + if !unresolved.is_empty() { missing.push(req.clone()); + missing_codes.insert(req.id.clone(), unresolved); } } Report { missing, + missing_codes, manual_hits, manual_unexecuted, source_hits: source_hits.clone(), @@ -1049,14 +1220,27 @@ pub fn build_report( } } -/// The `Verify:` codes whose plan row resolves *through* `partial_hits`: every code except `M`, -/// whose evidence is a manual-test document, and `Process`, which has no build-inspectable artifact -/// at all (see [`check_partial_verify_code`] for why those two are refused rather than rendered). +/// Whether a requirement's plan row resolves *through* `partial_hits` -- equivalently, whether +/// **any** code it states is one an annotated artifact in this repository can carry. Every code +/// except `M`, whose evidence is a manual-test document, and `Process`, which has no +/// build-inspectable artifact at all (see [`check_partial_verify_code`] for why a partial naming +/// only those is refused rather than rendered). /// -/// Written once and read by both [`render_test_plan`]'s dispatch and [`partial_row_ids`], so the -/// rows the plan carries and the number R-13 prints cannot come from two different conditions. -fn resolves_through_partials(verify: char) -> bool { - !matches!(verify, 'M' | 'P') +/// `any`, not `all`, and that is the whole of what issue #27 changes here: FR-STATE-040's `M plus +/// S` states one code of each kind, so it is both traced by a manual document *and* owed a source +/// annotation, and a `trace-partial:` naming it is legitimate where a partial on a bare `Verify: M` +/// is not. +/// +/// Written once and read by [`render_test_plan`]'s dispatch, [`build_report`] and +/// [`partial_row_ids`], so the rows the plan carries and the number R-13 prints cannot come from +/// two different conditions. +pub fn resolves_through_partials(verify: &[char]) -> bool { + verify.iter().any(|c| !matches!(c, 'M' | 'P')) +} + +/// Whether a requirement states a code whose evidence is a manual-test document (D-18.6). +fn needs_manual_document(verify: &[char]) -> bool { + verify.contains(&'M') } /// The ids [`render_test_plan`] emits a **PARTIAL** row for, sorted as the plan sorts them. @@ -1079,7 +1263,7 @@ pub fn partial_row_ids(requirements: &[Requirement], report: &Report) -> Vec = requirements .iter() .filter(|req| { - resolves_through_partials(req.verify) && report.partial_hits.contains_key(&req.id) + resolves_through_partials(&req.verify) && report.partial_hits.contains_key(&req.id) }) .map(|req| req.id.clone()) .collect(); @@ -1099,6 +1283,9 @@ pub fn render_test_plan(requirements: &[Requirement], report: &Report) -> String Do not hand-edit -- regenerate instead. Maps every Must-priority requirement to how it is \ verified: a manual-test document (`Verify: M`) or the crate(s) whose test source carries a \ `trace:` annotation or matching test-function name for it (`Verify: U/I/G/B/S`). A \ + method stating more than one code (`M+S`, `U+I`) is **compound**: every code it states \ + has to resolve, and the cell carries one entry per class of evidence, joined with `+` in \ + the order the FRS states them (issue #27). A \ `Verify: M` row resolves only when its document's own `**Result:` line records a clean \ pass -- a document recording `NOT EXECUTED`, a partial or a qualified pass, and a \ document carrying no verdict line at all, leave the requirement UNRESOLVED with the \ @@ -1116,40 +1303,58 @@ pub fn render_test_plan(requirements: &[Requirement], report: &Report) -> String |---|---|---|\n", ); - // Same dispatch, same invariant as `build_report`'s: the `'M'`/`'P'` arms never reach - // `partial_hits`, and `check_partial_verify_code` is what makes that lossless. + // Same dispatch, same invariant as `build_report`'s: one cell per *class* of evidence the + // method names, joined in the order the FRS states the codes. A single-code requirement + // renders exactly what it rendered before issue #27; a compound one renders both halves, so a + // half that resolved to nothing is visible as `**UNRESOLVED**` beside the half that did. for req in &sorted { - let covered_by = if resolves_through_partials(req.verify) - && let Some(partials) = report.partial_hits.get(&req.id) - { - // A partial wins over a plain tag on the same id. The two assert contradictory things - // (whole requirement vs. named unmet clause) and D-23.1 settles neither; rendering the - // gap is the honest direction, and no such case exists in this tree today. - render_partial(partials, report.source_hits.get(&req.id)) - } else if req.verify == 'M' { - match ( - report.manual_hits.get(&req.id), - report.manual_unexecuted.get(&req.id), - ) { - (Some(f), _) => format!("`docs/manual-tests/{f}`"), - // Issue #34: the document is named even though it does not resolve the - // requirement -- the reader needs to know a script exists and what it says about - // itself, which is strictly more than "**UNRESOLVED**" alone can tell them. - (None, Some((file, reason))) => { - format!("**UNRESOLVED** — `docs/manual-tests/{file}` {reason}") + let mut pieces: Vec = Vec::new(); + let mut source_done = false; + for code in &req.verify { + let piece = match code { + 'M' => match ( + report.manual_hits.get(&req.id), + report.manual_unexecuted.get(&req.id), + ) { + (Some(f), _) => format!("`docs/manual-tests/{f}`"), + // Issue #34: the document is named even though it does not resolve the + // requirement -- the reader needs to know a script exists and what it says + // about itself, which is strictly more than "**UNRESOLVED**" alone can tell + // them. + (None, Some((file, reason))) => { + format!("**UNRESOLVED** — `docs/manual-tests/{file}` {reason}") + } + (None, None) => "**UNRESOLVED**".to_string(), + }, + 'P' => "process (review + commit order, not build-inspectable)".to_string(), + _ => { + // Every source-class code a method states shares one artifact lookup: a + // `trace:` tag names a requirement, never one code of it. Rendered once, at + // the position of the first such code. + if source_done { + continue; + } + source_done = true; + if let Some(partials) = report.partial_hits.get(&req.id) { + // A partial wins over a plain tag on the same id. The two assert + // contradictory things (whole requirement vs. named unmet clause) and + // D-23.1 settles neither; rendering the gap is the honest direction, and + // no such case exists in this tree today. + render_partial(partials, report.source_hits.get(&req.id)) + } else if let Some(crates) = report.source_hits.get(&req.id) { + backticked_components(crates) + } else { + "**UNRESOLVED**".to_string() + } } - (None, None) => "**UNRESOLVED**".to_string(), - } - } else if req.verify == 'P' { - "process (review + commit order, not build-inspectable)".to_string() - } else if let Some(crates) = report.source_hits.get(&req.id) { - backticked_components(crates) - } else { - "**UNRESOLVED**".to_string() - }; + }; + pieces.push(piece); + } out.push_str(&format!( "| {} | {} | {} |\n", - req.id, req.verify, covered_by + req.id, + render_verify_codes(&req.verify), + pieces.join(" + ") )); } @@ -1615,7 +1820,7 @@ mod tests { reqs, vec![Requirement { id: "FR-CHAIN-090".into(), - verify: 'U', + verify: vec!['U'], section: String::new(), }] ); @@ -1640,18 +1845,104 @@ mod tests { reqs, vec![Requirement { id: "FR-CHAIN-060".into(), - verify: 'I', + verify: vec!['I'], section: String::new(), }] ); } + /// Issue #27: this test asserted `reqs[0].verify == 'U'` until M15 -- it pinned the defect, + /// with the FRS's own FR-CHAIN-020 text as its fixture. Every code a compound method states is + /// kept now, in the order stated. #[test] - fn takes_the_first_code_when_verify_lists_more_than_one() { + fn keeps_every_code_when_verify_lists_more_than_one() { let frs = "**FR-CHAIN-020 (Must)** — text.\n\ *Verify:* U per stage; I for click-freedom.\n"; let reqs = parse_must_requirements(frs).unwrap(); - assert_eq!(reqs[0].verify, 'U'); + assert_eq!(reqs[0].verify, vec!['U', 'I']); + } + + /// The other connective, and the FRS's own FR-STATE-040 text: `plus` rather than `;`, with the + /// second code carrying a parenthesised gloss. + #[test] + fn reads_a_plus_joined_compound_method() { + let frs = "**FR-STATE-040 (Must)** — text.\n\ + *Verify:* M plus S (schema check).\n"; + let reqs = parse_must_requirements(frs).unwrap(); + assert_eq!(reqs[0].verify, vec!['M', 'S']); + } + + /// NFR-RT-010's shape: the second code is on a **wrapped** line, which is why the method text + /// is the marker's line plus its continuations rather than the marker's line alone. + #[test] + fn reads_a_code_stated_on_a_continuation_line() { + let frs = "**NFR-RT-010 (Must)** — text.\n\ + *Verify:* S — an allocation-detecting harness fails any test that allocates —\n\ + plus I under a stress test with concurrent model loading.\n"; + let reqs = parse_must_requirements(frs).unwrap(); + assert_eq!(reqs[0].verify, vec!['S', 'I']); + } + + /// The continuation stops at the next block. An appended `*Consequence ...*` paragraph is + /// prose about the requirement, not more of its method, and FR-IO-010's `*Verify:* M.` is + /// followed directly by one with no blank line between them. + #[test] + fn a_following_italic_paragraph_is_not_part_of_the_method() { + let frs = "**FR-IO-010 (Must)** — text.\n\ + *Verify:* M.\n\ + *Consequence (added M8-planning)* — I am prose, not a second code.\n"; + let reqs = parse_must_requirements(frs).unwrap(); + assert_eq!(reqs[0].verify, vec!['M']); + } + + /// A qualifier is not a second method. NFR-PERF-010's "B, as a CI regression gate" and + /// NFR-RT-020's "S plus code review" are the FRS's two live cases, and both state one code: + /// a comma is not a separator at all, and a `plus` clause opening with a word rather than a + /// code contributes nothing. The issue that prompted this change listed both as compound; they + /// are not, and reading them as compound would have invented a code the FRS never wrote. + #[test] + fn a_qualifier_clause_is_not_a_second_code() { + for (text, expected) in [ + ("*Verify:* B, as a CI regression gate.", vec!['B']), + ("*Verify:* S plus code review.", vec!['S']), + ( + "*Verify:* U per control against a synthesised burst.", + vec!['U'], + ), + ] { + let frs = format!("**FR-X-010 (Must)** — text.\n{text}\n"); + let reqs = parse_must_requirements(&frs).unwrap(); + assert_eq!(reqs[0].verify, expected, "{text}"); + } + } + + /// `Process` is folded to one character, as it was before issue #27 -- the FRS spells the code + /// in full and nothing else in this module does. + #[test] + fn process_is_folded_to_one_character() { + let frs = "**NFR-QUAL-020 (Must)** — text.\n\ + *Verify:* Process — enforced by review, evidenced by commit order.\n"; + let reqs = parse_must_requirements(frs).unwrap(); + assert_eq!(reqs[0].verify, vec!['P']); + } + + /// A line that merely names the marker in prose is not a `*Verify:*` line, and the FRS carries + /// several. Before the requirement's real method, such a line must not be mistaken for it. + #[test] + fn a_prose_mention_of_the_marker_is_not_a_verify_line() { + let frs = "**FR-X-010 (Must)** — text.\n\ + This note does not touch the `*Verify:*` line above.\n\ + *Verify:* G.\n"; + let reqs = parse_must_requirements(frs).unwrap(); + assert_eq!(reqs[0].verify, vec!['G']); + } + + /// The `Verify` column, and every message that names a method: single codes render exactly as + /// they did before issue #27, so only the compound rows move in the generated plan. + #[test] + fn verify_codes_render_joined_with_a_plus() { + assert_eq!(render_verify_codes(&['U']), "U"); + assert_eq!(render_verify_codes(&['M', 'S']), "M+S"); } #[test] @@ -2132,7 +2423,7 @@ mod tests { fn build_report_leaves_a_manual_must_uncovered_when_its_document_records_no_pass() { let reqs = vec![Requirement { id: "FR-CHAIN-010".into(), - verify: 'M', + verify: vec!['M'], section: "5.1".into(), }]; let docs = vec![( @@ -2164,7 +2455,7 @@ mod tests { // fixture is malformed in some other way. let reqs = vec![Requirement { id: "FR-CHAIN-010".into(), - verify: 'M', + verify: vec!['M'], section: "5.1".into(), }]; let docs = vec![( @@ -2180,7 +2471,7 @@ mod tests { fn a_verdictless_document_leaves_its_requirement_uncovered_and_says_why() { let reqs = vec![Requirement { id: "FR-CHAIN-010".into(), - verify: 'M', + verify: vec!['M'], section: "5.1".into(), }]; let docs = vec![( @@ -2200,7 +2491,7 @@ mod tests { fn build_report_flags_a_must_requirement_with_no_coverage() { let reqs = vec![Requirement { id: "FR-X-010".into(), - verify: 'U', + verify: vec!['U'], section: String::new(), }]; let report = build_report(&reqs, &[], &HashMap::new(), &HashMap::new()); @@ -2212,7 +2503,7 @@ mod tests { fn build_report_resolves_a_manual_verified_requirement_by_filename() { let reqs = vec![Requirement { id: "FR-IO-020".into(), - verify: 'M', + verify: vec!['M'], section: String::new(), }]; let docs = vec![( @@ -2234,7 +2525,7 @@ mod tests { // already has, and the one legitimate multi-requirement document in the tree. let reqs = vec![Requirement { id: "FR-IO-040".into(), - verify: 'M', + verify: vec!['M'], section: String::new(), }]; let docs = vec![( @@ -2258,7 +2549,7 @@ mod tests { // sentence; it is simply not a claim to verify FR-UI-020. let reqs = vec![Requirement { id: "FR-UI-020".into(), - verify: 'M', + verify: vec!['M'], section: String::new(), }]; let docs = vec![( @@ -2305,7 +2596,7 @@ mod tests { fn build_report_treats_process_verified_as_always_covered() { let reqs = vec![Requirement { id: "NFR-QUAL-020".into(), - verify: 'P', + verify: vec!['P'], section: String::new(), }]; let report = build_report(&reqs, &[], &HashMap::new(), &HashMap::new()); @@ -2316,7 +2607,7 @@ mod tests { fn build_report_resolves_a_source_verified_requirement() { let reqs = vec![Requirement { id: "FR-NAM-070".into(), - verify: 'I', + verify: vec!['I'], section: String::new(), }]; let mut hits = HashMap::new(); @@ -2343,7 +2634,7 @@ mod tests { // rule that a Partial is not Done -- not this gate. let reqs = vec![Requirement { id: "FR-LIB-020".into(), - verify: 'I', + verify: vec!['I'], section: String::new(), }]; let partials = one_partial("FR-LIB-020", "namir-worker", FR_LIB_020_UNCOVERED); @@ -2357,7 +2648,7 @@ mod tests { // FR-IO-020 by its manual-test document without ever consulting `partial_hits`. Refused // rather than rendered: the requirement's own `Verify:` method is a written script, and no // source annotation is one. - let err = check_partial_verify_code("FR-IO-020", 'M', 7).unwrap_err(); + let err = check_partial_verify_code("FR-IO-020", &['M'], 7).unwrap_err(); assert!(err.starts_with("7: "), "{err}"); assert!(err.contains("FR-IO-020"), "{err}"); assert!(err.contains("`Verify: M`"), "{err}"); @@ -2368,7 +2659,7 @@ mod tests { fn a_partial_naming_a_process_verified_requirement_is_a_hard_error() { // Reported with the FRS's own spelling of the code, `Process` -- the parser keeps only the // first character, and `Verify: P` is a code no FRS line carries. - let err = check_partial_verify_code("NFR-QUAL-020", 'P', 3).unwrap_err(); + let err = check_partial_verify_code("NFR-QUAL-020", &['P'], 3).unwrap_err(); assert!(err.starts_with("3: "), "{err}"); assert!(err.contains("`Verify: Process`"), "{err}"); assert!(!err.contains("`Verify: P`"), "{err}"); @@ -2380,17 +2671,28 @@ mod tests { // worked example, is `Verify: I`. for verify in ['U', 'I', 'G', 'B', 'S'] { assert!( - check_partial_verify_code("FR-LIB-020", verify, 1).is_ok(), + check_partial_verify_code("FR-LIB-020", &[verify], 1).is_ok(), "Verify: {verify}" ); } } + /// Issue #27's own case, and the reason the guard asks whether **any** code resolves through a + /// source annotation rather than whether the first one does. FR-STATE-040's `M plus S` is owed + /// a source annotation for its `S` half, so a partial naming it is legitimate -- refusing it, + /// which is what the single-code guard did, left the requirement with no way to record the gap + /// at all. + #[test] + fn a_partial_naming_a_compound_method_with_a_source_half_is_accepted() { + assert!(check_partial_verify_code("FR-STATE-040", &['M', 'S'], 1).is_ok()); + assert!(check_partial_verify_code("FR-IN-020", &['U', 'M'], 1).is_ok()); + } + #[test] fn render_test_plan_marks_unresolved_requirements_explicitly() { let reqs = vec![Requirement { id: "FR-X-010".into(), - verify: 'U', + verify: vec!['U'], section: String::new(), }]; let report = build_report(&reqs, &[], &HashMap::new(), &HashMap::new()); @@ -2403,7 +2705,7 @@ mod tests { fn render_test_plan_marks_a_partial_and_carries_its_uncovered_text_verbatim() { let reqs = vec![Requirement { id: "FR-LIB-020".into(), - verify: 'I', + verify: vec!['I'], section: String::new(), }]; let partials = one_partial("FR-LIB-020", "namir-worker", FR_LIB_020_UNCOVERED); @@ -2422,7 +2724,7 @@ mod tests { fn render_test_plan_escapes_a_pipe_inside_the_uncovered_text() { let reqs = vec![Requirement { id: "FR-LIB-020".into(), - verify: 'I', + verify: vec!['I'], section: String::new(), }]; let partials = one_partial( @@ -2454,17 +2756,17 @@ mod tests { let reqs = vec![ Requirement { id: "FR-LIB-020".into(), - verify: 'I', + verify: vec!['I'], section: "5.10".into(), }, Requirement { id: "FR-IO-020".into(), - verify: 'M', + verify: vec!['M'], section: "5.13".into(), }, Requirement { id: "NFR-QUAL-020".into(), - verify: 'P', + verify: vec!['P'], section: "6.4".into(), }, ]; @@ -2516,7 +2818,7 @@ mod tests { fn req(id: &str, section: &str) -> Requirement { Requirement { id: id.into(), - verify: 'U', + verify: vec!['U'], section: section.into(), } } @@ -2837,7 +3139,7 @@ mod tests { fn render_test_plan_appends_the_section_block_after_the_requirement_table() { let reqs = vec![Requirement { id: "FR-CFG-010".into(), - verify: 'S', + verify: vec!['S'], section: "4".into(), }]; let report = build_report(&reqs, &[], &HashMap::new(), &HashMap::new()); From 2875a400f6a71a500e5b31e0c4e2a9e49d0fd19f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:02:47 +0000 Subject: [PATCH 29/44] Regenerate the test plan for compound Verify methods and FR-IO-070 Ten lines: the header's new sentence on compound methods, the eight compound rows' Verify column (M+S, U+I and so on), and FR-IO-070's rewritten uncovered: text from d236e00, which replaced the annotation without regenerating. FR-IN-020 and FR-STATE-040 also move their Covered by cells, the two compound methods whose codes span both evidence classes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- docs/03-test-plan.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/03-test-plan.md b/docs/03-test-plan.md index fd51da3..4efd23c 100644 --- a/docs/03-test-plan.md +++ b/docs/03-test-plan.md @@ -1,6 +1,6 @@ # Test plan -Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, FRS §10). Do not hand-edit -- regenerate instead. Maps every Must-priority requirement to how it is verified: a manual-test document (`Verify: M`) or the crate(s) whose test source carries a `trace:` annotation or matching test-function name for it (`Verify: U/I/G/B/S`). A `Verify: M` row resolves only when its document's own `**Result:` line records a clean pass -- a document recording `NOT EXECUTED`, a partial or a qualified pass, and a document carrying no verdict line at all, leave the requirement UNRESOLVED with the document still named (issue #34). A row marked "PARTIAL" carries a `// trace-partial:` tag whose artifact covers only part of the requirement: the text after it is that tag's mandatory `// uncovered:` line verbatim, naming the unspanned member and the milestone that closes it (D-23.1). A partial counts as coverage for this check's exit status and is not Done for §14's table. A requirement listed under "UNRESOLVED" has neither -- `cargo run -p xtask -- traceability` exits non-zero while any remain. CI's **required** step passes `--allow-uncovered` and gates on this file's freshness (and on §14's denominators) alone; the zero-uncovered half stays informational until it becomes required at M14's close-out (D-18.5) -- M9b's own close-out moved it there, having closed out without reaching it. +Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, FRS §10). Do not hand-edit -- regenerate instead. Maps every Must-priority requirement to how it is verified: a manual-test document (`Verify: M`) or the crate(s) whose test source carries a `trace:` annotation or matching test-function name for it (`Verify: U/I/G/B/S`). A method stating more than one code (`M+S`, `U+I`) is **compound**: every code it states has to resolve, and the cell carries one entry per class of evidence, joined with `+` in the order the FRS states them (issue #27). A `Verify: M` row resolves only when its document's own `**Result:` line records a clean pass -- a document recording `NOT EXECUTED`, a partial or a qualified pass, and a document carrying no verdict line at all, leave the requirement UNRESOLVED with the document still named (issue #34). A row marked "PARTIAL" carries a `// trace-partial:` tag whose artifact covers only part of the requirement: the text after it is that tag's mandatory `// uncovered:` line verbatim, naming the unspanned member and the milestone that closes it (D-23.1). A partial counts as coverage for this check's exit status and is not Done for §14's table. A requirement listed under "UNRESOLVED" has neither -- `cargo run -p xtask -- traceability` exits non-zero while any remain. CI's **required** step passes `--allow-uncovered` and gates on this file's freshness (and on §14's denominators) alone; the zero-uncovered half stays informational until it becomes required at M14's close-out (D-18.5) -- M9b's own close-out moved it there, having closed out without reaching it. | Requirement | Verify | Covered by | |---|---|---| @@ -8,7 +8,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | FR-CFG-020 | G | `namir-clap` | | FR-CFG-030 | I | **PARTIAL** — `ci`: FR-CFG-030 — the method's four clauses are two products x (installed alone in a clean environment, exercised there). Only Linux installs at all, `install.sh` places **both** products in one run so neither is installed *alone*, no Windows or macOS installer is executed by any job, and nothing is exercised afterwards: the standalone's exercising clause is the FRS's accepted limitation (no runner has an audio device), the plugin's is not accepted and stays open; closes M8; `xtask`: FR-CFG-030 — the artifact annotated here is xtask layering's compile-time dependency-edge lint over LAYERING_TABLE, which argues compile-time reachability and neither installs nor exercises either product. M14's bundle-and-inspect lane in ci.yml executes the Linux installer into a throwaway prefix, which is the first half of one platform's clause; the tag there carries what that still leaves; closes M8 | | FR-CHAIN-010 | I | `namir-engine` | -| FR-CHAIN-020 | U | `namir-engine` | +| FR-CHAIN-020 | U+I | `namir-engine` | | FR-CHAIN-030 | U | `namir-engine` | | FR-CHAIN-040 | U | `namir-engine` | | FR-CHAIN-050 | I | `namir-engine` | @@ -23,23 +23,23 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | FR-CLAP-060 | I | **PARTIAL** — `namir-clap`: FR-CLAP-060 — the click-free limb is unspanned, and it is a live defect rather than missing coverage: namir_engine::Chain::set_global_bypass flips a bool with no crossfade, so the transition this file locates to the frame completes in one sample (measured at ~0.5x the settled peak, by this file's last test) where FR-CHAIN-020's per-stage bypass fades over 15 ms; the fix belongs to Chain, in namir-engine, and this crate cannot make it; closes M8 | | FR-CLAP-070 | U | `namir-clap` | | FR-CLAP-080 | I | **PARTIAL** — `namir-clap`: FR-CLAP-080 — swept at 154 rates (both endpoints, the six standard rates, a 1 kHz grid and one fractional value), not every rate the requirement's range admits: `src/audio.rs` rounds the host's rate to an integer, so the set a host can present collapses onto ~147 900 distinct `SampleRate` values, of which this file reaches 154. The resource-loaded limb `loaded` adds — where D-9.2's `SlotResampler`, the one rate-dependent subsystem in the chain, is actually engaged — is narrower still: 8 of those 154, the two endpoints, the six standard rates and two off-grid values, since a rate there costs a model load and real inference rather than a pass-through block; closes M8 | -| FR-CLAP-090 | I | **PARTIAL** — `namir-clap`: FR-CLAP-090 — the B half of "I plus B", that N instances of one model use materially less memory than N separate copies, is measured by nothing: this crate's one bench (benches/plugin_instantiation.rs) times NFR-PERF-040's instantiation window, and no benchmark anywhere in the workspace measures memory at all; closes M8 | +| FR-CLAP-090 | I+B | **PARTIAL** — `namir-clap`: FR-CLAP-090 — the B half of "I plus B", that N instances of one model use materially less memory than N separate copies, is measured by nothing: this crate's one bench (benches/plugin_instantiation.rs) times NFR-PERF-040's instantiation window, and no benchmark anywhere in the workspace measures memory at all; closes M8 | | FR-CLAP-100 | I | **PARTIAL** — `namir-clap`: FR-CLAP-100 — the embedded-editor clause on macOS and Linux, where `is_api_supported` accepts only `GuiApiType::WIN32` with no `cfg` (issue #18), and `set_parent`, whose real embedding needs a live host window and stays in docs/manual-tests/fr-clap-100-gui-embedding.md; closes M8 | -| FR-CLAP-130 | S | **PARTIAL** — `namir-clap`: FR-CLAP-130 — of the three enumerated user actions only model loading and preset recall are driven for real by the I half (clap_host_rt_blocking.rs), library scanning standing in as a worker job that reads, hashes and parses a multi-megabyte file while holding the same instance mutex a scan's jobs take, because starting a real scan would erase the developer's own library index; and the S half this file adds is a whole-identifier name ban rather than a call-graph analysis, so it catches the introduction of a blocking primitive in an audio-thread region but not a helper defined elsewhere that blocks internally; closes M8 | +| FR-CLAP-130 | S+I | **PARTIAL** — `namir-clap`: FR-CLAP-130 — of the three enumerated user actions only model loading and preset recall are driven for real by the I half (clap_host_rt_blocking.rs), library scanning standing in as a worker job that reads, hashes and parses a multi-megabyte file while holding the same instance mutex a scan's jobs take, because starting a real scan would erase the developer's own library index; and the S half this file adds is a whole-identifier name ban rather than a call-graph analysis, so it catches the introduction of a blocking primitive in an audio-thread region but not a helper defined elsewhere that blocks internally; closes M8 | | FR-EQ-010 | U | `namir-engine` | | FR-EQ-020 | U | `namir-dsp` | | FR-EQ-030 | U | **PARTIAL** — `namir-engine`: FR-EQ-030 — all twelve of EqStage's parameters are now driven and measured against FR-PARAM-040's 20 ms-linear-ramp bound, and eight of them exceed it: the bypass/defeat toggles and the frequency-like parameters, whose smoothing FR-PARAM-040 states only as "the same audible standard". eq.enabled is 16.8x the bound with a transient 1.40x the settled range, eq.mid_freq_hz 3.4x, and eq.low_pass_freq_hz 2.3x. Whether that meets an audible standard is a judgement this test cannot make and lengthening D-9.9's coefficient ramp does not settle (see this test's own doc comment for the 20 ms re-measurement, which improves three rows and worsens two); closes M8 | | FR-ERR-010 | I | `namir-platform` | | FR-ERR-020 | S | `namir-core` | -| FR-ERR-030 | S | **PARTIAL** — `xtask`: FR-ERR-030 — the S half's logging limb only. The allocation limb is D-7.5's assert_no_alloc harness rather than this check, and since M14 that harness does reach namir-app's own audio callbacks, where it found two real allocations; the "diagnostics ... communicated to a non-real-time thread without blocking" clause is spanned by nothing; and the `plus I` half of the Verify line has no integration test driving a real audio callback with the process-global logger installed and asserting no record was emitted; closes M8 | +| FR-ERR-030 | S+I | **PARTIAL** — `xtask`: FR-ERR-030 — the S half's logging limb only. The allocation limb is D-7.5's assert_no_alloc harness rather than this check, and since M14 that harness does reach namir-app's own audio callbacks, where it found two real allocations; the "diagnostics ... communicated to a non-real-time thread without blocking" clause is spanned by nothing; and the `plus I` half of the Verify line has no integration test driving a real audio callback with the process-global logger installed and asserting no record was emitted; closes M8 | | FR-ERR-040 | I | **PARTIAL** — `namir-worker`: FR-ERR-040 — two of the subsystems the method's "each" quantifies over are outside this crate and unreached from here: the GUI thread, whose containment is namir-clap's gui.rs and reachable only through that crate's clack-host harness, and with it the requirement's second sentence, which scopes "shall contain such a fault and continue passing audio" to the plugin configuration specifically — the audio probe below is a bare AudioEngine, not a plugin instance driven by a host's process() call. Settings I/O is covered separately, in namir-app/tests/settings_faults.rs; closes M8 | | FR-ERR-060 | S | **PARTIAL** — `workspace`: FR-ERR-060 — the method's "no network-capable dependency is linked" remains a by-name deny list its own comment calls deliberately non-exhaustive: a network-capable crate not on it enters Cargo.lock with this gate green, and the compensating control is that xtask attribution fails until a human adds the new crate to THIRD-PARTY-NOTICES.md, which is review rather than a build-time classification; closes M8 | -| FR-ERR-070 | S | `workspace` | +| FR-ERR-070 | S+I | `workspace` | | FR-GATE-010 | U | `namir-engine` | | FR-GATE-020 | U | `namir-dsp` | | FR-GATE-030 | U | `namir-dsp` | | FR-IN-010 | U | `namir-engine` | -| FR-IN-020 | U | **PARTIAL** — `namir-dsp`: FR-IN-020 — the "M for the display" half of the Verify line has no artifact: there is no docs/manual-tests/fr-in-020-*.md, and namir_ui::MeterReading carries only peak_db and rms_db, so the peak-hold value TrimStage publishes reaches no UI field for any script to observe; closes M8 | +| FR-IN-020 | U+M | **PARTIAL** — `namir-dsp`: FR-IN-020 — the "M for the display" half of the Verify line has no artifact: there is no docs/manual-tests/fr-in-020-*.md, and namir_ui::MeterReading carries only peak_db and rms_db, so the peak-hold value TrimStage publishes reaches no UI field for any script to observe; closes M8 + **UNRESOLVED** | | FR-IN-030 | U | **PARTIAL** — `namir-engine`: FR-IN-030 — the "resettable by the user" clause is unbuilt as well as unverified: Meter::reset_clip has no caller outside its own unit test, TrimStage exposes no clip-reset parameter and its Stage::reset is the transport-stop path, and UiIntent carries no reset-clip variant; closes M8 | | FR-IO-010 | M | `docs/manual-tests/fr-io-010-device-enumeration.md` | | FR-IO-020 | M | `docs/manual-tests/fr-io-020-wasapi-exclusive-mode.md` | @@ -47,7 +47,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | FR-IO-040 | M | `docs/manual-tests/fr-io-010-device-enumeration.md` | | FR-IO-050 | M | **UNRESOLVED** — `docs/manual-tests/fr-io-050-latency-measurement.md` records `PARTIAL.` | | FR-IO-060 | I | **PARTIAL** — `namir-app`: FR-IO-060 — the "resettable by the user" clause has no path to exercise: XrunCounter::reset has no caller outside its own two unit tests and no UiIntent reaches it, and the running count surfaces only through an eprintln! rather than anywhere in the window; closes M8 | -| FR-IO-070 | I | **PARTIAL** — `namir-app`: FR-IO-070 — the method's named apparatus, a virtual device that can be made to fail on demand, does not exist and the tagged test opens no device, its whole body asserting that selecting from an empty slice is None, so device removal while in use, "stop the stream cleanly" and "allow the user to select another device" are all unexercised; closes M8 | +| FR-IO-070 | I | **PARTIAL** — `namir-app`: FR-IO-070 — "allow the user to select another device" is spanned only by the restart-mediated substitute below (`device_state::select_device` picking a replacement on the next launch); no in-session device chooser exists in either shell, so the clause as written is unimplemented (issue #26, roadmap §15 item 16) and no test can reach it. The failable device is also virtual, so what a real removal makes the OS and cpal do stays evidenced only by docs/manual-tests/fr-io-070-device-removal.md, whose steps 1 and 3 are still NOT EXECUTED; closes M8 | | FR-IO-080 | I | `namir-app` | | FR-IR-010 | U | `namir-ir` | | FR-IR-030 | U | `namir-ir` | @@ -88,7 +88,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | FR-STATE-010 | U | `namir-state` | | FR-STATE-020 | U | `namir-state` | | FR-STATE-030 | I | **PARTIAL** — `namir-worker`: FR-STATE-030 — the save clause and both directions of "interchangeable between the standalone application and the CLAP plugin" are unspanned: the tagged test recalls an in-memory State and never writes or names a preset, and no artifact loads an app-written .namirpreset into the plugin or a plugin-written blob into the app; closes M8 | -| FR-STATE-040 | M | `docs/manual-tests/fr-state-040-diffability-and-hand-editability.md` | +| FR-STATE-040 | M+S | `docs/manual-tests/fr-state-040-diffability-and-hand-editability.md` + `namir-state`, `xtask` | | FR-STATE-050 | I | `namir-worker` | | FR-STATE-060 | I | **PARTIAL** — `namir-worker`: FR-STATE-060 — the method's "save a project, restart the host, reopen" is executed by nothing: both scenarios here re-invoke this test binary and drive namir_worker::Instance directly, never loading namir-clap or calling its state extension, so what is proven is that a saved state reproduces the tone in a fresh process, not that a CLAP host's save/reopen round trip carries it. The "identity of the loaded model and IR files" clause closed at M14, in the path-referenced scenario beside this one; closes M8 | | FR-STATE-070 | I | **PARTIAL** — `namir-state`: FR-STATE-070 — the third member of the failure list, "with an option to locate it manually", is spanned by nothing and exists nowhere in the product: UiIntent carries no locate or browse variant and neither shell offers such a path, the only mention in the tree being a doc comment paraphrasing the requirement; closes M8 | @@ -127,7 +127,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | NFR-QUAL-040 | S | **PARTIAL** — `namir-ir`, `namir-nam`, `namir-state`: NFR-QUAL-040 — the method's "fuzz targets run in CI" is executed for three of the four targets: .github/workflows/fuzz.yml has a job per load_nam, read_state and probe_wav, and M14's load_ir — the only target that reaches decode, the MAX_LOAD_SECONDS clamp, resampling and FFT planning — has none, so the deep audio reader is fuzzable but not fuzzed continuously; closes M8 | | NFR-QUAL-050 | S | `ci` | | NFR-QUAL-060 | S | `ci` | -| NFR-RT-010 | S | **PARTIAL** — `namir-worker`: NFR-RT-010 — the requirement enumerates six properties and the harness detects one. assert_no_alloc sees heap allocation only; "no lock any non-real-time thread can hold", "no file or network I/O" and "no system call that may block" are asserted by nothing anywhere and rest on review, and the bounded-worst-case pair is covered only indirectly, by this file's MAX_BLOCK_MULTIPLE and by NFR-RT-040's own benchmark. Separately, the cpal data callbacks in namir-app/src/audio_io.rs are executed by no test, needing a real device — M14 closed the rest of that gap by putting stream.rs's real input and output callbacks under D-7.5's harness, which found and fixed two allocations there; closes M8 | +| NFR-RT-010 | S+I | **PARTIAL** — `namir-worker`: NFR-RT-010 — the requirement enumerates six properties and the harness detects one. assert_no_alloc sees heap allocation only; "no lock any non-real-time thread can hold", "no file or network I/O" and "no system call that may block" are asserted by nothing anywhere and rest on review, and the bounded-worst-case pair is covered only indirectly, by this file's MAX_BLOCK_MULTIPLE and by NFR-RT-040's own benchmark. Separately, the cpal data callbacks in namir-app/src/audio_io.rs are executed by no test, needing a real device — M14 closed the rest of that gap by putting stream.rs's real input and output callbacks under D-7.5's harness, which found and fixed two allocations there; closes M8 | | NFR-RT-020 | S | `namir-engine` | | NFR-RT-030 | B | **PARTIAL** — `namir-engine`: NFR-RT-030 — the "on any supported platform" clause. Both the assembled arms and the per-stage arms added at M14 assert the 10% budget wherever this binary is run, but one run measures one platform and D-2.4 certifies only the 02-architecture.md §2 reference machine (Windows 11, x86-64); nothing runs this on macOS or Linux, where DenormalGuard's own per-architecture degradation (D-7.4) is what would show up; closes M8 | | NFR-RT-040 | B | **PARTIAL** — `namir-engine`: NFR-RT-040 — all three variables the requirement names are varied and asserted here: nine content and parameter conditions (spread 1.9%) and every run's own two halves (worst drift 10.1%), against the contamination-immune estimator. What is only partly spanned is the statistic the Verify line actually names: raw p99.9 is computed and printed for all nine arms but compared across only the 3 of 9 that D-2.4 left quotable on the machine this has run on, the other six being contaminated. And that machine is not 02-architecture.md section 2's reference machine — the ratios this binary asserts are machine-independent in a way NFR-PERF-010's absolute budget is not, but no run on the reference machine has been performed. Both are closed by one quiet run there, not by more code; closes M8 | From 085bd85e44b71f83a1a4eb30ba37b22493e11dce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:08:43 +0000 Subject: [PATCH 30/44] Record how a compound Verify method is covered (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-23.1 gains the rule the gate now enforces: each code resolves against the evidence class it names, and all of them are required, so on a compound method a plain trace: asserts the source-class half in full while D-18.6 leaves the M half with the manual document. Two corrections to roadmap §15 item 21 as written, both recorded rather than quietly fixed: there are eight compound Musts, not six, and two of the six it lists are not compound at all -- a qualifier clause is not a second code. FR-IN-020's demotion is left standing and explained. It is the mechanism working: its own uncovered: field had said in prose for milestones that the display half has no manual document, and the tool now agrees with its author instead of contradicting them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- docs/02-architecture.md | 30 ++++++++++++++++++++++++++++++ docs/03-implementation-roadmap.md | 11 ++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/02-architecture.md b/docs/02-architecture.md index c5f3177..5b9ee86 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -3718,6 +3718,36 @@ No text above is rewritten and no decision changes: this note is the correction, clause 4, its Rationale and changelog 0.19 are all read subject to it. FR-NAM-030's closing milestone is unchanged at **M10**, where both annotations point. +*Consequence (added M15, 2026-08-29, from issue #27 — the gate now reads a compound `Verify:` +method as the set of codes it states).* Until M15 `xtask traceability` kept only the **first** code +of a method, so a requirement stating two was covered by satisfying either one. FR-STATE-040 +(`M plus S`) read **Done** on its manual document alone while the `S` half had no artifact in the +tree at all. The parser now reads every code — and the method text is the `*Verify:*` line **plus +its continuation lines**, which is load-bearing rather than tidy: NFR-RT-010's second code sits on a +wrapped line and was invisible to a line-at-a-time reader. + +**What a tag asserts on a compound method.** `build_report` resolves each code against the evidence +class that code names — a `docs/manual-tests/` document for `M`, a source annotation for +`U`/`I`/`G`/`B`/`S`, nothing for `Process` — and requires **all** of them. So on a compound method a +plain `// trace:` asserts the *source-class* half in full, exactly as D-18.6 leaves the `M` half's +traced artifact with the manual document; neither half alone covers the requirement, and +`check_partial_verify_code` refuses a `trace-partial:` only when **no** code could resolve through a +source annotation, which is what lets such a requirement record a gap at all. + +*The grammar is deliberately narrow, and two requirements turn on it.* A clause contributes a code +only where it *opens* with one, so NFR-PERF-010's `B, as a CI regression gate` and NFR-RT-020's +`S plus code review` stay **single**-code. Roadmap §15 item 21 listed both as compound; reading them +that way would invent codes the FRS never wrote. Eight Musts state a genuinely compound method: +FR-CHAIN-020, FR-IN-020, FR-STATE-040, FR-CLAP-090, FR-CLAP-130, FR-ERR-030, FR-ERR-070 and +NFR-RT-010. + +*One demotion is the mechanism working, and is left standing.* FR-IN-020 (`U for the measurement; +M for the display`) became uncovered, because no `docs/manual-tests/fr-in-020-*.md` exists — which +that requirement's own `uncovered:` field had already said in prose. The tool now agrees with the +tag's author rather than contradicting them, and the gap closes when someone can run the script: +it needs a human at a screen and a peak-hold surface `namir_ui::MeterReading` does not yet carry. + + **Decision D-23.2 (added M9's P0 decision pass, 2026-08-08)** — A **Must** requirement's status in `03-implementation-roadmap.md` §14 is adjudicated against **that requirement's own text and its own `*Verify:*` method** — never against whether an implementation exists, and never against `xtask diff --git a/docs/03-implementation-roadmap.md b/docs/03-implementation-roadmap.md index 616da01..bff5a3f 100644 --- a/docs/03-implementation-roadmap.md +++ b/docs/03-implementation-roadmap.md @@ -3079,7 +3079,16 @@ that happens to depend on them first. FR-CLAP-100 stays `**UNRESOLVED**` and unmet on two of three platforms through M14. One stale detail found while writing this and recorded rather than fixed: the requirement's ledger entry still books it to **M9b**, a milestone that has run, so whoever takes the decision re-books it. -21. ~~**`xtask traceability` keeps only the first code of a compound `Verify:` method, and for one +21. **Closed 2026-08-29 (issue #27), both halves built.** `xtask traceability` reads the whole + code set (D-23.1's M15 consequence note has the rule and the demotions); `namir-state`'s + `schema` module and `cargo run -p xtask -- schema` are FR-STATE-040's missing `S` artifact. + Two corrections to the item as written below: the count is **eight** compound Musts, not six — + it missed FR-CHAIN-020, FR-IN-020, FR-ERR-070 and NFR-RT-010 — and **two of the six it does + list are not compound at all**, NFR-PERF-010's `B, as a CI regression gate` and NFR-RT-020's + `S plus code review` being a single code with a qualifier. Still owed: a CI step running + `xtask schema`, with the README line `xtask ci-commands` requires in the same commit. + + ~~**`xtask traceability` keeps only the first code of a compound `Verify:` method, and for one requirement that silently hides an unexecuted half.** Raised 2026-08-12 at M9b, found while building FR-ERR-030's static check. **Six** Musts state a compound method: FR-STATE-040 (`M plus S (schema check)`), FR-CLAP-090 (`I plus B`), FR-CLAP-130 (`S plus I`), FR-ERR-030 From 1864c12b7423ea0f51ec02600ee103f68cbde2af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 17:24:31 +0000 Subject: [PATCH 31/44] Give the gesture workaround a tripwire, and stop the manifest lying (#144) One of the two clack workarounds would not have noticed a fixed library, which is the opposite of what this issue claimed of both. The set_size one does notice, verified from both sides of the ABI: clack's host side turns a false from the C ABI into Err(GuiError::SetSizeError), so the harness test asserting the refusal is swallowed fails the day the trampoline writes Ok(... .is_ok()). Its message already tells the reader to flip it. The gesture one did not. Its test reads events through UnknownEvent::as_event, which checks the raw header and is entirely independent of CoreEventSpace::from_unknown, and nothing else in the crate calls as_core_event() on a gesture event. A fixed 0.1.2 would have landed silently and the as_event detour would have stayed forever, unexplained. There is now a test asserting the decoder gap itself -- it fails the moment from_unknown gains the two arms, naming prokopyl/clack#97 and #99 and saying what to delete. Checked live rather than assumed: inverting the assertion fails it with three events in hand. The manifest comment claimed exact version pinning. It is the caret requirement ^0.1.1; what holds the version is the lockfile, so a bump is a Cargo.lock update that nothing in the manifest flags. Comment now says that, names both workarounds and the tests that go red, and records =0.1.1 as an open decision rather than taking it. The clack-host dev-dependency comment repeated the same falsehood and is corrected too. Upstream status recorded at each site: set_size unreported as of 2026-08-30; gesture decode is prokopyl/clack#97 with an unmerged fix in #99 targeting 0.2, which reaches no 0.1.1 dependent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-clap/Cargo.toml | 31 +++++++++-- crates/namir-clap/src/gui.rs | 9 +++- crates/namir-clap/src/params_ext.rs | 66 ++++++++++++++++++++++++ crates/namir-clap/tests/clap_host_gui.rs | 14 +++-- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/crates/namir-clap/Cargo.toml b/crates/namir-clap/Cargo.toml index e9cdf53..fcbff03 100644 --- a/crates/namir-clap/Cargo.toml +++ b/crates/namir-clap/Cargo.toml @@ -68,9 +68,29 @@ namir-worker = { path = "../namir-worker", version = "0.1.0" } namir-ui = { path = "../namir-ui", version = "0.1.0" } # D-14.2's decision, S-4-validated (`docs/02-architecture.md` §19: clap-validator 15/15, loads -# and runs in Reaper, GUI extension confirmed). Exact versions pinned to what -# `spikes/s4-clack-clap`'s own `Cargo.lock` resolved to, matching that spike's own comment on -# pre-1.0 API churn (R-2, retired but "managed by exact version pinning"). +# and runs in Reaper, GUI extension confirmed). The versions below are the ones +# `spikes/s4-clack-clap`'s own `Cargo.lock` resolved to, taken deliberately because of that +# spike's comment on pre-1.0 API churn (R-2, retired but "managed by exact version pinning"). +# +# **These requirements are not exact pins, and this comment claimed they were until issue #144.** +# `"0.1.1"` is the caret requirement `^0.1.1`: a published 0.1.2 would be taken by `cargo update` +# with nothing here changing. What actually holds the version is the committed `Cargo.lock`. +# Making them `=0.1.1` is a real option and the owner's open call (#144's third next step) -- +# noted here rather than taken. +# +# **So a clack version bump is a re-check, not a routine update.** Two workarounds in this crate +# exist only because clack 0.1.1 is wrong, both documented at their sites and both written to go +# red when the version moves: +# - `src/gui.rs`'s `set_size` refusal, which 0.1.1's plugin-side trampoline reports to the host +# as success. `tests/clap_host_gui.rs`'s +# `a_refused_set_size_changes_nothing_and_clack_0_1_1_swallows_the_refusal` asserts the +# swallowing through the real vtable, so a fixed clack fails it. Behind the non-default +# `host-ext-tests` feature, which CI runs as a required step. +# - `src/params_ext.rs`'s gesture assertions, which read `UnknownEvent::as_event` because +# 0.1.1's `CoreEventSpace::from_unknown` cannot decode the two gesture types +# (prokopyl/clack#97; the fix, prokopyl/clack#99, is unmerged and targets 0.2, so it reaches +# no 0.1.1 dependent). `clack_0_1_1_cannot_decode_a_gesture_event_through_core_event_space` +# in that module asserts the decoder gap itself, so a fixed clack fails it too. clack-plugin = "0.1.1" clack-extensions = { version = "0.1.1", features = [ "clack-plugin", @@ -163,8 +183,9 @@ assert_no_alloc = { version = "1.1.2", default-features = false, features = [ # (see `clack-extensions` 0.1.1's own `src/__doc_utils.rs:114-146`, which does exactly that). # # **Dev-dependency only -- never linked into a release build**, on the same terms as -# `assert_no_alloc` in `namir-engine` (§17). Pinned to exactly the 0.1.1 line `clack-plugin` and -# `clack-extensions` above are pinned to, per R-2's dated M9a note: the pre-1.0 churn residual is +# `assert_no_alloc` in `namir-engine` (§17). Held at the same 0.1.1 the shipped `clack-plugin` and +# `clack-extensions` above are held at -- by the lockfile, not by the requirement, exactly as those +# two are (see their comment, and #144) -- per R-2's dated M9a note: the pre-1.0 churn residual is # reopened here only on a dev-only surface, and only at the version the shipped side already uses. # # Features mirror `clack-extensions` 0.1.1's own dev-dependency on this crate (its diff --git a/crates/namir-clap/src/gui.rs b/crates/namir-clap/src/gui.rs index 7c8d856..a24cc22 100644 --- a/crates/namir-clap/src/gui.rs +++ b/crates/namir-clap/src/gui.rs @@ -211,11 +211,18 @@ impl<'a> PluginGuiImpl for NamirMainThread<'a> { /// call panicked, so an `Err` returned here becomes `true` at the C ABI. Every neighbouring /// method in that same file gets this right (`set_scale`, `show`, `hide` are all /// `Ok(...is_ok())`), so this is a defect in one function rather than the crate's convention. - /// The version is pinned exactly (D-14.2/R-2), so the fix is upstream's or a later pin's; /// [`accepts_size`] is factored out so the decision this plugin makes is testable and correct /// on the day the answer starts being transmitted, and /// `tests/clap_host_gui.rs` carries a live record of the swallowing. /// + /// **Upstream status (issue #144, as of 2026-08-30): not reported.** A search of + /// `prokopyl/clack` for `set_size` returns nothing, so there is no upstream ticket to track + /// and no released version to move to — crates.io publishes only 0.1.0 and 0.1.1. The fix is + /// one line in that trampoline (`Ok(...is_ok())`, as its siblings already read). Nor is the + /// version *pinned*: `Cargo.toml` declares `"0.1.1"`, i.e. `^0.1.1`, and it is the committed + /// `Cargo.lock` that holds it — so a `cargo update` that picks up a fixed 0.1.2 is what + /// retires this, and the host-harness test named above is what says so. + /// /// **Why refuse rather than become resizable.** FR-CLAP-110 (host-driven resize) is a *Should* /// this round declares out of scope (see this crate's `lib.rs`), and the fixed 960x640 is a /// deliberate, sufficient size: it is comfortably above FR-UI-080's 800x600 floor, and issue diff --git a/crates/namir-clap/src/params_ext.rs b/crates/namir-clap/src/params_ext.rs index 1d88938..530463e 100644 --- a/crates/namir-clap/src/params_ext.rs +++ b/crates/namir-clap/src/params_ext.rs @@ -340,6 +340,17 @@ mod tests { /// decoder rather than in what this plugin emits; checking the header's own `type_id` is both /// the accurate assertion and the one that will not silently start passing for the wrong /// reason if that decoder is ever fixed. + /// + /// **Upstream status (issue #144, as of 2026-08-30): reported as + /// [prokopyl/clack#97](https://github.com/prokopyl/clack/issues/97)** (opened 2026-08-16, + /// open — do not refile). A fix exists in flight as + /// [prokopyl/clack#99](https://github.com/prokopyl/clack/pull/99), adding exactly the two + /// missing arms, but it is unmerged, unreviewed and targets the 0.2 line, so it reaches no + /// 0.1.1 dependent; crates.io publishes only 0.1.0 and 0.1.1. + /// + /// **This test would not notice a fixed clack** — `as_event` reads the header and keeps + /// working either way, which is exactly why it was chosen. Noticing is + /// [`clack_0_1_1_cannot_decode_a_gesture_event_through_core_event_space`]'s job, below. #[test] fn a_gui_originated_change_comes_out_as_a_gesture_wrapped_automation_point() { use clack_plugin::events::event_types::{ @@ -387,6 +398,61 @@ mod tests { ); } + /// **The tripwire for [prokopyl/clack#97](https://github.com/prokopyl/clack/issues/97), and + /// the reason the test above can afford to be quiet about it.** + /// + /// `clack-common` 0.1.1's `CoreEventSpace::from_unknown` (`src/events/spaces/core.rs:66-84`) + /// has no arm for `ParamGestureBeginEvent::TYPE_ID` or `ParamGestureEndEvent::TYPE_ID`, so a + /// gesture event this crate emits — well-formed, as the first assertion in the loop below + /// re-establishes through `UnknownEvent::as_event` — decodes to `None` through + /// `as_core_event()`. + /// That is why `emit_gui_param_changes`' own test asserts through `as_event` and why + /// `crate::audio`'s input path can only ever match `CoreEventSpace::ParamValue`. + /// + /// Asserting the *gap itself* is what makes it retire on its own. The `as_event` assertions + /// keep passing once the decoder is fixed (issue #144: a `cargo update` to a fixed 0.1.2 is + /// all it takes, since `Cargo.toml`'s `"0.1.1"` is `^0.1.1` and the lockfile is what holds + /// the version), so without this test nothing anywhere would report that the workaround had + /// become unnecessary. When this fails, delete it, and prefer `as_core_event()` at both + /// sites. + #[test] + fn clack_0_1_1_cannot_decode_a_gesture_event_through_core_event_space() { + use clack_plugin::events::io::EventBuffer; + + let mirror = crate::param_mirror::ParamMirror::new(); + mirror.set_by_key_from_gui(namir_params::stages::trim::GAIN_DB.key, 4.5); + + let mut buffer = EventBuffer::with_capacity(8); + emit_gui_param_changes(&mirror, &mut buffer.as_output()); + let events: Vec<&clack_plugin::events::UnknownEvent> = buffer.iter().collect(); + assert_eq!(events.len(), 3, "begin + value + end"); + + // The one event in the trio 0.1.1 does decode, so a failure below is the decoder's two + // missing arms and not this test having lost its way to the event stream. + assert!( + matches!( + events[1].as_core_event(), + Some(CoreEventSpace::ParamValue(_)) + ), + "the value event decodes through CoreEventSpace in every version" + ); + + for (name, event) in [("begin", events[0]), ("end", events[2])] { + assert!( + event.as_event::().is_some() + || event.as_event::().is_some(), + "the gesture {name} event is well-formed: the header says what it is" + ); + assert!( + event.as_core_event().is_none(), + "clack 0.1.1's CoreEventSpace::from_unknown drops gesture events \ + (prokopyl/clack#97; fix in flight as prokopyl/clack#99, targeting 0.2). If this \ + now decodes, the lockfile has moved to a version that fixes it: delete this test \ + and read the gesture {name} event through as_core_event() instead of as_event" + ); + } + } + /// The other half of the same rule: a change that came *from* the host is not sent back to it. #[test] fn a_host_originated_change_produces_no_output_events() { diff --git a/crates/namir-clap/tests/clap_host_gui.rs b/crates/namir-clap/tests/clap_host_gui.rs index a73f782..83cc84f 100644 --- a/crates/namir-clap/tests/clap_host_gui.rs +++ b/crates/namir-clap/tests/clap_host_gui.rs @@ -427,11 +427,17 @@ fn declining_the_gui_renders_bit_identical_audio_to_opening_it() { /// the plugin's `Result` is wrapped as the closure's *success value*, so the boolean the host /// receives says only "the call did not panic". `set_scale`, `show` and `hide` in that same file /// are all `Ok(...is_ok())`, so this is one function's defect, not the crate's convention — and -/// this workspace pins clack exactly (D-14.2, R-2), so it is not this crate's to fix. +/// it is not this crate's to fix. /// -/// The day that pin moves to a version that transmits the answer, this test fails and says so, -/// which is exactly what a recorded gap is for. What *is* asserted unconditionally is the part -/// that matters to a host either way: nothing about the editor moved. +/// **Upstream status (issue #144, as of 2026-08-30): not reported.** Nothing in `prokopyl/clack` +/// mentions `set_size`, and crates.io has only 0.1.0 and 0.1.1, so there is neither a ticket to +/// track nor a release to move to. Note also what actually holds the version: `Cargo.toml`'s +/// `"0.1.1"` is `^0.1.1`, so the trigger is a `Cargo.lock` update, not a manifest edit. +/// +/// The day that lockfile moves to a version that transmits the answer, the first assertion below +/// fails and says so, which is exactly what a recorded gap is for. What *is* asserted +/// unconditionally is the part that matters to a host either way: nothing about the editor +/// moved. #[cfg(feature = "host-ext-tests")] #[test] fn a_refused_set_size_changes_nothing_and_clack_0_1_1_swallows_the_refusal() { From c45f2d28f935620f2538e836094407cc199eca87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 17:27:11 +0000 Subject: [PATCH 32/44] Open the window without sRGB when no framebuffer offers it (#143) Verified under Xvfb, not argued. Pre-fix, the smoke example dies at: baseview-0.2.2/src/platform/x11/visual_info.rs:28: Could not fetch framebuffer config: CreationFailed(NoValidFBConfig) #19 attributes this to GLX. It is sRGB. glxinfo on the same display reports direct rendering, GL 4.5 core and 240 GLXFBConfigs -- GLX is healthy -- with the sRGB flag clear on all 240 of them. glXChooseFBConfig asking for GLX_FRAMEBUFFER_SRGB_CAPABLE_ARB matches nothing; GLX is only the call that comes back empty. open_with_srgb_fallback retries once with srgb: false. baseview 0.2.2 has no fallible open -- both entry points end in rx.recv().unwrap().unwrap() -- so a caught panic is the only failure signal available, and the doc says so. The host moves behind an Arc> because egui-baseview takes state by value and the first attempt's copy dies with its window thread. Colour does not change. egui_glow disables FRAMEBUFFER_SRGB in prepare_painting whenever the extension exists, since egui's shader already emits gamma-encoded colour, so srgb: true only ever selected a framebuffer capable of a conversion egui then switched off. Measured: a captured frame paints panel_fill as exactly (27, 27, 27), byte-identical to Color32::from_gray(27). The A/B against a real sRGB framebuffer cannot be run here -- that is the config no headless server offers -- so that half is read from the source, and the fallback site says which half is which. Headless startup now prints two panic messages before it works. No temporary panic hook to suppress them: the hook is process-global and would briefly swallow a real panic from the audio thread. Still owed, and not done here: CI installs no Xvfb and runs no such example, which is the step that turns "needs a human at a screen" into "runs on every push". R-16's mechanism text and #19's body still carry the GLX misattribution. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- .../namir-ui/examples/manual_window_smoke.rs | 65 +++-- crates/namir-ui/src/app.rs | 249 ++++++++++++++++-- crates/namir-ui/src/lib.rs | 2 +- 3 files changed, 275 insertions(+), 41 deletions(-) diff --git a/crates/namir-ui/examples/manual_window_smoke.rs b/crates/namir-ui/examples/manual_window_smoke.rs index 84aeb85..cab6c84 100644 --- a/crates/namir-ui/examples/manual_window_smoke.rs +++ b/crates/namir-ui/examples/manual_window_smoke.rs @@ -13,6 +13,13 @@ //! throughout this crate specifically avoid depending on. Run it by hand (`cargo run --example //! manual_window_smoke -p namir-ui`) to visually confirm the crate actually paints, as opposed to //! merely laying out widgets correctly headlessly. +//! +//! Since issue #143 it needs no *physical* display: it opens through +//! `namir_ui::open_with_srgb_fallback`, so a software X server serves it. Measured, not assumed -- +//! under `Xvfb :99 -screen 0 1280x1024x24` on Mesa 25.2.8/llvmpipe this example panicked with +//! `Could not fetch framebuffer config: CreationFailed(NoValidFBConfig)` before that change and +//! renders its 90 frames and exits 0 after it. `DISPLAY=:99 cargo run --example +//! manual_window_smoke -p namir-ui` is the whole invocation. use std::path::PathBuf; @@ -119,31 +126,39 @@ fn main() { title: "Namir UI -- manual smoke test".to_string(), ..Default::default() }; - let mut host = SmokeHost { frames: 0 }; - let mut view = ViewState::default(); - - EguiWindow::open_blocking( - settings, - (), - |_ctx, _cmds, _state| { - println!("build: egui context created"); - }, - |_output, _viewport, _state| {}, - move |ui, _cmds, _state| { - let snapshot = host.snapshot(); - let mut intents = Vec::new(); - namir_ui::render(ui, &mut view, &snapshot, &mut intents); - for intent in intents { - host.dispatch(intent); - } - ui.ctx().request_repaint(); - - if host.frames >= FRAMES_BEFORE_CLOSE { - println!("rendered {} frames; closing", host.frames); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - }, - ); + + // Through `namir_ui::open_with_srgb_fallback`, exactly as `namir_ui::open_blocking` and + // `open_parented` do, so this example opens under a headless X server too (issue #143) -- + // which is the whole point of an unattended smoke test. Note what that costs: the closure may + // run twice, so the host and view state are built *inside* it rather than moved in from + // outside, since the first attempt's copies are dropped with `baseview`'s window thread. + namir_ui::open_with_srgb_fallback(settings, |settings| { + let mut host = SmokeHost { frames: 0 }; + let mut view = ViewState::default(); + + EguiWindow::open_blocking( + settings, + (), + |_ctx, _cmds, _state| { + println!("build: egui context created"); + }, + |_output, _viewport, _state| {}, + move |ui, _cmds, _state| { + let snapshot = host.snapshot(); + let mut intents = Vec::new(); + namir_ui::render(ui, &mut view, &snapshot, &mut intents); + for intent in intents { + host.dispatch(intent); + } + ui.ctx().request_repaint(); + + if host.frames >= FRAMES_BEFORE_CLOSE { + println!("rendered {} frames; closing", host.frames); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + }, + ); + }); println!("window closed cleanly"); } diff --git a/crates/namir-ui/src/app.rs b/crates/namir-ui/src/app.rs index 3e078e4..984cd96 100644 --- a/crates/namir-ui/src/app.rs +++ b/crates/namir-ui/src/app.rs @@ -4,6 +4,8 @@ //! crate's top doc comment for why a single type parameterized by *which `open_*` call wraps it* //! satisfies FR-UI-010 rather than two separate UIs. +use std::sync::{Arc, Mutex, PoisonError}; + use egui::{CentralPanel, Panel, ScrollArea, Ui}; use namir_params::global::{GLOBAL_BYPASS, OUTPUT_CEILING_DB}; use namir_state::ParamValues; @@ -309,10 +311,113 @@ fn default_window_size() -> baseview::dpi::Size { baseview::dpi::Size::Logical(baseview::dpi::LogicalSize::new(960.0, 640.0)) } +/// Opens a window through `open`, and if that attempt fails, opens it once more with sRGB +/// framebuffer selection turned off. +/// +/// # What this works around (issue #143) +/// +/// `baseview`'s default `GlConfig` sets `srgb: true`, which its `get_fb_attribs` passes to +/// `glXChooseFBConfig` as `GLX_FRAMEBUFFER_SRGB_CAPABLE_ARB, 1`. A software X server offers no +/// sRGB-capable framebuffer config at all, so that request matches nothing and +/// `find_best_visual_config_for_gl` panics on its own `.expect("Could not fetch framebuffer +/// config")`. Measured under Xvfb on Mesa 25.2.8/llvmpipe: GLX itself is entirely healthy there -- +/// direct rendering, a 4.5 core profile, 240 framebuffer configs -- and **not one** of those 240 +/// carries the sRGB flag. That is why the failure reads as a GLX problem (issue #19's original +/// diagnosis): GLX is merely the call that comes back empty. Retrying without the flag opens the +/// window on the very same display. +/// +/// Dropping the flag costs nothing visually on this stack, because nothing was using it: `egui_glow` +/// (0.35, the renderer `egui-baseview` 0.6 drives) calls `gl.disable(FRAMEBUFFER_SRGB)` in +/// `prepare_painting` on every frame it can, since egui's shader already emits gamma-encoded +/// colour and must not have the driver convert it again. So `srgb: true` only ever selected a +/// framebuffer *capable* of a conversion that egui then switched off. +/// +/// Measured as far as one machine can measure it: a frame rendered through the fallback and read +/// back off the X server (`xwd`) paints `egui::Visuals::dark()`'s `panel_fill` as exactly +/// `(27, 27, 27)` -- byte-identical to `Color32::from_gray(27)`, so no linear-to-sRGB conversion is +/// being applied on write. The A/B against an sRGB framebuffer cannot be run on the same display, +/// since that is precisely the config no headless X server offers; for that half the evidence is +/// `egui_glow`'s `disable(FRAMEBUFFER_SRGB)`, read rather than executed. +/// +/// # Why a caught panic is the failure signal +/// +/// `baseview` 0.2.2 has no fallible open: both `Window::open_blocking` and `Window::open_parented` +/// end in `rx.recv().unwrap().unwrap()`, so a window thread that dies during setup reaches the +/// calling thread as a panic and as nothing else. A retry therefore has to catch one. The catch is +/// narrower than it looks: a panic raised by a *frame* runs on `baseview`'s own window thread, +/// which `open_blocking` absorbs in its `thread.join().unwrap_or_else(..)`, so what arrives here is +/// a window that failed to open. +/// +/// A real display is unaffected -- its first attempt succeeds and `open` is called exactly once. +/// If the second attempt fails too, the failure was never about sRGB (no `DISPLAY` at all, say) +/// and its panic propagates unchanged rather than being swallowed. +/// +/// `open` is called at most twice, so it must build its own per-attempt window state; see +/// [`open_blocking`] for what a host that cannot simply be rebuilt does instead. +pub fn open_with_srgb_fallback( + settings: egui_baseview::EguiWindowSettings, + mut open: impl FnMut(egui_baseview::EguiWindowSettings) -> T, +) -> T { + // `AssertUnwindSafe` because nothing observable survives a failed attempt: `open` moves its own + // window state into `baseview`'s window thread, which drops it while unwinding, and the only + // value this function itself carries across the two attempts is `settings`, which it clones + // rather than mutates. + let first = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| open(settings.clone()))); + match first { + Ok(opened) => opened, + Err(_) => { + let mut fallback = settings; + fallback.graphics.gl_config.srgb = false; + // The two panic messages the default hook has just printed are alarming and, on a + // headless display, expected; say which is which rather than leaving them unexplained. + eprintln!( + "namir-ui: the window could not be opened with an sRGB-capable framebuffer; \ + retrying without one (expected under a headless X server -- see issue #143)." + ); + open(fallback) + } + } +} + +/// A [`UiHost`] both attempts of an [`open_with_srgb_fallback`] can share. +/// +/// `egui-baseview` takes the window's state **by value**, and the first attempt's state is moved +/// into `baseview`'s window thread and dropped when that thread unwinds -- so a host handed over +/// directly would be gone before the retry could use it, and `H` cannot simply be rebuilt: it is a +/// live bridge to a running engine, not data (see [`UiHost`]). Holding it behind a shared cell +/// keeps the one host alive across both attempts. +/// +/// The lock is uncontended: only one attempt is ever live, and within it only the window thread +/// ever takes the host. This is the UI thread, never the audio thread, so NFR-RT-010 has nothing +/// to say about a lock here. A poisoned lock is taken anyway rather than panicked on -- poisoning +/// means a frame already panicked, and a second panic on the way out would replace that failure's +/// message with a less informative one. +struct SharedHost(Arc>); + +impl UiHost for SharedHost { + fn snapshot(&mut self) -> UiSnapshot { + self.0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .snapshot() + } + + fn dispatch(&mut self, intent: UiIntent) { + self.0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .dispatch(intent); + } +} + /// Opens `host` in a standalone, blocking window -- `namir-app`'s use of FR-UI-010's one shared /// UI implementation. Blocks the calling thread until the window is closed (matching /// `egui_baseview::EguiWindow::open_blocking`'s own contract); `namir-app` is expected to call /// this from whatever thread it dedicates to the GUI. +/// +/// Goes through [`open_with_srgb_fallback`], so this opens a window under a headless X server too; +/// `host` is shared with the retry through a [`SharedHost`] rather than consumed by the first +/// attempt. pub fn open_blocking(title: impl Into, host: H) where H: UiHost + 'static, @@ -322,13 +427,16 @@ where size: default_window_size(), ..Default::default() }; - egui_baseview::EguiWindow::open_blocking( - settings, - NamirUi::new(host), - |_ctx, _cmds, _state: &mut NamirUi| {}, - |_output, _viewport, _state: &mut NamirUi| {}, - |ui, _cmds, state: &mut NamirUi| state.frame(ui), - ); + let host = Arc::new(Mutex::new(host)); + open_with_srgb_fallback(settings, |settings| { + egui_baseview::EguiWindow::open_blocking( + settings, + NamirUi::new(SharedHost(Arc::clone(&host))), + |_ctx, _cmds, _state: &mut NamirUi>| {}, + |_output, _viewport, _state: &mut NamirUi>| {}, + |ui, _cmds, state: &mut NamirUi>| state.frame(ui), + ); + }); } /// Opens `host` embedded in `parent`'s window -- `namir-clap`'s use of FR-UI-010's one shared UI @@ -336,6 +444,9 @@ where /// eventually supplies `parent` from; wiring a real CLAP plugin to this function is `namir-clap`'s /// job, not this crate's). Returns immediately with a handle the caller closes when the host asks /// the plugin to destroy its editor. +/// +/// Goes through [`open_with_srgb_fallback`] for the same reason [`open_blocking`] does, and shares +/// `host` with the retry the same way. pub fn open_parented(parent: &P, title: impl Into, host: H) -> baseview::WindowHandle where H: UiHost + 'static, @@ -346,14 +457,17 @@ where size: default_window_size(), ..Default::default() }; - egui_baseview::EguiWindow::open_parented( - parent, - settings, - NamirUi::new(host), - |_ctx, _cmds, _state: &mut NamirUi| {}, - |_output, _viewport, _state: &mut NamirUi| {}, - |ui, _cmds, state: &mut NamirUi| state.frame(ui), - ) + let host = Arc::new(Mutex::new(host)); + open_with_srgb_fallback(settings, |settings| { + egui_baseview::EguiWindow::open_parented( + parent, + settings, + NamirUi::new(SharedHost(Arc::clone(&host))), + |_ctx, _cmds, _state: &mut NamirUi>| {}, + |_output, _viewport, _state: &mut NamirUi>| {}, + |ui, _cmds, state: &mut NamirUi>| state.frame(ui), + ) + }) } #[cfg(test)] @@ -1020,4 +1134,109 @@ mod tests { ); } } + + /// The upstream default the whole sRGB fallback is premised on: `baseview`'s `GlConfig` asks + /// for an sRGB-capable framebuffer, and `egui-baseview` carries that default straight into + /// `EguiWindowSettings`. If a future bump flips that default, the retry below stops being a + /// fallback and becomes a second identical attempt -- this test is what says so out loud + /// rather than leaving a retry that quietly changes nothing. + #[test] + fn the_default_window_settings_ask_for_an_srgb_framebuffer() { + assert!( + egui_baseview::EguiWindowSettings::default() + .graphics + .gl_config + .srgb + ); + } + + /// A display that can satisfy the default config -- every real one -- is opened exactly once, + /// with the settings untouched. The fallback must cost a working machine nothing. + #[test] + fn a_window_that_opens_first_try_is_opened_once_and_unmodified() { + let mut attempts = Vec::new(); + let opened = + open_with_srgb_fallback(egui_baseview::EguiWindowSettings::default(), |settings| { + attempts.push(settings.graphics.gl_config.srgb); + "the window" + }); + assert_eq!(opened, "the window"); + assert_eq!(attempts, vec![true], "one attempt, sRGB left as it came in"); + } + + /// Issue #143's case: the first attempt dies the way `baseview` dies under Xvfb, and the retry + /// arrives with sRGB switched off and its window is the one returned. + #[test] + fn a_window_that_fails_to_open_is_retried_once_without_srgb() { + let mut attempts = Vec::new(); + let opened = + open_with_srgb_fallback(egui_baseview::EguiWindowSettings::default(), |settings| { + attempts.push(settings.graphics.gl_config.srgb); + if attempts.len() == 1 { + // Verbatim what baseview 0.2.2's `visual_info.rs:28` raises on a display whose + // framebuffer configs are none of them sRGB-capable. + panic!("Could not fetch framebuffer config: CreationFailed(NoValidFBConfig)"); + } + "the window" + }); + assert_eq!(opened, "the window"); + assert_eq!( + attempts, + vec![true, false], + "the first attempt as given, then one retry with sRGB off" + ); + } + + /// A failure that is not about sRGB -- no `DISPLAY` at all, say -- must still reach the caller. + /// Swallowing it would turn "no window" into a silent hang or an unexplained exit, which is + /// worse than the panic this issue started from. + #[test] + fn a_failure_that_survives_the_retry_is_not_swallowed() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let attempts = AtomicUsize::new(0); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + open_with_srgb_fallback(egui_baseview::EguiWindowSettings::default(), |_settings| { + attempts.fetch_add(1, Ordering::SeqCst); + panic!("no display of any kind"); + }) + })); + assert!(outcome.is_err(), "the second failure reached the caller"); + assert_eq!( + attempts.load(Ordering::SeqCst), + 2, + "tried the default config, then the fallback, then gave up" + ); + } + + /// The host survives a failed first attempt: `SharedHost` hands the *same* host to the retry, + /// which is the whole reason it exists (`egui-baseview` takes the window state by value, and + /// the first attempt's copy is dropped when `baseview`'s window thread unwinds). Driven + /// through a `SharedHost` rather than through a real window, since opening one needs a display. + #[test] + fn a_host_shared_across_attempts_is_the_same_host_both_times() { + let host = Arc::new(Mutex::new(RecordingHost::default())); + + let mut attempts = 0; + open_with_srgb_fallback(egui_baseview::EguiWindowSettings::default(), |_settings| { + attempts += 1; + // What each attempt does with the host it is handed, minus the window. + let mut shared = SharedHost(Arc::clone(&host)); + let _ = shared.snapshot(); + shared.dispatch(UiIntent::DismissNotice { id: attempts }); + if attempts == 1 { + panic!("Could not fetch framebuffer config: CreationFailed(NoValidFBConfig)"); + } + }); + + let dispatched = &host.lock().unwrap().dispatched; + assert_eq!( + dispatched, + &[ + UiIntent::DismissNotice { id: 1 }, + UiIntent::DismissNotice { id: 2 } + ], + "both attempts reached one and the same host, in order" + ); + } } diff --git a/crates/namir-ui/src/lib.rs b/crates/namir-ui/src/lib.rs index 654e132..4495878 100644 --- a/crates/namir-ui/src/lib.rs +++ b/crates/namir-ui/src/lib.rs @@ -79,7 +79,7 @@ mod library_view; mod meter; mod notices; -pub use app::{NamirUi, ViewState, open_blocking, open_parented, render}; +pub use app::{NamirUi, ViewState, open_blocking, open_parented, open_with_srgb_fallback, render}; pub use host::{ AudioModeStatus, AudioShareMode, LibrarySnapshot, MeterReading, PresetSummary, UiHost, UiIntent, UiNotice, UiSnapshot, From 37c7dc029447f698690e476e70c5f3df64b84e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 17:53:04 +0000 Subject: [PATCH 33/44] Crossfade the global bypass, and make a first load audible in its own fade (#142, #141) #142: set_global_bypass flipped a bool while FR-CHAIN-020's per-stage bypass fades over 15 ms, so the parameter a host is most likely to automate was the one that stepped. CrossCuttingState gains a one-pole blend and process has three shapes: settled engaged (byte for byte the old path), settled bypassed (dry copied out, no arithmetic, so FR-CHAIN-030's null stays bit-exact), and in transition. Through the real vtable the jump at the event's frame falls from ~50% of settled peak to 0.069%, about 720x; largest step is 99.93% of the linear 15 ms bound and it settles at exactly 4800 samples. Two things the issue did not anticipate. The ceiling is clamped on the wet term inside the blend, not on the blended block: clamping the blend would clip an above-ceiling dry signal for the whole fade and jump at completion, which is the click being removed. And an f32 one-pole never reaches its endpoint -- it stalls about 2e-5 short -- which would leave unity gain permanently ~94 dB approximate and break FR-CHAIN-030, so the blend snaps within one coefficient of target, a step no larger than an ordinary one. #141 was diagnosed, not guessed: the wet signal IS produced and the equal-power fade IS applied, then multiplied away, because mix_target read slots[active], which on a first load is None. So the fade only engaged when the handover finalised -- inside whichever block that fell in. Wet onset moves from frame 512/768/896/959 (block 512/256/64/1) to frame 2 at every block size. That also subsumes what chain_probes documented as a separate transient: the block-division dependence was 3.1e-2 (Nam) and 7.3e-2 (Ir), and is now exactly zero. Recorded rather than hidden: with a low-cut already engaged, IrStage's wet path carries a filtered copy of the dry input, so snapping there would step dry -> filter(dry) in one sample. That configuration keeps the 15 ms one-pole; the block-quantised onset is gone either way. FR-CLAP-060 stays trace-partial, against the agent's promotion. Both limbs it names are now executed, but the requirement asks for bypass "equivalent to FR-CHAIN-030", whose own content is the null against the delayed input, and nothing in that file loads a model -- the plugin's bypass is only ever observed at zero chain latency. The uncovered: text now says that instead. bypass_compensation_tracks_the_latency_a_resampled_model_adds_at_runtime nulls from the switch frame onward, which no crossfade can satisfy; it now settles first. That does not weaken #58's property: a compensation that failed to track the latency change fails the post-settle null too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-clap/src/audio.rs | 11 +- .../namir-clap/tests/clap_host_automation.rs | 377 +++++++++--- crates/namir-engine/src/chain.rs | 580 +++++++++++++++--- crates/namir-engine/src/chain_probes.rs | 249 +++++++- crates/namir-engine/src/stages/ir.rs | 204 +++++- crates/namir-engine/src/stages/nam.rs | 234 ++++++- docs/03-test-plan.md | 2 +- 7 files changed, 1436 insertions(+), 221 deletions(-) diff --git a/crates/namir-clap/src/audio.rs b/crates/namir-clap/src/audio.rs index 4dff3a2..d61d08f 100644 --- a/crates/namir-clap/src/audio.rs +++ b/crates/namir-clap/src/audio.rs @@ -46,10 +46,13 @@ //! the segment's range for exactly this reason — doing either over the whole block would have a //! later segment overwrite what an earlier one produced. //! -//! **The click-free half of FR-CLAP-060 is not this module's to close.** `namir_engine::Chain`'s -//! global bypass is a `bool` flip with no crossfade, where FR-CHAIN-020's *per-stage* bypass fades -//! over 15 ms; sample-accurate delivery is what makes that step land where the host asked, not what -//! smooths it. `tests/clap_host_automation.rs` measures the step and books the gap. +//! **The click-free half of FR-CLAP-060 was not this module's to close, and issue #142 closed it +//! elsewhere.** `namir_engine::Chain`'s global bypass used to be a `bool` flip with no crossfade, +//! where FR-CHAIN-020's *per-stage* bypass faded over 15 ms; sample-accurate delivery is what makes +//! a change land where the host asked, not what smooths it. `Chain` now runs the same 15 ms blend +//! for its global bypass, so what this module delivers sample-accurately is the fade's *start*, and +//! `tests/clap_host_automation.rs` measures both halves — the frame the transition begins on, and +//! the trajectory it takes from there. //! //! # FR-CLAP-040: latency reporting, and the restart CLAP's own contract requires //! diff --git a/crates/namir-clap/tests/clap_host_automation.rs b/crates/namir-clap/tests/clap_host_automation.rs index d44fd01..3053c21 100644 --- a/crates/namir-clap/tests/clap_host_automation.rs +++ b/crates/namir-clap/tests/clap_host_automation.rs @@ -1,6 +1,8 @@ -//! **FR-CLAP-060's sample-accuracy limb**: "the host's bypass is sample-accurate and click-free, -//! equivalent to FR-CHAIN-030", driven through the real C vtable with real -//! `clap_event_param_value` events carrying real sample offsets. +//! **FR-CLAP-060, both limbs**: "the host's bypass is sample-accurate and click-free, equivalent +//! to FR-CHAIN-030", driven through the real C vtable with real `clap_event_param_value` events +//! carrying real sample offsets. Sample accuracy is located to the frame at six offsets; click +//! freedom is measured as a blend trajectory, in both directions, against the same 15 ms linear +//! ramp bound `namir-engine`'s own per-stage bypass tests use. //! //! **Before writing a test against `support`, read that module's doc comment — in particular the //! HAZARD about `start_library_scan` and the developer's real library index.** Nothing here starts @@ -22,22 +24,25 @@ //! and the whole question is *where* the switch landed. The pre-M14 behaviour — apply every event //! before the block, never reading `header().time()` — fails the first half by `t` frames. //! -//! **Deliberately stated as "differs", not as "equals the input".** Today `Chain::set_global_bypass` -//! flips a `bool`, so the post-event frames *are* the input exactly; that is asserted, separately, -//! by [`a_bypassed_block_is_unity_gain_passthrough_not_the_processed_signal`], which is the test a -//! future click-free crossfade has to revisit. The tagged test above it is written so that it does -//! not have to: a crossfade that *begins* at frame `t` satisfies it unchanged, and one that begins -//! anywhere else does not. +//! **Deliberately stated as "differs", not as "equals the input".** That was written before issue +//! #142, when `Chain::set_global_bypass` flipped a `bool` and the post-event frames *were* the +//! input exactly — and it is why the tagged test needed no revisiting when the crossfade landed: a +//! fade that *begins* at frame `t` satisfies "differs at `t`, matches before it" unchanged, and one +//! that begins anywhere else does not. The bypassed frames are now the input exactly only once the +//! fade has settled, which is what +//! [`a_bypassed_block_is_unity_gain_passthrough_not_the_processed_signal`] waits for. //! //! # Why global bypass is the parameter under test rather than trim gain //! -//! `global.bypass` reaches `namir_engine::Chain::apply`, which flips one `bool` and takes effect on -//! the very next sample — so the transition's position is exactly readable. Every continuous -//! parameter in `REGISTRY` is declared `SmoothingCategory::GainLike` and ramps over ~20 ms, which -//! spreads a mis-timed application across hundreds of frames and would make a single-frame error -//! indistinguishable from the ramp itself. Testing the crisp parameter is what gives the assertion -//! its resolution; the block-splitting machinery it exercises (`src/audio.rs`'s `process`) is the -//! same for every parameter. +//! First, because FR-CLAP-060 is a requirement about this parameter and no other. Second, because +//! its position is the most exactly readable: `global.bypass` reaches `namir_engine::Chain::apply`, +//! whose effect — a `bool` before issue #142, that blend's *target* since — begins on the very next +//! sample, and the blend's range is the whole difference between a processed and a bypassed block, +//! so even its first frame departs from the reference by about a 720th of a factor of two. Every +//! continuous parameter in `REGISTRY` is declared `SmoothingCategory::GainLike` and ramps over +//! ~20 ms from wherever it happens to be, which is both slower and, at a small parameter change, +//! arbitrarily close to no departure at all. The block-splitting machinery this exercises +//! (`src/audio.rs`'s `process`) is the same for every parameter. //! //! Input trim is still involved, at +6 dB: with **nothing loaded** the six-stage chain is very //! nearly unity, so a bypassed block and a processed block would be almost the same buffer and the @@ -54,14 +59,32 @@ //! side rather than by reading that code: a bypassed block here is required to equal the *input*, //! and under the fallback it would equal the input at +6 dB. //! +//! # How click-freedom is measured (issue #142) +//! +//! Until #142 the transition this file locates to the frame was a genuine discontinuity: +//! `Chain::set_global_bypass` flipped a `bool` with no crossfade, where every *per-stage* bypass in +//! the chain (`GateStage`'s `mix`/`mix_target`/`mix_coeff`, FR-CHAIN-020) faded over 15 ms. The +//! global bypass — the one a host actually automates — was the only one that stepped. It now runs +//! the same 15 ms one-pole blend, and [`the_bypass_transition_is_a_crossfade_not_a_step`] is what +//! `the_bypass_transition_is_a_single_sample_step_today` became: the same event at the same +//! frame, with the bound inverted from "moves more than 10% of the settled peak in one sample" to +//! "never moves more than a linear 15 ms ramp would, in either direction". +//! +//! What makes that measurable from the host's side is that **the reference run is the wet signal**. +//! While a fade is in flight the chain runs its stages in both runs, on the same input, from the +//! same state, so the no-event reference is exactly the wet term of the blend; the input is the dry +//! term (this file loads nothing, so the chain reports zero latency and the compensation delay is +//! zero); and the blend position itself falls out by division — see [`inferred_mix`]. Every +//! assertion about the fade's *shape* is made on that inferred trajectory rather than on the audio, +//! so the tone's own slew never has to be subtracted from a click. +//! //! # What this file does not cover //! -//! **Click-freedom.** The transition this file locates to the frame is a genuine discontinuity: -//! `Chain::set_global_bypass` flips a `bool` with no crossfade, where every *per-stage* bypass in -//! the chain (`GateStage`'s `mix`/`mix_target`/`mix_coeff`, FR-CHAIN-020) fades over 15 ms. Its -//! magnitude is recorded here as a *measurement*, not as an approval — see -//! [`the_bypass_transition_is_a_single_sample_step_today`] — and the fix belongs in `namir-engine`, -//! not in this crate. The tagged test's `uncovered:` field says so. +//! **Bypass at a nonzero chain latency.** Nothing here loads a model or an IR, so the chain +//! reports zero latency throughout and FR-CHAIN-030's compensation delay is never exercised from +//! the plugin's side. That half is the engine's own — `namir_engine::chain`'s null test at +//! latencies 0/3/97, and `chain_probes`' resampled-model probe — and FR-CLAP-060 is a statement +//! about the adapter delivering the host's bypass to that mechanism, not a second copy of it. mod support; @@ -105,6 +128,24 @@ const TRIM_GAIN_DB: f32 = 6.0; /// anything is compared. const WARMUP_BLOCKS: usize = 8; +/// Blocks rendered after a bypass change before the chain is treated as settled on the far side of +/// it. `namir_engine::chain`'s blend is a 15 ms one-pole that takes its last step outright once the +/// remainder is one ordinary step wide, which is about 100 ms — 9.4 blocks at 48 kHz/512, so twelve +/// leaves margin without making the tests slow. +const CROSSFADE_SETTLE_BLOCKS: usize = 12; + +/// The per-sample bound every fade in this file is held to: the movement a **linear** ramp over the +/// blend's full range would make in one frame, at FR-CHAIN-020's 15 ms. Expressed on the blend +/// position itself (0 to 1) rather than on audio, so it is the same number whatever the signal is +/// doing. `namir_engine`'s `stages/gate.rs` holds its own per-stage bypass to the identical bound, +/// in the same words; a one-pole of the same time constant clears it by about 0.1%. +const MAX_MIX_STEP_PER_FRAME: f32 = 1.0 / (0.015 * DEFAULT_SAMPLE_RATE as f32); + +/// How far apart the dry and wet signals must be at a frame for [`inferred_mix`] to divide by their +/// difference. The probe tone crosses zero 24 times a block and both sides go with it; a twentieth +/// of [`AMPLITUDE`] keeps the ~13% of frames nearest each crossing out of the arithmetic. +const MIX_INFERENCE_FLOOR: f32 = AMPLITUDE / 20.0; + /// The offsets within a block at which the event under test is placed. Chosen to include both /// boundaries a splitter can get wrong (`0` — the whole block is post-event; `BLOCK - 1` — exactly /// one frame is), a mid-block value, and values that are multiples of nothing in particular. @@ -226,16 +267,75 @@ fn first_difference(a: &[f32], b: &[f32]) -> Option { a.iter().zip(b.iter()).position(|(x, y)| x != y) } +/// The blend position `namir_engine::chain`'s bypass crossfade must have been at, implied by one +/// frame of a run against its no-event reference. `0.0` = the chain's own output, `1.0` = the input +/// passed through. +/// +/// `out = wet * (1 - m) + dry * m`, where the reference run *is* `wet` (both runs render the same +/// stages on the same input from the same state for as long as the fade is in flight, and once it +/// settles the bypassed run is the input exactly, which the same formula still reports as `1.0`) +/// and the input *is* `dry` (nothing is loaded here, so the chain reports zero latency and +/// FR-CHAIN-030's compensation delay is zero). Inverting it recovers `m`. +/// +/// `None` where the two sides are too close for the division to carry information — see +/// [`MIX_INFERENCE_FLOOR`]. Nothing is asserted at those frames rather than something weak being +/// asserted at all of them. +fn inferred_mix(switched: f32, reference: f32, input: f32) -> Option { + let range = input - reference; + (range.abs() > MIX_INFERENCE_FLOOR).then(|| (switched - reference) / range) +} + +/// Every frame of `switched` at which [`inferred_mix`] has an answer, as `(frame, mix)`. +fn mix_trajectory(switched: &[f32], reference: &[f32], input: &[f32]) -> Vec<(usize, f32)> { + switched + .iter() + .zip(reference) + .zip(input) + .enumerate() + .filter_map(|(frame, ((switched, reference), input))| { + inferred_mix(*switched, *reference, *input).map(|mix| (frame, mix)) + }) + .collect() +} + +/// Asserts that no frame of `trajectory` moves the blend further than [`MAX_MIX_STEP_PER_FRAME`] +/// allows, scaling the allowance by the gap where frames were skipped for being too near a zero +/// crossing. Returns the total distance travelled, so a caller can also check the fade went +/// somewhere — a blend that never moves passes every step bound trivially. +fn assert_no_step_exceeds_a_15ms_ramp(trajectory: &[(usize, f32)], what: &str) -> f32 { + let mut travelled = 0.0f32; + for pair in trajectory.windows(2) { + let (previous_frame, previous_mix) = pair[0]; + let (frame, mix) = pair[1]; + let step = (mix - previous_mix).abs(); + travelled += step; + let allowed = (frame - previous_frame) as f32 * MAX_MIX_STEP_PER_FRAME * 1.01; + assert!( + step <= allowed, + "{what}: the blend moved {step} between frames {previous_frame} and {frame}, past the \ + {allowed} a linear 15 ms ramp would -- the bypass is stepping, not fading" + ); + } + travelled +} + /// FR-CLAP-060's sample-accuracy limb, at six offsets including both boundaries: the block is split /// at the event's own frame, neither earlier nor later. See this file's doc comment for the shape /// of the two-sided assertion and for why it is stated as "differs" rather than "equals the input". +/// +/// **The tag is plain, and it rests on two tests, not this one alone.** FR-CLAP-060 asks for a +/// bypass that is "sample-accurate *and* click-free"; this test is the first half and +/// [`the_bypass_transition_is_a_crossfade_not_a_step`], below, is the second. It was a +/// `trace-partial:` until issue #142, because the click-free half was not merely untested but +/// unmet — `Chain::set_global_bypass` flipped a `bool` — and the `uncovered:` field said so. The +/// fade landed in `namir-engine`, the measurement below inverted its bound, and the ledger entry +/// was retired by closing the gap it named rather than by promoting the tag over it. // trace-partial: FR-CLAP-060 -// uncovered: FR-CLAP-060 — the click-free limb is unspanned, and it is a live defect rather than -// uncovered: missing coverage: namir_engine::Chain::set_global_bypass flips a bool with no -// uncovered: crossfade, so the transition this file locates to the frame completes in one sample -// uncovered: (measured at ~0.5x the settled peak, by this file's last test) where FR-CHAIN-020's -// uncovered: per-stage bypass fades over 15 ms; the fix belongs to Chain, in namir-engine, and -// uncovered: this crate cannot make it; closes M8 +// uncovered: FR-CLAP-060 — the click-free and sample-accuracy limbs are both executed here, but +// uncovered: the requirement asks for bypass "equivalent to FR-CHAIN-030", and FR-CHAIN-030's own +// uncovered: content is the null against the *delayed* input: nothing in this file loads a model, +// uncovered: so the plugin's bypass is only ever observed at zero chain latency, and the +// uncovered: compensation limb is exercised engine-side alone; closes M8 #[test] fn host_bypass_automation_takes_effect_at_the_event_s_own_frame() { for split in SPLIT_OFFSETS { @@ -266,11 +366,19 @@ fn host_bypass_automation_takes_effect_at_the_event_s_own_frame() { } } -/// Two bypass events in one block: the block must be split twice, so the middle segment is -/// bypassed and both outer segments are not. +/// Two bypass events in one block: the block must be split twice, so the middle segment fades +/// toward the input and the segment after it turns around and fades back. /// /// A splitter that honours only the first event, or that applies both before the block, fails this /// where the single-event test above could still pass. +/// +/// **Rewritten for issue #142.** The middle segment used to be required to *be* the input, which +/// was true only while bypass was a one-sample switch; 200 frames is a quarter of the blend's time +/// constant, so it is now a rising blend rather than a settled one. What the two events produce is +/// a turning point, and the turning point is what is asserted: the inferred blend position rises +/// strictly across the first segment and falls strictly across the second, with the turn at the +/// second event's own frame. That is a stronger statement about the split than the old one — a +/// second event applied one block late, or not at all, leaves the trajectory rising to the end. #[test] fn two_automation_points_in_one_block_split_it_twice() { const ON_AT: usize = 100; @@ -301,18 +409,62 @@ fn two_automation_points_in_one_block_split_it_twice() { None, "frames before the first event must render as they do with no events" ); - assert_eq!( - first_difference(&switched[ON_AT..OFF_AT], &input[ON_AT..OFF_AT]), - None, - "frames between the two events must be the input at unity gain (FR-CHAIN-030)" + + // The stages run for the whole block in both runs -- 400 frames is nowhere near enough of the + // blend for the bypassed side to take over and stop them -- so the reference is the wet term + // throughout and `inferred_mix` is exact on both segments. + let trajectory = mix_trajectory(&switched, &reference, &input); + let rising: Vec<(usize, f32)> = trajectory + .iter() + .copied() + .filter(|(frame, _)| (ON_AT..OFF_AT).contains(frame)) + .collect(); + let falling: Vec<(usize, f32)> = trajectory + .iter() + .copied() + .filter(|(frame, _)| *frame >= OFF_AT) + .collect(); + + assert!( + rising + .first() + .expect("the first segment has frames to measure") + .1 + > 0.0, + "the blend must have started moving by the first measurable frame after the first event" ); - // After the bypass is released the stages run again, on state they did not advance while - // bypassed, so this segment is not comparable to `reference` sample for sample. What is - // assertable -- and is exactly what the second event is for -- is that it stops being the - // input, from its own first frame. - assert_ne!( - switched[OFF_AT], input[OFF_AT], - "the frame the second event names must already have the bypass released" + for pair in rising.windows(2) { + assert!( + pair[1].1 > pair[0].1, + "frames between the two events must fade *toward* the input: the blend fell from {} \ + at frame {} to {} at frame {}", + pair[0].1, + pair[0].0, + pair[1].1, + pair[1].0 + ); + } + for pair in falling.windows(2) { + assert!( + pair[1].1 < pair[0].1, + "frames after the second event must fade back: the blend rose from {} at frame {} to \ + {} at frame {}", + pair[0].1, + pair[0].0, + pair[1].1, + pair[1].0 + ); + } + assert!( + falling + .first() + .expect("the second segment has frames to measure") + .1 + < rising + .last() + .expect("the first segment has frames to measure") + .1, + "the turn must happen at the second event's own frame, not later" ); } @@ -323,24 +475,35 @@ fn two_automation_points_in_one_block_split_it_twice() { /// nominally bypassed. If a product path ever reached that state this block would come out at /// +6 dB, so this is the assertion that the shipped path does not — see this file's doc comment. /// -/// **This is the test a click-free global bypass has to revisit**, since a crossfade would make the -/// first few hundred frames after the switch a blend rather than the input exactly. The tagged test -/// above is written not to need revisiting. +/// **Revisited for issue #142, as its own doc comment predicted it would have to be.** The block +/// carrying the event is now a blend rather than the input, so the assertion moved to the far side +/// of the fade: a *settled* bypass must be the input exactly. That is not a weaker claim — it is +/// the one FR-CHAIN-030 makes, and `namir_engine::chain`'s blend snaps to its endpoint precisely so +/// "exactly" stays true rather than becoming "to within about 2e-5". #[test] fn a_bypassed_block_is_unity_gain_passthrough_not_the_processed_signal() { let mut rig = Rig::new(); let input = rig.input(); - let bypassed = rig.run_block(&one_event(&namir_params::global::GLOBAL_BYPASS, 1.0, 0)); - let processed = rig.run_block(&one_event(&namir_params::global::GLOBAL_BYPASS, 0.0, 0)); + rig.run_block(&one_event(&namir_params::global::GLOBAL_BYPASS, 1.0, 0)); + for _ in 0..CROSSFADE_SETTLE_BLOCKS { + rig.run_block(&EventBuffer::new()); + } + let bypassed = rig.run_block(&EventBuffer::new()); + + rig.run_block(&one_event(&namir_params::global::GLOBAL_BYPASS, 0.0, 0)); + for _ in 0..CROSSFADE_SETTLE_BLOCKS { + rig.run_block(&EventBuffer::new()); + } + let processed = rig.run_block(&EventBuffer::new()); rig.finish(); assert_eq!( first_difference(&bypassed, &input), None, - "a bypassed block must be the input at unity gain (FR-CHAIN-030); it is not, which is \ - what `Chain::process`'s no-crosscutting fallback -- running every stage while nominally \ - bypassed -- would produce" + "a settled bypassed block must be the input at unity gain (FR-CHAIN-030); it is not, \ + which is what `Chain::process`'s no-crosscutting fallback -- running every stage while \ + nominally bypassed -- would produce" ); let peak_bypassed = peak(&bypassed); @@ -353,40 +516,110 @@ fn a_bypassed_block_is_unity_gain_passthrough_not_the_processed_signal() { ); } -/// **A measurement, not an approval**: FR-CLAP-060's click-free limb is not met today, and this -/// records by how much, so a later fix has a before-figure to move. +/// **FR-CLAP-060's click-free limb, and what this file's +/// `the_bypass_transition_is_a_single_sample_step_today` became.** That test recorded the defect: +/// with `Chain::set_global_bypass` flipping a `bool`, the event's own frame moved by the full +/// difference between the processed and bypassed renderings — +/// measured at about half the settled peak — and it asserted *that*, so a later fix would have a +/// before-figure to move. Issue #142 moved it. The bound is inverted here rather than deleted. +/// +/// The event still lands on a peak of the probe tone ([`PHASE_FRAMES`]'s own doc comment), where +/// the signal's own sample-to-sample movement is smallest and the whole of any observed jump is the +/// bypass. Three things are asserted, in the order they matter: +/// +/// 1. the first frame moves by a fraction of what it used to — the same measurement, three orders +/// of magnitude smaller; +/// 2. no frame of *either* transition moves the blend further than a linear 15 ms ramp would, +/// which is `namir_engine`'s own per-stage bypass bound (`stages/gate.rs`) applied to the global +/// one through the real C vtable; +/// 3. both transitions actually travel — a blend that never moves would satisfy 1 and 2 vacuously. /// -/// The event is placed on a peak of the probe tone ([`PHASE_FRAMES`]'s own doc comment), where the -/// signal's genuine sample-to-sample movement is at its smallest and the whole of the observed jump -/// is the bypass. Today the transition completes in one sample, so the jump is the full difference -/// between the processed and bypassed renderings — about half the settled peak. A 15 ms one-pole -/// crossfade of the kind `GateStage` already runs would move roughly 1/720 of that in the first -/// sample, three orders of magnitude under the bound below. +/// **Both directions, and the release deliberately happens before the engagement completes.** Once +/// the fade settles at fully bypassed the chain stops running its stages, so their state stops +/// advancing and the no-event reference stops being this run's wet signal; releasing while the +/// blend is still in flight keeps both runs rendering the same stages from the same state, which is +/// what makes [`inferred_mix`] exact in both directions. That the *settled* endpoints are reached +/// exactly is [`a_bypassed_block_is_unity_gain_passthrough_not_the_processed_signal`]'s assertion. +/// +/// Untagged, deliberately: this is one of FR-CLAP-060's two limbs and the requirement's tag sits +/// on the other, at [`host_bypass_automation_takes_effect_at_the_event_s_own_frame`], which says +/// so in its own doc comment. #[test] -fn the_bypass_transition_is_a_single_sample_step_today() { +fn the_bypass_transition_is_a_crossfade_not_a_step() { /// A multiple of the tone's 48-frame period, so the event lands on a peak. - const AT: usize = 240; - + const ENGAGE_AT: usize = 240; + /// Blocks rendered while the engagement fades before it is released, chosen to leave the blend + /// well short of settling (two blocks is ~21 ms against a 15 ms time constant, so it is around + /// three quarters of the way across) — see this test's doc comment for why that matters. + const BLOCKS_BEFORE_RELEASE: usize = 2; + /// Blocks rendered after the release, enough to watch the trajectory come back down. + const BLOCKS_AFTER_RELEASE: usize = 2; + const BLOCKS: usize = 1 + BLOCKS_BEFORE_RELEASE + BLOCKS_AFTER_RELEASE; + + // Reference: the identical rig driven through the identical block sequence, no events at all. let mut reference_rig = Rig::new(); - let reference = reference_rig.run_block(&EventBuffer::new()); + let reference: Vec> = (0..BLOCKS) + .map(|_| reference_rig.run_block(&EventBuffer::new())) + .collect(); + let input = reference_rig.input(); reference_rig.finish(); let mut rig = Rig::new(); - let switched = rig.run_block(&one_event( + let mut switched = Vec::with_capacity(BLOCKS); + switched.push(rig.run_block(&one_event( &namir_params::global::GLOBAL_BYPASS, 1.0, - AT as u32, - )); + ENGAGE_AT as u32, + ))); + for _ in 0..BLOCKS_BEFORE_RELEASE { + switched.push(rig.run_block(&EventBuffer::new())); + } + switched.push(rig.run_block(&one_event(&namir_params::global::GLOBAL_BYPASS, 0.0, 0))); + for _ in 0..BLOCKS_AFTER_RELEASE - 1 { + switched.push(rig.run_block(&EventBuffer::new())); + } rig.finish(); - let settled_peak = peak(&reference); - let jump = (switched[AT] - reference[AT]).abs(); + // 1: the measurement the old test made, with the inequality the other way round. + let settled_peak = peak(&reference[0]); + let jump = (switched[0][ENGAGE_AT] - reference[0][ENGAGE_AT]).abs(); + assert!( + jump <= settled_peak * 0.01, + "the bypass transition still completes in something close to one sample: frame \ + {ENGAGE_AT} moved {jump} against a settled peak of {settled_peak}, where a 15 ms \ + one-pole moves about 1/720 of the blend's range in its first frame" + ); + + // 2 and 3, on each transition separately: the engagement runs from the event to the release, + // the release from there to the end. + let engaging: Vec<(usize, f32)> = (0..=BLOCKS_BEFORE_RELEASE) + .flat_map(|block| { + let offset = block * BLOCK as usize; + mix_trajectory(&switched[block], &reference[block], &input) + .into_iter() + .map(move |(frame, mix)| (offset + frame, mix)) + }) + .filter(|(frame, _)| *frame >= ENGAGE_AT) + .collect(); + let releasing: Vec<(usize, f32)> = (BLOCKS_BEFORE_RELEASE + 1..BLOCKS) + .flat_map(|block| { + let offset = block * BLOCK as usize; + mix_trajectory(&switched[block], &reference[block], &input) + .into_iter() + .map(move |(frame, mix)| (offset + frame, mix)) + }) + .collect(); + + let engaged_distance = assert_no_step_exceeds_a_15ms_ramp(&engaging, "engaging"); + let released_distance = assert_no_step_exceeds_a_15ms_ramp(&releasing, "releasing"); + assert!( + engaged_distance > 0.5, + "the blend travelled only {engaged_distance} of its range while engaging; a 15 ms \ + one-pole covers about three quarters of it in the {BLOCKS_BEFORE_RELEASE} blocks this \ + waits, and a blend that never moves passes every step bound above vacuously" + ); assert!( - jump > settled_peak * 0.1, - "the bypass transition no longer completes in one sample (it moved {jump} against a \ - settled peak of {settled_peak}). If `namir_engine::Chain` has learned FR-CHAIN-020's \ - crossfade, this test has done its job: delete it, revisit \ - `a_bypassed_block_is_unity_gain_passthrough_not_the_processed_signal`, and promote \ - FR-CLAP-060's tag rather than loosening the bound" + released_distance > 0.25, + "the blend travelled only {released_distance} of its range while releasing" ); } diff --git a/crates/namir-engine/src/chain.rs b/crates/namir-engine/src/chain.rs index 91cc049..c1839ba 100644 --- a/crates/namir-engine/src/chain.rs +++ b/crates/namir-engine/src/chain.rs @@ -48,6 +48,10 @@ pub struct Chain { /// FR-CHAIN-030: when `true`, `process` routes the block to the output instead of running /// `stages` — unconditionally, whether or not `cross_cutting` is `Some` (issue #36). RT-safe /// to flip (see `set_global_bypass`) since it is read, never allocated, on the audio thread. + /// + /// Since issue #142 this is the *target* of `CrossCuttingState::mix`'s 15 ms crossfade rather + /// than a routing switch read directly per block; on an unprepared chain, which has no blend + /// state, it still routes outright. global_bypass: bool, /// FR-CHAIN-090's ceiling, already converted to a linear multiplier (so `process` never calls /// `db_to_linear` itself — that conversion happens once, in `set_output_ceiling_db`, off the @@ -80,9 +84,28 @@ pub struct Chain { /// than allowed to allocate or panic (D-16.3) — see [`DelayLine::run`]. const MAX_BYPASS_COMPENSATION_MS: f64 = 250.0; +/// Time constant of the dry/wet blend [`Chain::process`] runs across a global-bypass change +/// (issue #142), in milliseconds. +/// +/// **The same 15 ms figure every *per-stage* bypass already uses** — `stages/gate.rs`, +/// `stages/nam.rs` and `stages/ir.rs` each declare a `BYPASS_CROSSFADE_TIME_CONSTANT_MS` of 15.0 +/// for FR-CHAIN-020's click-free per-stage bypass. Before #142 the global bypass, which is the one +/// a host actually automates, was the only one in the chain that stepped: `set_global_bypass` +/// flipped a `bool` and `process` switched paths on it between one sample and the next. The +/// internal inconsistency is what made that a defect rather than a scope decision, so the fix +/// takes the figure the rest of the chain already agreed on rather than inventing a second one. +/// +/// Deliberately *not* pulled out into a shared constant in `namir-dsp` or `namir-params`: the +/// per-stage figure is each stage's own documented engineering default (see `gate.rs`'s note that +/// it is "not derived from an FRS requirement"), and hoisting four independent defaults into one +/// knob would assert a coupling nothing has asked for. Matching the value is the point; sharing +/// the definition is not. +const BYPASS_CROSSFADE_TIME_CONSTANT_MS: f64 = 15.0; + /// One channel's bypass-compensation delay: a fixed-capacity circular buffer, written on **every** -/// block (both paths — see [`CrossCuttingState::run_delay`]) and read back `delay` samples late -/// only while bypass is engaged. +/// block (both paths — see [`CrossCuttingState::capture_dry`]) and read back `delay` samples late +/// whenever the bypass side of the blend contributes to the block at all — which, since issue +/// #142's crossfade, is a window 15 ms wider than "while bypass is engaged" at each end. /// /// A circular `Vec` rather than the `VecDeque` this used to be, because the delay is now a /// per-block input rather than a constant fixed at preparation: a `VecDeque` expresses "delay by @@ -106,36 +129,55 @@ impl DelayLine { } } - /// Pushes every sample of `channel` into the line, in order, and — when `emit_delayed` — also - /// replaces each with the sample written `delay` positions earlier. + /// Pushes every sample of `input` into the line, in order, and — when `dry` is `Some` — writes + /// the sample recorded `delay` positions earlier into it. /// - /// **RT-safe:** no allocation, no branch whose bound depends on anything but `channel.len()`, + /// **Writes the delayed signal somewhere else rather than over `input` (issue #142).** Until + /// the global bypass learned to crossfade, the only two states were "bypassed" (emit delayed, + /// in place) and "not" (record only), so the line could overwrite the block it was handed. + /// A blend needs *both* the delayed dry and the stages' own output for the same frames, and + /// the stages must be fed the undelayed input they have always been fed, so the dry copy now + /// goes to caller-owned scratch (`CrossCuttingState::dry`, sized at preparation). + /// + /// **RT-safe:** no allocation, no branch whose bound depends on anything but `input.len()`, /// and one modulo for the whole block rather than one per sample. A `delay` above what this /// line was sized for is clamped rather than allowed to index out of bounds (D-16.3: degrade, /// don't panic on the audio thread); see [`MAX_BYPASS_COMPENSATION_MS`] for why that cannot /// happen for any chain this project ships. - fn run(&mut self, channel: &mut [f32], delay: usize, emit_delayed: bool) { + fn run(&mut self, input: &[f32], delay: usize, dry: Option<&mut [f32]>) { let cap = self.buf.len(); let delay = delay.min(cap - 1); let mut write = self.write; // Trails `write` by `delay`, so the value read at each step is the one written `delay` // steps ago. At `delay == 0` the two indices coincide and the read would be stale by a - // whole buffer — hence the `delay > 0` guard below; a zero-delay bypass wants the input + // whole buffer — hence the `delay > 0` arm below; a zero-delay bypass wants the input // unchanged anyway. let mut read = (write + cap - delay) % cap; - for sample in channel.iter_mut() { - let delayed = self.buf[read]; - self.buf[write] = *sample; - if emit_delayed && delay > 0 { - *sample = delayed; - } - write += 1; - if write == cap { - write = 0; + match dry { + Some(dry) => { + for (sample, dry) in input.iter().zip(dry.iter_mut()) { + *dry = if delay > 0 { self.buf[read] } else { *sample }; + self.buf[write] = *sample; + write += 1; + if write == cap { + write = 0; + } + read += 1; + if read == cap { + read = 0; + } + } } - read += 1; - if read == cap { - read = 0; + // Record-only: this block's output owes the bypass side nothing, but the line is + // still fed (issue #59) so the next one that does can reach back into real signal. + None => { + for sample in input { + self.buf[write] = *sample; + write += 1; + if write == cap { + write = 0; + } + } } } self.write = write; @@ -151,12 +193,37 @@ struct CrossCuttingState { /// One line per channel (`ctx.channel_config().output_channels()` many — `stage_io.rs`'s own /// doc comment: `StageIo`'s channel count is fixed for the whole chain to that figure). delay_lines: Vec, + /// Per-channel scratch holding *this* block's delayed dry signal, so the blend has something + /// to fade the stages' output against. One `Vec` per output channel, each sized to + /// `ctx.max_block_size()` in `prepare_crosscutting`; never resized in `process`. Exactly the + /// shape — and the reason — `stages/gate.rs`'s own `dry` field has. + dry: Vec>, + /// Current blend position: `0.0` = fully engaged (the block is the stage chain's output), + /// `1.0` = fully bypassed (the block is the delayed dry). Advances toward its target by + /// `mix_coeff` each sample, never jumps (issue #142). + /// + /// **One `f32`, not one per channel**, and that is deliberate: every channel recomputes the + /// same trajectory from this same starting value inside [`Self::blend`] and only the last + /// channel's endpoint is committed back here, so the channels stay in phase by construction + /// rather than by four separate states happening to agree. `stages/gate.rs`'s `mix` carries + /// the identical convention and its `process` says so in as many words. + mix: f32, + /// One-pole coefficient for `mix`, computed once in `prepare_crosscutting` from + /// [`BYPASS_CROSSFADE_TIME_CONSTANT_MS`] and the sample rate. + mix_coeff: f32, + /// `false` until the first `process` call after preparation, which snaps `mix` to its target + /// instead of fading into it — see that call's own comment for why (there is no previously + /// rendered sample for a fade to be continuous with). + started: bool, /// The context `prepare_crosscutting` was called with. See [`Chain::prepared_for`]. prepared_for: crate::prepare::PrepareContext, } impl CrossCuttingState { - /// FR-CHAIN-030's bypass path, and its always-on other half. + /// FR-CHAIN-030's bypass path, and its always-on other half: feeds every channel's delay line + /// from the block as handed in, and — when `capture` — leaves that channel's delayed copy in + /// [`Self::dry`] for [`Self::emit_dry`] or [`Self::blend`] to use. The block itself is left + /// untouched, so the stages still receive the undelayed input they always have. /// /// **Every block feeds the line, whether bypass is engaged or not (issue #59).** Writing it /// only while bypassed left it holding whatever the *last* bypass period ended with (zeros, @@ -170,17 +237,79 @@ impl CrossCuttingState { /// `delay` is read from the chain's *current* `latency_samples()` on every block rather than /// cached at preparation, so a model change that alters the reported latency (FR-CLAP-040) /// moves the compensation with it — issue #58. - fn run_delay(&mut self, io: &mut StageIo<'_>, delay: usize, bypassed: bool) { - if delay == 0 && !bypassed { - // Nothing to record and nothing to emit: the line can only ever hand back what it is - // given, so skipping it is not a state divergence. + fn capture_dry(&mut self, io: &mut StageIo<'_>, delay: usize, capture: bool) { + if delay == 0 && !capture { + // Nothing to record and nothing to hand back: the line can only ever return what it + // is given, so skipping it is not a state divergence. return; } - for (line, channel) in self.delay_lines.iter_mut().zip(io.channels_mut()) { - line.run(channel, delay, bypassed); + let frames = io.frames(); + for ((line, dry), channel) in self + .delay_lines + .iter_mut() + .zip(self.dry.iter_mut()) + .zip(io.channels_mut()) + { + let dry = if capture { + Some(&mut dry[..frames]) + } else { + None + }; + line.run(channel, delay, dry); + } + } + + /// The settled bypass path: this block *is* the delayed dry, copied over the block verbatim. + /// + /// Deliberately a copy rather than [`Self::blend`] evaluated at `mix == 1.0`: the two agree + /// arithmetically, but a copy puts nothing at all between the delay line and the output, so + /// FR-CHAIN-030's null test still nulls to the bit and the settled path is exactly the one + /// that shipped before issue #142's crossfade existed. + fn emit_dry(&self, io: &mut StageIo<'_>) { + let frames = io.frames(); + for (dry, channel) in self.dry.iter().zip(io.channels_mut()) { + channel.copy_from_slice(&dry[..frames]); } } + /// Issue #142's crossfade: replaces the block (the stages' output, "wet") with its per-sample + /// blend against [`Self::dry`], `mix` advancing one one-pole step per sample toward `target`. + /// + /// **The ceiling is applied to the wet term inside the blend, not to the blended result.** + /// Issue #61 established that FR-CHAIN-090's ceiling is a statement about the output stage, + /// which the bypass path does not run, and that FR-CHAIN-030's unity gain wins where the two + /// collide. Clamping the *blend* would honour neither endpoint: a dry signal above the + /// ceiling would be clipped for the whole fade and then jump to its true amplitude the sample + /// the fade completed — the very click this method exists to remove. Clamping the wet term + /// first is continuous in `mix` and exact at both ends: at `0.0` the block is the clamped + /// stage output, at `1.0` it is the untouched dry. + /// + /// **Why it snaps.** An `f32` one-pole never reaches its target: the increment falls below + /// half an ulp and the addition stops moving, some 2e-5 short at 48 kHz — which would leave + /// FR-CHAIN-030's "routes input to output with unity gain" permanently ~94 dB approximate. + /// So the last step is taken outright once the remainder is no larger than `mix_coeff`, one + /// ordinary step of the fade itself, which bounds the snap by the fade's own steepest sample + /// and keeps it from being the discontinuity. + fn blend(&mut self, io: &mut StageIo<'_>, target: f32, ceiling_linear: f32) { + let frames = io.frames(); + let start = self.mix; + let mut end = start; + for (dry, wet) in self.dry.iter().zip(io.channels_mut()) { + let mut m = start; + for (wet, dry) in wet.iter_mut().zip(dry[..frames].iter()) { + m += self.mix_coeff * (target - m); + let clamped = wet.clamp(-ceiling_linear, ceiling_linear); + *wet = clamped * (1.0 - m) + dry * m; + } + end = m; + } + self.mix = if (target - end).abs() <= self.mix_coeff { + target + } else { + end + }; + } + /// FR-CHAIN-080/090, run once per `process` call after either the stage loop or the bypass /// path has produced this block's samples. First scans for any non-finite sample: if found, /// the *entire* block — every channel, every sample, not just the offending one — is @@ -199,6 +328,10 @@ impl CrossCuttingState { /// for a gain of anything else. The NaN scan still runs: fault containment (FR-CHAIN-080) is /// about not sending a damaging non-finite sample to hardware, which the bypass path can do /// just as easily as the stage path. + /// + /// It is also false *during* issue #142's crossfade, for a different reason: there the + /// ceiling has already been applied, to the wet term alone, inside [`Self::blend`] — see that + /// method's doc comment for why the clamp goes there rather than over the blended block. fn scan_and_clamp( &mut self, io: &mut StageIo<'_>, @@ -274,6 +407,13 @@ impl Chain { /// dBFS, and must keep doing so unmodified). pub fn prepare_crosscutting(&mut self, ctx: &crate::prepare::PrepareContext) { let channel_count = ctx.channel_config().output_channels() as usize; + // Issue #142's blend: one dry scratch buffer per channel, `max_block_size` long, plus the + // one-pole coefficient for a `BYPASS_CROSSFADE_TIME_CONSTANT_MS` fade at this rate. Both + // are computed here, off the audio thread, exactly as every stage's own bypass blend + // computes them in its `prepare` (`stages/gate.rs`). + let tau_samples = (BYPASS_CROSSFADE_TIME_CONSTANT_MS / 1000.0) * ctx.sample_rate().hz_f64(); + let mix_coeff = (1.0 - (-1.0_f64 / tau_samples).exp()) as f32; + let dry = vec![vec![0.0; ctx.max_block_size()]; channel_count]; // Sized to the ceiling, not to today's `latency_samples()` (issue #58): with nothing // loaded that figure is 0, and installing a resampled model raises it *after* this call // has returned. `max` rather than a bare conversion so a chain that somehow already @@ -286,6 +426,12 @@ impl Chain { .collect(); self.cross_cutting = Some(CrossCuttingState { delay_lines, + dry, + // Seeded at "engaged", but the first `process` after this call overwrites it with + // whatever `global_bypass` says by then — see `started`, and `process`'s own comment. + mix: 0.0, + mix_coeff, + started: false, prepared_for: *ctx, }); } @@ -305,10 +451,19 @@ impl Chain { /// else — so this may be called from the audio thread's own command-handling path as well as /// from setup code. /// - /// Takes effect immediately, on a prepared chain or an unprepared one (issue #36). What + /// **What "takes effect" means since issue #142.** This sets the *target* of a 15 ms dry/wet + /// blend, not the block's routing: `process` runs both sides for the length of the fade and + /// crossfades between them, so the change begins at the very next sample and completes about + /// 100 ms later. It used to be a single-sample step, which is the click FR-CLAP-060 forbids — + /// and every *per-stage* bypass in the chain had faded over the same 15 ms since M2, so the + /// one parameter a host actually automates was the one that stepped. + /// + /// On a prepared chain or an unprepared one (issue #36), the bypass itself takes effect — + /// but on an unprepared one it still steps. What /// [`prepare_crosscutting`](Chain::prepare_crosscutting) adds is the latency-compensation - /// delay: without it a bypassed block is the input undelayed, which is unity-gain passthrough - /// but not sample-aligned against the latency the chain reports. Until M14 the bypass was + /// delay *and, since #142, the crossfade*: both need buffers allocated off the audio thread, + /// and without them a bypassed block is the input undelayed and unblended, which is unity-gain + /// passthrough but neither sample-aligned nor click-free. Until M14 the bypass was /// gated on that call and an unprepared chain ran every stage while nominally bypassed. /// /// **D-10.4:** the product path no longer calls this directly — a `global.bypass` change now @@ -342,12 +497,26 @@ impl Chain { } /// Runs every stage in order, on the audio thread (RT) — unless global bypass (FR-CHAIN-030) - /// is active, in which case the block passes to the output unmodified instead. Either way, - /// once cross-cutting is active (`prepare_crosscutting` has been called), the block this + /// is settled on, in which case the block passes to the output unmodified instead. Either + /// way, once cross-cutting is active (`prepare_crosscutting` has been called), the block this /// produces is then scanned for NaN/Inf (FR-CHAIN-080) and ceiling-clamped (FR-CHAIN-090) /// before returning, and the bypass path is delayed by the chain's reported latency. See /// `prepare_crosscutting`'s doc comment for what a chain built via `Chain::new` and never /// prepared for cross-cutting skips — the bypass is not on that list (issue #36). + /// + /// # The three shapes a block can have (issue #142) + /// + /// A bypass change is a 15 ms crossfade, so between the two settled states there is a third, + /// transitional one in which *both* sides are computed: + /// + /// 1. **Settled engaged** (`mix == 0`, bypass off) — the stage chain's output, ceiling-clamped. + /// Byte for byte what this method did before #142 existed. + /// 2. **Settled bypassed** (`mix == 1`, bypass on) — the delayed dry, copied out. The stages + /// are not run at all, so this costs no more than it used to, and no arithmetic stands + /// between the delay line and the output. + /// 3. **In transition** — the stages run *and* the delayed dry is captured, and the block is + /// the per-sample blend of the two. This is the only shape that costs more than before, it + /// lasts about 100 ms per change, and it is the whole of the fix. pub fn process(&mut self, io: &mut StageIo<'_>) { // Read *this block's* latency rather than a figure cached at preparation (issue #58): // installing a model whose declared rate differs from the engine's raises it mid-session, @@ -357,26 +526,69 @@ impl Chain { let latency = self.latency_samples() as usize; let bypassed = self.global_bypass; - if let Some(cross_cutting) = self.cross_cutting.as_mut() { - // Runs on both paths — see `run_delay`'s doc comment (issue #59). - cross_cutting.run_delay(io, latency, bypassed); - } - // Bypass is not conditional on `cross_cutting` (issue #36). With no ring built there is - // nothing to compensate the chain's latency with, so an unprepared bypass is the input - // undelayed rather than the input delayed — but it is still the *input*, which is the - // whole of what "bypass" claims. Running every stage instead, as this used to when + // Bypass is not conditional on `cross_cutting` (issue #36). With no ring and no dry + // scratch built there is nothing to compensate the chain's latency with and nothing to + // blend against, so an unprepared bypass is the input undelayed and unfaded rather than + // the input delayed and crossfaded — but it is still the *input*, which is the whole of + // what "bypass" claims. Running every stage instead, as this used to when // `prepare_crosscutting` had not been called, is the one reading the word cannot bear. - if !bypassed { + let Some(cross_cutting) = self.cross_cutting.as_mut() else { + if !bypassed { + for stage in &mut self.stages { + stage.process(io); + } + } + return; + }; + + let target = if bypassed { 1.0 } else { 0.0 }; + if !cross_cutting.started { + // The first block after preparation starts settled wherever the bypass already is, + // rather than fading into it: nothing has been rendered yet, so there is no previous + // sample for a fade to be continuous with, and a host that activates the plugin with + // bypass already on (restored with the session, say) wants that block bypassed, not + // 15 ms of the chain it asked to skip. Same reasoning, in the same words, as + // `stages/gate.rs`'s "no prior audio exists yet at stage creation; start settled". + cross_cutting.started = true; + cross_cutting.mix = target; + } + let mix = cross_cutting.mix; + // Which side of the blend this block owes anything to. Both, unless the fade is settled + // on one of its two endpoints — which is every block that is not inside a transition. + let dry_contributes = mix > 0.0 || target > 0.0; + let wet_contributes = mix < 1.0 || target < 1.0; + + // Feeds the delay line on both paths — see `capture_dry`'s doc comment (issue #59). + cross_cutting.capture_dry(io, latency, dry_contributes); + + if wet_contributes { for stage in &mut self.stages { stage.process(io); } } - if let Some(cross_cutting) = self.cross_cutting.as_mut() { - let ceiling_linear = self.output_ceiling_linear; - // The ceiling is an output-stage statement and the bypass path does not run the - // output stage; the NaN scan applies to both. See `scan_and_clamp` (issue #61). - cross_cutting.scan_and_clamp(io, ceiling_linear, !bypassed, &mut self.fault_count); + let ceiling_linear = self.output_ceiling_linear; + let cross_cutting = self + .cross_cutting + .as_mut() + .expect("checked at the top of this call"); + match (dry_contributes, wet_contributes) { + // Settled engaged. The ceiling is an output-stage statement and this is the path that + // runs the output stage; the NaN scan applies to every path. See `scan_and_clamp`. + (false, _) => { + cross_cutting.scan_and_clamp(io, ceiling_linear, true, &mut self.fault_count); + } + // Settled bypassed: no ceiling (issue #61), no arithmetic, still scanned for faults. + (true, false) => { + cross_cutting.emit_dry(io); + cross_cutting.scan_and_clamp(io, ceiling_linear, false, &mut self.fault_count); + } + // In transition: the ceiling is applied to the wet term inside `blend`, so the scan + // that follows must not clamp the blended block on top of it. + (true, true) => { + cross_cutting.blend(io, target, ceiling_linear); + cross_cutting.scan_and_clamp(io, ceiling_linear, false, &mut self.fault_count); + } } } @@ -878,8 +1090,9 @@ mod tests { assert!((out[3] - (-0.1)).abs() < 1e-5); } - // --- Issues #58/#59/#61: the bypass path's three defects, one test each. All three are - // about the *same* delay line, so they share `VariableLatency` and `run_blocks` below. --- + // --- Issues #58/#59/#61/#142: the bypass path's four defects. The first three are about the + // *same* delay line and the fourth is about the blend that now reads from it, so all of them + // share `VariableLatency` and `run_blocks` below. --- /// Id `VariableLatency` answers to. Any value `Chain::apply` does not recognise itself is /// broadcast to every stage, so this needs only to differ from the two chain-level ids. @@ -986,16 +1199,33 @@ mod tests { /// which is exactly what FR-CLAP-060 ("sample-accurate and click-free, equivalent to /// FR-CHAIN-030") forbids. /// - /// Three phases, because the third is what proves the fix rather than merely restating it: - /// bypass off (the line must be filling), bypass on (the first `LATENCY` samples must be the - /// last `LATENCY` samples of the *previous, unbypassed* block), bypass off again, then on - /// again (the line must still be coherent across a period it was not being read from). + /// Four phases, because the fourth is what proves the fix rather than merely restating it: + /// bypass off (the line must be filling), bypass on (the samples the delay reaches back for + /// must be real input from the previous, unbypassed phase), off again, then on again (the + /// line must still be coherent across a period nothing read it). + /// + /// Committed red-first: before the fix, phase one's first three samples are 0.0. /// - /// Committed red-first: before the fix, phase two's first three samples are 0.0. + /// **Rewritten for issue #142's crossfade, and this is the one place the fade genuinely + /// costs coverage.** A blend that opens at `mix_coeff` attenuates the first bypassed samples + /// — precisely the ones a stale ring corrupts — by about 720x, so an assertion on the settled + /// output alone would no longer notice the bug at all: after 15 ms of bypass the ring holds + /// bypass-period content whether or not it was fed while engaged. The early samples are + /// therefore compared against the *closed-form* blend instead of against the delayed input + /// directly (a one-pole from a settled endpoint has one: `m_k = 1 - (1 - coeff)^k`), and each + /// comparison is paired with a check that the dry term it depends on is larger than the + /// tolerance — so a line handing back silence still fails, by ~80x the bound. #[test] fn engaging_bypass_emits_the_real_signal_rather_than_stale_ring_content() { - const BLOCK: usize = 8; + const BLOCK: usize = 64; const LATENCY: usize = 3; + // One phase of off/on/off/on. Longer than the crossfade's settling window (`blend`'s doc + // comment: about 99 ms at 48 kHz) so every phase contains a whole transition *and* ends + // settled, and a whole number of blocks so a phase change lands on a block boundary. + const PHASE: usize = 8_192; + // How many samples after each engagement are compared against the closed-form blend. Any + // prefix would do; five is enough to span the whole `LATENCY` and short enough to read. + const PROBE: usize = 5; // `ConstantTail::process` is a no-op, so the unbypassed path is an exact passthrough and // every difference between the two paths is the compensation line alone. @@ -1005,34 +1235,59 @@ mod tests { })]); chain.prepare_crosscutting(&ctx()); - let input: Vec = (0..BLOCK * 4).map(|n| 0.01 * (n + 1) as f32).collect(); + // Distinct within any 61-sample window, so a misalignment of one to three samples shows, + // and bounded well under the 0 dBFS ceiling `blend` applies to its wet term. + let input: Vec = (0..PHASE * 4) + .map(|n| 0.01 * ((n % 61) + 1) as f32) + .collect(); let output = run_blocks(&mut chain, &input, BLOCK, |i, chain| { // off, on, off, on. - chain.set_global_bypass(i % 2 == 1); + chain.set_global_bypass((i * BLOCK / PHASE) % 2 == 1); }); - // Phase 0 (bypass off): a no-op stage passes the input straight through. + // Phase 0 (bypass off): a no-op stage passes the input straight through, from the first + // sample — the block a `prepare_crosscutting`d chain starts settled on. assert_eq!(&output[..BLOCK], &input[..BLOCK]); - // Phase 1 (bypass on): delayed by LATENCY, and the samples that delay reaches back for - // are real input from phase 0 -- not the zeros a line written only while bypassed holds. - for n in BLOCK..2 * BLOCK { - assert!( - (output[n] - input[n - LATENCY]).abs() < 1e-6, - "sample {n}: engaging bypass emitted {} instead of the input delayed by \ - {LATENCY} ({})", - output[n], - input[n - LATENCY] - ); + + let tau_samples = (BYPASS_CROSSFADE_TIME_CONSTANT_MS / 1000.0) * 48_000.0; + let coeff = (1.0 - (-1.0_f64 / tau_samples).exp()) as f32; + for start in [PHASE, 3 * PHASE] { + let mut m = 0.0f32; + for k in 0..PROBE { + let n = start + k; + m += coeff * (1.0 - m); + let wet = input[n]; // the no-op stage's own output + let dry = input[n - LATENCY]; // what the line must hand back + let expected = wet * (1.0 - m) + dry * m; + assert!( + (output[n] - expected).abs() <= 1e-7, + "sample {n}: engaging bypass emitted {} instead of the blend ({expected}) of \ + the stage output ({wet}) and the input delayed by {LATENCY} ({dry}) at mix \ + {m}", + output[n] + ); + assert!( + (expected - wet * (1.0 - m)).abs() > 1e-6, + "sample {n}: the dry term is within the tolerance above, so this comparison \ + would pass against a line that handed back silence — the check is vacuous" + ); + } + // ... and the phase ends settled on the delayed input, to the bit. + for n in start + PHASE - BLOCK..start + PHASE { + assert_eq!( + output[n], + input[n - LATENCY], + "sample {n}: a settled bypass must be the delayed input exactly" + ); + } } - // Phase 2 (bypass off again): passthrough once more. - assert_eq!(&output[2 * BLOCK..3 * BLOCK], &input[2 * BLOCK..3 * BLOCK]); - // Phase 3 (bypass on again): the line stayed coherent through a period nothing read it. - for n in 3 * BLOCK..4 * BLOCK { - assert!( - (output[n] - input[n - LATENCY]).abs() < 1e-6, - "sample {n}: re-engaging bypass emitted {} instead of {}", - output[n], - input[n - LATENCY] + + // Phase 2 (bypass off again): passthrough once more, by the end of its own transition. + for n in 3 * PHASE - BLOCK..3 * PHASE { + assert_eq!( + output[n], input[n], + "sample {n}: releasing bypass must settle on the \ + stage path" ); } } @@ -1115,6 +1370,10 @@ mod tests { const BLOCK: usize = 64; const TOTAL: usize = BLOCK * 8; const LATENCY: usize = 7; + /// Passes of `input` (512 frames each) run after releasing bypass and before measuring, + /// to clear issue #142's crossfade — about 4 740 frames at 48 kHz, so twelve passes is + /// a little over 6 000 and the measured pass is fully settled. + const SETTLING_RUNS: usize = 12; let null_floor = namir_core::db_to_linear(-120.0); // Peak 1.5, comfortably above the default 0 dBFS ceiling `prepare_crosscutting` activates. @@ -1151,7 +1410,17 @@ mod tests { // The converse: with bypass off, the ceiling still applies. Fixing #61 must not have // turned FR-CHAIN-090 off. + // + // **Settled first, since issue #142.** Releasing bypass now fades onto the stage path + // over about 100 ms instead of switching onto it, and for the length of that fade the + // block still carries the dry term — which is exactly what this test's first half proves + // the ceiling must not touch. So the peak is legitimately above the ceiling until the + // fade completes. The assertion is about the *engaged* path, so the fade is skipped + // rather than the bound loosened: loosening it would stop testing #61's converse at all. chain.set_global_bypass(false); + for _ in 0..SETTLING_RUNS { + run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + } let clamped = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); let peak = clamped.iter().fold(0.0f32, |m, s| m.max(s.abs())); assert!( @@ -1160,6 +1429,143 @@ mod tests { ); } + /// **Issue #142.** `set_global_bypass` used to flip a `bool` that `process` switched paths on + /// between one sample and the next, so automating global bypass — the parameter a host is + /// most likely to automate — stepped, while every *per-stage* bypass in the chain has faded + /// over 15 ms since M2 (FR-CHAIN-020). FR-CLAP-060 asks the plugin's bypass to be + /// "sample-accurate and click-free, equivalent to FR-CHAIN-030"; this is the click-free half, + /// measured where the defect actually lived. + /// + /// The bound is `stages/gate.rs`'s, in its own words: **no single sample may move further + /// than a linear 15 ms ramp over the same range would**, which a one-pole of the same time + /// constant clears by about 0.1%. A constant input and a fixed-gain stage make the whole of + /// every observed delta the crossfade's own — there is no signal movement to subtract — and + /// each phase's first delta is measured from the *last settled sample of the previous phase*, + /// which is where the old single-sample step was. + /// + /// Both directions, because a fade that only ran one way would still click on the other, and + /// each direction's settled endpoint is asserted exactly: the dry side to the bit (that is + /// FR-CHAIN-030's unity gain, and `blend`'s snap is what makes it exact rather than 94 dB + /// approximate), the wet side to within the stage's own tolerance. + /// + /// Committed red-first: before the fix each transition's first sample moves the full range at + /// once, 720x the bound below. + #[test] + fn global_bypass_crossfades_in_both_directions_rather_than_stepping() { + const BLOCK: usize = 64; + const DRY: f32 = 0.25; + /// One phase, in frames: 250 ms at 48 kHz, comfortably past the crossfade's own settling + /// window (`blend`'s doc comment: about 99 ms) so every phase ends settled. + const PHASE: usize = 12_000; + + let prep = FixedGainPrep { gain_db: 6.0 }; + let stage = prep.prepare(&ctx()).unwrap(); + let mut chain = Chain::new(vec![Box::new(stage)]); + chain.prepare_crosscutting(&ctx()); + + // Constant, so every sample-to-sample movement below is the blend and nothing else. At + // +6 dB the stage path sits at 0.5, inside FR-CHAIN-090's default 0 dBFS ceiling, so the + // clamp `blend` applies to its wet term never fires and cannot be mistaken for the fade. + let input = vec![DRY; PHASE]; + let engaged = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + chain.set_global_bypass(true); + let engaging = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + chain.set_global_bypass(false); + let releasing = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + + let wet = DRY * namir_core::db_to_linear(6.0); + assert!( + (engaged[PHASE - 1] - wet).abs() < 1e-6, + "the chain must start settled on the stage path, not fade onto it: {}", + engaged[PHASE - 1] + ); + assert_eq!( + engaging[PHASE - 1], + DRY, + "an engaged bypass must settle on the dry signal exactly -- a one-pole alone stalls \ + about 2e-5 short, which is what `blend`'s snap is for" + ); + assert!( + (releasing[PHASE - 1] - wet).abs() < 1e-6, + "releasing bypass must settle back on the stage path: {}", + releasing[PHASE - 1] + ); + + // |wet - dry| is the blend's whole range; a linear ramp over 15 ms of it is the bound. + let ideal_max_delta = (wet - DRY) / (0.015 * 48_000.0); + for (name, previous, phase) in [ + ("engaging", engaged[PHASE - 1], &engaging), + ("releasing", engaging[PHASE - 1], &releasing), + ] { + let mut previous = previous; + let mut max_delta = 0.0f32; + for &sample in phase.iter() { + max_delta = max_delta.max((sample - previous).abs()); + previous = sample; + } + assert!( + max_delta <= ideal_max_delta * 1.01, + "{name}: max_delta={max_delta} exceeds the 15 ms linear ramp bound \ + {ideal_max_delta} -- global bypass is stepping where every per-stage bypass fades" + ); + assert!(max_delta > 0.0, "{name}: the blend never advanced"); + } + } + + /// The one case that is deliberately *not* faded: the first block after preparation starts + /// settled wherever `global_bypass` already is. Nothing has been rendered yet, so there is no + /// previous sample for a fade to be continuous with, and a host that activates a plugin whose + /// bypass was restored with the session wants that block bypassed rather than 15 ms of the + /// chain it asked to skip. `stages/gate.rs` seeds its own bypass mix the same way and for the + /// same reason. + /// + /// The converse is asserted in the same test, so "start settled" cannot quietly become + /// "never fade": the *second* switch, on a chain that has rendered a block, does fade. + + #[test] + fn the_first_block_after_preparation_starts_settled_rather_than_fading() { + let prep = FixedGainPrep { gain_db: 6.0 }; + let stage = prep.prepare(&ctx()).unwrap(); + let mut chain = Chain::new(vec![Box::new(stage)]); + chain.prepare_crosscutting(&ctx()); + chain.set_global_bypass(true); + + let input = [0.1f32, 0.2, 0.3, 0.4]; + let mut first = input; + { + let mut channels: [&mut [f32]; 1] = [&mut first]; + let mut io = StageIo::new(&mut channels, 4); + audio_section(|| chain.process(&mut io)); + } + assert_eq!( + first, input, + "the first block of a chain prepared with bypass already on must be the input \ + exactly, not the first 4 samples of a fade out of a chain that never ran" + ); + + // The converse: releasing bypass now, with a block already rendered, fades. The first + // sample must still be far nearer the dry it is leaving than the +6 dB stage path. + chain.set_global_bypass(false); + let mut second = input; + { + let mut channels: [&mut [f32]; 1] = [&mut second]; + let mut io = StageIo::new(&mut channels, 4); + audio_section(|| chain.process(&mut io)); + } + let wet = input[0] * namir_core::db_to_linear(6.0); + assert!( + (second[0] - input[0]).abs() < (second[0] - wet).abs(), + "a switch after the first block must fade, not step: got {} against a dry {} and a \ + stage path {wet}", + second[0], + input[0] + ); + assert_ne!( + second[0], input[0], + "...and it must actually have started moving" + ); + } + /// FR-CHAIN-080 is *not* what issue #61 turns off on the bypass path: a non-finite sample must /// still silence the block and raise the fault counter, whichever path produced it. #[test] @@ -1182,9 +1588,13 @@ mod tests { assert_eq!(chain.fault_count(), 1); } + /// All three shapes a `process` call can take (that method's own doc comment), each inside + /// [`audio_section`]: settled bypassed, in transition, settled engaged. The middle one is + /// issue #142's blend, which is the only one that touches both sides in the same block. #[test] - fn cross_cutting_process_does_not_allocate_in_either_path() { - // Bypass path, nonzero latency (exercises the delay ring). + fn cross_cutting_process_does_not_allocate_in_any_of_the_three_block_shapes() { + // Bypass path, nonzero latency (exercises the delay ring). The first block starts settled + // (`process`'s own comment), so this one is shape 2: the delayed dry, copied out. let stages: Vec> = vec![Box::new(ConstantTail { latency: 4, tail: 0, @@ -1198,14 +1608,24 @@ mod tests { let mut io = StageIo::new(&mut channels, 64); audio_section(|| chain.process(&mut io)); - // Normal (non-bypassed) path, cross-cutting still active: exercises the fault scan, the - // ceiling clamp, and -- since issue #59 -- the delay line being *fed* while bypass is off, - // which is the one path in `process` that is new work on every block of ordinary playback. + // Shape 3, the transition: with the mix settled at 1.0 and the target now 0.0, this block + // captures the dry, runs the stages and blends the two -- every line issue #142 added. chain.set_global_bypass(false); let mut buf2 = [0.1f32; 64]; let mut channels2: [&mut [f32]; 1] = [&mut buf2]; let mut io2 = StageIo::new(&mut channels2, 64); audio_section(|| chain.process(&mut io2)); + + // Shape 1, settled engaged: the fault scan, the ceiling clamp, and -- since issue #59 -- + // the delay line being *fed* while bypass is off, which is the one path in `process` that + // is new work on every block of ordinary playback. Reached by letting the fade above run + // to its snap, which is also the only way to reach it. + let settling = vec![0.1f32; 12_000]; + run_blocks(&mut chain, &settling, 64, |_, _| {}); + let mut buf3 = [0.1f32; 64]; + let mut channels3: [&mut [f32]; 1] = [&mut buf3]; + let mut io3 = StageIo::new(&mut channels3, 64); + audio_section(|| chain.process(&mut io3)); } // --- D-10.4: `apply` now routes `global.bypass`/`global.output_ceiling_db` `ParamChange`s diff --git a/crates/namir-engine/src/chain_probes.rs b/crates/namir-engine/src/chain_probes.rs index f621497..f32763e 100644 --- a/crates/namir-engine/src/chain_probes.rs +++ b/crates/namir-engine/src/chain_probes.rs @@ -820,7 +820,12 @@ fn bypass_compensation_tracks_the_latency_a_resampled_model_adds_at_runtime() { let switch = BYPASS_AT_BLOCK * BLOCK; let null_floor = db_to_linear(-120.0); - let peak_residual = (switch..FRAMES) + /// Frames of issue #142's bypass crossfade to let pass before comparing. `Chain`'s blend + /// settles at about 4 800 frames at 48 kHz; this leaves margin and still leaves 3 072 frames + /// to null over. Skipping the transition does not weaken the #58 property this guards: a + /// compensation that failed to track the latency change fails the post-settle null too. + const SETTLE_FRAMES: usize = 5_120; + let peak_residual = (switch + SETTLE_FRAMES..FRAMES) .map(|n| (out[0][n] - signal[n - reported]).abs()) .fold(0.0f32, f32::max); assert!( @@ -891,9 +896,10 @@ fn run_split(chain: &mut Chain, input: &[Vec], frames: usize) -> Vec Chain { /// splices whole samples of silence and lands three orders above the bound; the observed maximum /// is carried into the failure message so a drift from 0 is legible rather than absorbed. /// -/// # The transient this deliberately does not measure, and why it is a different question +/// # The transient this deliberately does not measure — issue #141, since fixed /// -/// [`SPLIT_SETTLE_FRAMES`] is not padding. Inside the ~20 ms after a resource is installed, this -/// chain's output *does* depend on the block division: measured at 1.3e-2 (Nam) and 7.2e-2 (Ir) -/// against settled peaks of ~1.2e-1 and ~5.1e-1, decaying to 1.9e-4 and 9.3e-4 over the -/// following 4 000 frames. That is **not** the split's doing and not new — it reproduces exactly under -/// [`probe::run`] alone at 512 against 256, 128 and 64 frames, with no sub-block anywhere — and -/// its mechanism is upstream of this file. On a *first* load, both stages' output stays -/// **bit-exactly the dry input** for the whole 960-sample equal-power handover crossfade, and the -/// wet signal first appears at the start of the block the fade completes in: measured at frame -/// 512, 768, 896 and 959 for block sizes 512, 256, 64 and 1, identically for Nam and for Ir. So -/// what a first load actually sounds like is the 15 ms per-stage bypass blend starting at a -/// block-quantised instant, with the equal-power fade masked behind it. Recorded here because -/// this is the probe that found it; it belongs to the handover path (`stages/nam.rs`, -/// `stages/ir.rs`), not to issue #30, and is reported rather than fixed here. +/// [`SPLIT_SETTLE_FRAMES`] was not padding. Inside the ~20 ms after a resource was installed, this +/// chain's output *did* depend on the block division: measured at 1.3e-2 (Nam) and 7.2e-2 (Ir) +/// against settled peaks of ~1.2e-1 and ~5.1e-1, decaying to 1.9e-4 and 9.3e-4 over the following +/// 4 000 frames. That was **not** the split's doing — it reproduced exactly under [`probe::run`] +/// alone at 512 against 256, 128 and 64 frames, with no sub-block anywhere — and its mechanism was +/// upstream of this file, in the handover path both stages share. +/// +/// It was reported from here as issue #141 and fixed there. On a first load both stages' output +/// stayed **bit-exactly the dry input** for the whole 960-sample equal-power handover crossfade, +/// and the wet signal first appeared at the start of the block the fade completed in — frame 512, +/// 768, 896 and 959 for block sizes 512, 256, 64 and 1, identically for Nam and for Ir — because +/// the shared bypass blend derived its target from the (empty) outgoing slot and so stayed shut for +/// exactly the interval the fade occupied. What a first load sounded like was therefore the 15 ms +/// bypass blend starting at a block-quantised instant, with FR-NAM-070's and FR-IR-060's fade +/// masked behind it. `stages/nam.rs`'s `begin_crossfade` carries the account; +/// [`a_first_load_is_audible_inside_its_own_fade_at_every_block_size`] is the assertion. +/// +/// **Re-measured after that fix, with this probe's own chain and signal: the transient's +/// block-division dependence is 0e0** — at 512 against 256, 128 and 64, for a Nam-only and an +/// Ir-only load alike, and for this probe's own whole-versus-split comparison run from frame 0 with +/// no settling at all (8.6e-3 before the fix, exactly zero after). [`SPLIT_SETTLE_FRAMES`] is +/// therefore no longer load-bearing for the comparison below; it is kept because a probe that +/// asserts a settled property should still settle, and because dropping it would silently widen +/// what this test is claiming. #[test] fn splitting_a_block_the_way_host_automation_does_changes_nothing() { const FRAMES: usize = 16_384; @@ -1063,3 +1080,199 @@ fn the_split_probe_would_notice_a_single_spliced_sample() { that spliced a sample of silence into the stream" ); } + +// --------------------------------------------------------------------------------------------- +// Issue #141 — a first load's equal-power fade, and where its onset actually lands. +// --------------------------------------------------------------------------------------------- + +/// The handover crossfade's length in frames at [`SR`]: [`stages::HANDOVER_CROSSFADE_MS`] (20 ms) +/// at 48 kHz. Written as the same conversion the stages perform rather than as `960`, so a change +/// to that constant moves this probe with it. +const FADE_FRAMES: usize = (stages::HANDOVER_CROSSFADE_MS as usize) * (SR as usize) / 1000; + +/// The block sizes issue #141's own table was measured at — the point of the test being that the +/// answer must not depend on which one a host picks. `1` is not a realistic host block size; it is +/// the limit case that makes a block-quantised onset unmistakable (959 before the fix, against 512 +/// at a 512-frame block). +const ONSET_BLOCKS: [usize; 4] = [512, 256, 64, 1]; + +/// The declared block size every run of the onset probe is prepared at, so the IR's partition +/// schedule and every stage's scratch are identical across [`ONSET_BLOCKS`] and only the division +/// into `process` calls varies — the same isolation [`SPLIT_BLOCK`] performs for the split probe. +const ONSET_PREPARED_BLOCK: usize = 512; + +/// Which resource a run of [`first_load_onset`] loads. Both stages carry the same handover +/// machinery and issue #141 measured the same numbers through both, so both are driven — one at a +/// time, so the frame each becomes audible at is attributable to that stage. +#[derive(Clone, Copy, Debug)] +enum FirstLoad { + Nam, + Ir, +} + +/// Runs `frames` of a probe sine through a default chain in `block`-frame blocks, once with +/// nothing loaded and once with `what` loaded immediately before the first block, and returns +/// `(baseline, loaded)`. +/// +/// The two chains are built identically from the same seeds and driven with the same input, so the +/// only difference between the two outputs is the resource — which makes "the first frame at which +/// they differ" exactly "the first frame at which the newly-loaded resource became audible". +fn first_load_pair(what: FirstLoad, block: usize, frames: usize) -> (Vec, Vec) { + let ctx = probe::ctx_at(SR, ONSET_PREPARED_BLOCK, ChannelConfig::Mono); + let signal = probe::sine(frames, 220.0, SR, 0.25); + let input = probe::duplicated(&signal, 1); + + let build = || { + let mut chain = build_default_chain(&ctx).unwrap(); + // Well below the probe's level, so the gate is open from the first block and its envelope + // is identical in both runs rather than being the thing that differs. + probe::set_param(&mut chain, gate::THRESHOLD_DB.id, -70.0); + chain + }; + + let mut baseline_chain = build(); + let baseline = probe::run(&mut baseline_chain, &input, block); + + let mut loaded_chain = build(); + match what { + // A model declaring the engine's own rate: D-9.2 bypasses `SlotResampler` entirely, so the + // stage adds no latency and the two runs stay sample-aligned. Issue #141 is about *when* + // the wet signal appears, and a latency difference between the two runs would confound it. + FirstLoad::Nam => probe::load_nam( + &mut loaded_chain, + probe::nam_model(WaveNetShape::Nano, 11, SR), + &ctx, + ), + FirstLoad::Ir => probe::load_ir( + &mut loaded_chain, + probe::mono_ir(5, 1_024, SR, ONSET_PREPARED_BLOCK), + &ctx, + ), + } + let loaded = probe::run(&mut loaded_chain, &input, block); + + ( + baseline.into_iter().next().unwrap(), + loaded.into_iter().next().unwrap(), + ) +} + +/// The first frame at which `loaded` differs from `baseline` at all — bit inequality rather than a +/// threshold, because the question this probe asks is when the wet signal *appears*, and the +/// equal-power fade's own first samples are legitimately tiny (`sin(pi/2 / 960)` is 1.6e-3 of the +/// wet signal one sample in). A threshold would answer a different, blurrier question. +fn first_divergence(baseline: &[f32], loaded: &[f32]) -> Option { + baseline + .iter() + .zip(loaded.iter()) + .position(|(a, b)| a.to_bits() != b.to_bits()) +} + +/// **Issue #141, asserted.** A first load must become audible inside its own equal-power fade, at +/// the same frame whatever block size the host happens to be using. +/// +/// # What was wrong, and what this would have caught +/// +/// On a first load `slots[active]` is `None`, and both stages derived the shared bypass blend's +/// target from that slot alone — so the blend stayed shut for the fade's whole duration and +/// multiplied FR-NAM-070's/FR-IR-060's equal-power crossfade out of existence. Both stages emitted +/// **bit-exactly the dry input** for all [`FADE_FRAMES`] of the fade, and the model or IR first +/// became audible at the start of the *block* in which the fade completed. Measured before the fix, +/// identically for [`FirstLoad::Nam`] and [`FirstLoad::Ir`]: +/// +/// | block | onset before | onset after | +/// |---|---|---| +/// | 512 | 512 | 1 | +/// | 256 | 768 | 1 | +/// | 64 | 896 | 1 | +/// | 1 | 959 | 1 | +/// +/// So what a user heard was not the specified fade at all but the 15 ms per-stage bypass blend, +/// starting at a block-quantised instant — up to ~85 ms of jitter at a 4096-frame block, on a path +/// [`splitting_a_block_the_way_host_automation_does_changes_nothing`] found by accident. +/// +/// Frame 1 rather than frame 0 is not slack: the fade's first sample has `theta == 0`, so its +/// `sin` term is exactly zero and its `cos` term is exactly the dry passthrough a `None` outgoing +/// slot contributes. The two runs are *required* to agree bit-for-bit there, and that is the same +/// fact that makes the bypass blend's snap click-free. +/// +/// # The three things asserted, and why each is needed +/// +/// 1. **The onset is inside the fade, immediately** — `<= ONSET_TOLERANCE_FRAMES`, two orders +/// inside [`FADE_FRAMES`], for every block size. +/// 2. **The onset does not depend on the block size** — the defect's whole signature was that it +/// did. Asserted as equality across [`ONSET_BLOCKS`], not just as a bound each satisfies. +/// 3. **It is a fade, not a step** — the largest single-sample movement anywhere in the fade is +/// bounded by what the same signal produces with nothing loaded plus what it produces once the +/// fade has settled, which is the most an equal-power blend of the two can slew. Without this +/// the first two assertions would be satisfied by snapping straight to the wet signal, which is +/// the click FR-CHAIN-020 forbids. +#[test] +fn a_first_load_is_audible_inside_its_own_fade_at_every_block_size() { + /// How far into the fade the wet signal is allowed to first appear. One sample is what the + /// fix produces; the bound is loose enough not to pin an implementation detail and two orders + /// tighter than the block-quantised onsets the defect produced. + const ONSET_TOLERANCE_FRAMES: usize = 8; + /// Long enough to leave a settled window well past the fade to measure the wet signal's own + /// slew in. + const FRAMES: usize = 8_192; + /// Where "settled" starts: several times the 20 ms fade and the 15 ms bypass blend. + const SETTLED: usize = 4_096; + + for what in [FirstLoad::Nam, FirstLoad::Ir] { + let mut onsets: Vec<(usize, usize)> = Vec::new(); + + for block in ONSET_BLOCKS { + let (baseline, loaded) = first_load_pair(what, block, FRAMES); + + // Non-vacuity: if loading changed nothing at all, every assertion below is empty. + let settled_difference = + probe::max_abs_difference(&baseline[SETTLED..], &loaded[SETTLED..]); + assert!( + settled_difference > 1e-3, + "{what:?} at block {block}: loading changed the settled output by only \ + {settled_difference:e}, so this probe cannot see a resource become audible at all" + ); + + let onset = first_divergence(&baseline, &loaded).unwrap_or_else(|| { + panic!("{what:?} at block {block}: the loaded run never diverged from the baseline") + }); + assert!( + onset <= ONSET_TOLERANCE_FRAMES, + "{what:?} at block {block}: the wet signal first appears at frame {onset}, not \ + inside the {FADE_FRAMES}-frame equal-power fade that started at frame 0. That is \ + issue #141: the fade is masked by a bypass blend that stays shut until it \ + completes, so what is heard is a 15 ms blend beginning at a block boundary" + ); + assert_eq!( + baseline[0].to_bits(), + loaded[0].to_bits(), + "{what:?} at block {block}: the fade's first sample must be the dry signal \ + bit-for-bit (theta = 0), or the bypass blend's snap is a step" + ); + + // 3: a fade, not a step. + let fade_slew = probe::max_abs_first_difference(&loaded[..FADE_FRAMES]); + let dry_slew = probe::max_abs_first_difference(&baseline[..FADE_FRAMES]); + let settled_slew = probe::max_abs_first_difference(&loaded[SETTLED..]); + let bound = (dry_slew + settled_slew) * 1.05; + assert!( + fade_slew <= bound, + "{what:?} at block {block}: the largest single-sample step inside the fade is \ + {fade_slew:e}, above the {bound:e} an equal-power blend of a dry signal slewing \ + {dry_slew:e} and a wet one slewing {settled_slew:e} can produce — the onset is a \ + step, not a fade" + ); + + onsets.push((block, onset)); + } + + let (_, first_onset) = onsets[0]; + assert!( + onsets.iter().all(|&(_, onset)| onset == first_onset), + "{what:?}: the frame the wet signal appears at depends on the block size: {onsets:?}. \ + That dependence is issue #141's audible consequence — the instant a model becomes \ + audible jitters by up to a whole block, ~85 ms at 4096 frames" + ); + } +} diff --git a/crates/namir-engine/src/stages/ir.rs b/crates/namir-engine/src/stages/ir.rs index c268e19..c585e80 100644 --- a/crates/namir-engine/src/stages/ir.rs +++ b/crates/namir-engine/src/stages/ir.rs @@ -28,10 +28,14 @@ //! (`chain.rs`'s own doc comment: "at most one stage with a nonzero tail"). See //! [`Stage::tail_samples`]'s impl below. //! -//! Everything else — the two-fades-composed reasoning, the `mix_target` recomputation triggers, -//! and D-8.1's four-step handover including the two audio-thread drop sites M4 closed (a completing -//! handover's outgoing slot, and a displaced still-fading-in slot) — is identical in spirit to -//! `nam.rs`'s own module doc comment; read that first, this doc comment only covers what differs. +//! Everything else — the two-fades reasoning, the `mix_target` recomputation triggers (including +//! issue #141's widening of them, and [`IrStage::begin_crossfade`]'s snap), and D-8.1's four-step +//! handover including the two audio-thread drop sites M4 closed (a completing handover's outgoing +//! slot, and a displaced still-fading-in slot) — is identical in spirit to `nam.rs`'s own module +//! doc comment; read that first, this doc comment only covers what differs. Issue #141 reproduced +//! identically on both stages (the wet signal first appearing at frame 512, 768, 896 or 959 for +//! block sizes 512, 256, 64 and 1) and is fixed identically on both, with one difference this +//! stage's own extra wet-path processing forces — see [`IrStage::wet_path_is_transparent`]. //! //! # The handover crossfade is per physical channel, not mono-core //! @@ -333,9 +337,10 @@ pub struct IrStage { /// Current dry/wet blend for the *shared* bypass crossfade: `0.0` = fully dry/bypassed, /// `1.0` = fully wet/engaged. mix: f32, - /// Where `mix` is heading: `1.0` when `enabled && slots[active].is_some()`, `0.0` otherwise - /// (FR-CHAIN-040). Recomputed by `apply`, `load_ir`, and by `process_wet` itself right after a - /// handover completes and `active` changes. + /// Where `mix` is heading: `1.0` when `enabled` and some slot is contributing wet signal to + /// the output right now, `0.0` otherwise (FR-CHAIN-040). Recomputed by `apply`, by + /// `begin_crossfade`, and by `process_wet` itself right after a handover completes and + /// `active` changes. mix_target: f32, /// One-pole coefficient for the `mix` crossfade, computed once in `prepare` from /// [`BYPASS_CROSSFADE_TIME_CONSTANT_MS`] and the sample rate. @@ -423,11 +428,7 @@ impl IrStage { self.retired = Some(Resource::ir(displaced, self.prepared_for)); } self.slots[inactive] = Some(slot); - self.crossfade = Some(Crossfade { - remaining: self.crossfade_total_samples, - total: self.crossfade_total_samples, - }); - self.recompute_mix_target(); + self.begin_crossfade(); None } @@ -446,21 +447,71 @@ impl IrStage { if let Some(displaced) = self.slots[inactive].take() { self.retired = Some(Resource::ir(displaced, self.prepared_for)); } + self.begin_crossfade(); + } + + /// **RT-safe.** Starts a handover fade and puts the shared bypass blend where that fade can be + /// heard — `nam.rs`'s `begin_crossfade`, for this stage. Read that method's doc comment for + /// issue #141's measurement and for why the bypass blend is *snapped* to its target rather + /// than ramped there; only the transparency test differs, and it differs in + /// [`Self::wet_path_is_transparent`], not here. + fn begin_crossfade(&mut self) { + // Sampled before `self.crossfade` is overwritten, for the reason `nam.rs`'s identical line + // gives. + let mix_is_unobservable = self.wet_path_is_transparent(); self.crossfade = Some(Crossfade { remaining: self.crossfade_total_samples, total: self.crossfade_total_samples, }); self.recompute_mix_target(); + if mix_is_unobservable { + self.mix = self.mix_target; + } } - /// `mix_target` is a function of exactly two inputs (`enabled`, `slots[active]`'s presence) — - /// identical rule to `nam.rs`'s `recompute_mix_target`. + /// Whether this stage's wet path currently reproduces its dry input, which is what makes `mix` + /// unobservable and [`Self::begin_crossfade`]'s snap inaudible. + /// + /// **This is the one place issue #141's fix is not symmetric with `nam.rs`'s.** That stage's + /// wet path with nothing active is a bit-exact passthrough and the test is just "nothing + /// active, no fade in flight". This one's is not: FR-IR-070's low-cut, high-cut and level run + /// *unconditionally* on whatever `process_wet` produced (this module's doc comment: so that + /// bypassing the stage bypasses the whole IR+filter+level chain, not just the convolution), so + /// with an enabled low-cut and nothing loaded the wet path carries a high-passed copy of the + /// dry input that `mix == 0.0` is the only thing discarding. Snapping `mix` to 1.0 there would + /// step the output from `dry` to `filter(dry)` in one sample — a click, and precisely what + /// FR-CHAIN-020 forbids. So all three controls must be at their neutral settings too. + /// + /// When they are not, nothing is snapped: `mix_target` is still engaged for the fade's whole + /// duration (which is what removes #141's block-quantised onset), and `mix` reaches it on the + /// ordinary 15 ms one-pole, composed with the equal-power curve rather than replaced by it. + /// That is the honest residue of this fix — the fade a user hears on a first IR load *with a + /// low-cut, high-cut or non-unity level already dialled in* is still not purely equal-power, + /// and closing that needs the FR-IR-070 chain moved to the far side of the handover blend, + /// which is a larger change than #141 asks for. + /// + /// One further approximation, stated rather than hidden: the three fields tested here are + /// parameter *targets*, while `Biquad`'s coefficient interpolation and `GainRamp`'s gain are + /// smoothed towards them. A control returned to neutral within the last few milliseconds reads + /// as transparent here while its smoother is still a fraction of the way from where it was, so + /// the snap can carry that fraction of the (already small) difference. `namir-dsp` exposes no + /// settled-ness query to test instead, and the window is a coefficient ramp of at most + /// `max_block_size` samples wide. + fn wet_path_is_transparent(&self) -> bool { + self.slots[self.active].is_none() + && self.crossfade.is_none() + && !self.low_cut_enabled + && !self.high_cut_enabled + && self.level_db == 0.0 + } + + /// `mix_target` is a function of `enabled` and of whether *any* slot is contributing wet + /// signal to the output right now — identical rule, and identical issue #141 rationale, to + /// `nam.rs`'s `recompute_mix_target`. fn recompute_mix_target(&mut self) { - self.mix_target = if self.enabled && self.slots[self.active].is_some() { - 1.0 - } else { - 0.0 - }; + let engaged = self.slots[self.active].is_some() + || (self.crossfade.is_some() && self.slots[1 - self.active].is_some()); + self.mix_target = if self.enabled && engaged { 1.0 } else { 0.0 }; } /// The low-cut (high-pass) coefficient target right now: [`BiquadCoeffs::identity`] when off, @@ -1048,6 +1099,121 @@ mod tests { /// /// Committed red-first: before the fix, `crossfade` is still `Some(remaining: 0)` and `active` /// is still 1 after the pen has been drained and further blocks processed. + /// **Issue #141 at this stage** — `nam.rs`'s `a_first_load_engages_the_bypass_blend_for_the_whole_fade`, + /// for the Ir stage, which reproduced the same defect with the same numbers (the wet signal + /// first appearing at frame 512, 768, 896 or 959 for block sizes 512, 256, 64 and 1). Read + /// that test's doc comment for the measurement that identified the mechanism: the equal-power + /// blend was computed correctly all along and then multiplied away by a bypass blend that + /// stayed shut for the fade's whole duration. + #[test] + fn a_first_load_engages_the_bypass_blend_for_the_whole_fade() { + const SR: u32 = 48_000; + let mut stage = stage(SR, ChannelConfig::Mono); + let taps = [0.6f32, -0.2, 0.1]; + + assert_eq!(stage.mix, 0.0, "nothing loaded: bypassed"); + assert_eq!(stage.mix_target, 0.0); + assert!( + stage.wet_path_is_transparent(), + "at its defaults this stage's wet path is a passthrough, which is what makes the snap \ + below inaudible" + ); + + stage.load_ir(mono_ir(SR, &taps, 64)); + assert_eq!( + stage.mix_target, 1.0, + "a first load's fade must be heard, so the bypass blend's target is engaged when the \ + fade starts -- not when it completes" + ); + assert_eq!(stage.mix, 1.0, "and `mix` is snapped there (issue #141)"); + + let input = 0.37f32; + let out = process_constant_in_chunks(&mut stage, 64, input); + assert_eq!( + out[0].to_bits(), + input.to_bits(), + "the fade's first sample has theta = 0, so it must be the dry input bit-for-bit" + ); + let divergence = out.iter().position(|s| s.to_bits() != input.to_bits()); + assert_eq!( + divergence, + Some(1), + "the wet signal must appear on the fade's second sample, not at the block boundary \ + after the fade completes (issue #141)" + ); + assert!( + stage.crossfade.is_some(), + "64 samples is well inside a 960-sample fade" + ); + } + + /// **The guard on issue #141's snap, which is this stage's own and has no `nam.rs` equivalent.** + /// FR-IR-070's low-cut/high-cut/level run on the wet path unconditionally, so with a low-cut + /// engaged and nothing loaded the wet path carries a high-passed copy of the dry input that + /// only `mix == 0.0` is discarding. Snapping `mix` to 1.0 there would step the output from + /// `dry` to `filter(dry)` in a single sample — the click FR-CHAIN-020 forbids — so + /// [`IrStage::wet_path_is_transparent`] refuses it and the blend ramps instead. + /// + /// The onset is still not block-quantised (`mix_target` is engaged from the fade's first + /// sample either way), which is the part of #141 that must hold in every configuration. + #[test] + fn a_filtered_wet_path_ramps_the_bypass_blend_instead_of_snapping_it() { + const SR: u32 = 48_000; + let mut stage = stage(SR, ChannelConfig::Mono); + let taps = [0.6f32, -0.2, 0.1]; + + stage.apply(ParamChange { + id: LOW_CUT_FREQ_HZ_ID, + value: 300.0, + }); + stage.apply(ParamChange { + id: LOW_CUT_ENABLED_ID, + value: 1.0, + }); + // Settle the coefficient ramp, and confirm the guard sees a non-transparent wet path. + process_constant_in_chunks(&mut stage, 4_096, 0.37); + assert!(!stage.wet_path_is_transparent()); + + stage.load_ir(mono_ir(SR, &taps, 64)); + assert_eq!( + stage.mix_target, 1.0, + "the fade is still engaged from its first sample -- that half of #141's fix is \ + unconditional" + ); + assert_eq!( + stage.mix, 0.0, + "but `mix` may not be snapped across a wet path that is high-passing the dry signal: \ + that step is a click, not a fade" + ); + + // And the transition is smooth. Two fades are travelling at once here — the bypass + // one-pole and the handover's equal-power curve — so the bound is the sum of their + // steepest per-sample slopes over the range the output actually covers: `1 - e^(-1/tau)` + // for a one-pole of time constant tau, and `(pi/2) / total` for a quarter-sine spread over + // the fade's `total` samples. A step would be orders above it; the measured figure is + // about half of it. + let out = process_constant_in_chunks(&mut stage, 2_048, 0.37); + let range = out + .iter() + .fold(0.0f32, |m, &s| m.max((s - 0.37).abs())) + .max(1e-6); + let tau_samples = (BYPASS_CROSSFADE_TIME_CONSTANT_MS / 1000.0) * f64::from(SR); + let one_pole_step = (1.0 - (-1.0 / tau_samples).exp()) as f32; + let equal_power_step = FRAC_PI_2 / stage.crossfade_total_samples as f32; + let ideal_max_delta = range * (one_pole_step + equal_power_step); + let mut prev = 0.37f32; + let mut max_delta = 0.0f32; + for &s in &out { + max_delta = max_delta.max((s - prev).abs()); + prev = s; + } + assert!( + max_delta <= ideal_max_delta, + "max_delta={max_delta} exceeds the {ideal_max_delta} two smooth fades can travel in \ + one sample across a range of {range}" + ); + } + #[test] fn a_handover_deferred_by_a_full_retire_pen_finalizes_once_the_pen_clears() { const SR: u32 = 48_000; diff --git a/crates/namir-engine/src/stages/nam.rs b/crates/namir-engine/src/stages/nam.rs index beed1b7..c369825 100644 --- a/crates/namir-engine/src/stages/nam.rs +++ b/crates/namir-engine/src/stages/nam.rs @@ -54,17 +54,22 @@ //! `gate.rs`/`trim.rs` use), which blends the handover crossfade's *result* against this //! stage's dry input, based on `enabled && slots[active].is_some()`. //! -//! `mix_target` is recomputed from `slots[active]` — deliberately the *pre-handover* active slot, -//! not whichever slot is fading in — every time `enabled` changes or `active` itself changes -//! (i.e. when a handover completes, never mid-handover). One consequence worth stating plainly: -//! loading the very first model (nothing previously active) does not make the bypass blend start -//! moving until the handover crossfade itself finishes and `active` flips — the two fades compose -//! in sequence for that specific case, not in parallel. Both fades are individually smooth -//! one-pole/equal-power curves, so the composition is still click-free throughout, just not the -//! single ~20 ms fade a naive reading might expect. Loading a *replacement* model into an already -//! fully-engaged stage (`slots[active]` already `Some`, bypass blend already settled at 1.0) does -//! not have this composition effect: `mix_target` is already 1.0 and stays there, so the handover -//! crossfade's own equal-power blend is heard in full, which is the FR-NAM-070 case that matters. +//! `mix_target` is recomputed every time `enabled` changes, every time `active` changes (i.e. when +//! a handover completes) and at the start of every handover. Loading a *replacement* model into an +//! already fully-engaged stage never moves it: it is already 1.0 and stays there, so the handover +//! crossfade's own equal-power blend is heard in full. +//! +//! **Loading the very first model used to be the exception, and issue #141 is that it was the +//! wrong one.** `mix_target` was a function of `slots[active]` alone — deliberately the +//! *pre-handover* active slot, which on a first load is `None` — so the bypass blend stayed pinned +//! at 0.0 for the whole handover and multiplied the equal-power fade's result out of existence. +//! Measured on this stage: bit-exactly the dry input for all 960 samples of the fade, with the +//! model first becoming audible at the *start of the block* the fade completed in (frame 512, 768, +//! 896 and 959 for block sizes 512, 256, 64 and 1). FR-NAM-070's fade was inaudible and what +//! replaced it was the 15 ms bypass blend at a block-quantised instant. Since #141 the target +//! counts the slot a fade is fading *into* as well, and [`NamStage::begin_crossfade`] moves `mix` +//! there at once rather than ramping — read that method's doc comment for why an instant move is +//! the click-free choice here and a ramp is not. use std::collections::VecDeque; use std::f32::consts::FRAC_PI_2; @@ -664,7 +669,8 @@ pub struct NamStage { /// `1.0` = fully wet/engaged. See this module's doc comment for how this composes with the /// separate handover crossfade above. mix: f32, - /// Where `mix` is heading: `1.0` when `enabled && slots[active].is_some()`, `0.0` otherwise + /// Where `mix` is heading: `1.0` when `enabled` and some slot is contributing wet signal to + /// the output right now, `0.0` otherwise /// (FR-CHAIN-040: nothing loaded behaves as bypassed). Recomputed by `apply`, `load_model`, /// and by `process` itself right after a handover completes and `active` changes — every /// place this stage's doc comment lists as changing one of the two inputs to this formula. @@ -751,11 +757,7 @@ impl NamStage { self.retired = Some(Resource::nam(displaced, self.prepared_for)); } self.slots[inactive] = Some(slot); - self.crossfade = Some(Crossfade { - remaining: self.crossfade_total_samples, - total: self.crossfade_total_samples, - }); - self.recompute_mix_target(); + self.begin_crossfade(); None } @@ -783,22 +785,77 @@ impl NamStage { // A move, not a drop. See `install`'s doc comment. self.retired = Some(Resource::nam(displaced, self.prepared_for)); } + self.begin_crossfade(); + } + + /// **RT-safe.** Starts a [`HANDOVER_CROSSFADE_MS`]-long fade (D-8.1 step 3) and puts the + /// shared bypass blend where that fade can actually be heard. Shared by [`Self::install`] and + /// [`Self::unload`], which differ only in what they leave in the inactive slot first. + /// + /// # Issue #141: why `mix` is *snapped* here rather than left to its one-pole + /// + /// See this module's own doc comment for the measurement. In short: on a first load the fade's + /// outgoing side is `None`, so a `mix_target` derived from `slots[active]` alone held the + /// bypass blend at 0.0 for the fade's whole duration and the equal-power blend was multiplied + /// out of existence — the stage emitted bit-exactly its dry input until the fade *completed*, + /// and then engaged over 15 ms starting at whatever block boundary that landed on. + /// + /// [`Self::recompute_mix_target`] fixes the first half by counting the slot being faded *into*. + /// That alone would leave the second: a one-pole ramp composed on top of the equal-power curve + /// is not the equal-power curve FR-NAM-070 asks for, and would still spread the onset over + /// 15 ms. So `mix` is moved to its target in one step. + /// + /// **The step is click-free by construction, not by tolerance.** It is taken only when + /// [`Self::wet_path_is_transparent`] holds — this stage's wet path is currently a bit-exact + /// copy of its dry input — and in that state `mix` is unobservable: the stage's output is the + /// dry signal for *every* value of `mix`, on the sample before the step and on the sample + /// after it. The fade then picks up from exactly there, because its first sample has + /// `theta == 0`, `cos(0) == 1`, `sin(0) == 0` and a `None` outgoing slot contributes a + /// `copy_from_slice` of the dry input. When the wet path is *not* transparent (a replacement + /// model faded into an already-engaged stage) nothing is snapped and `mix` is already 1.0 + /// anyway, which is the case that always worked. + /// + /// Costs two `Option` inspections and three scalar assignments; allocates nothing, and every + /// branch is straight-line. + fn begin_crossfade(&mut self) { + // Sampled *before* `self.crossfade` is overwritten: an in-flight fade is itself one of the + // things that makes the wet path non-transparent. + let mix_is_unobservable = self.wet_path_is_transparent(); self.crossfade = Some(Crossfade { remaining: self.crossfade_total_samples, total: self.crossfade_total_samples, }); self.recompute_mix_target(); + if mix_is_unobservable { + self.mix = self.mix_target; + } + } + + /// Whether this stage's wet path currently reproduces its dry input **bit-exactly**, which is + /// what makes `mix` unobservable and [`Self::begin_crossfade`]'s snap inaudible. + /// + /// True exactly when nothing is active and no fade is in flight: `process_channel0` returns + /// without touching `io` in that state (FR-CHAIN-040's passthrough), so the bypass blend below + /// it is blending the dry signal against itself. Unlike `ir.rs`'s counterpart there is nothing + /// else on this stage's wet path to be transparent about — no filters, no level ramp. + fn wet_path_is_transparent(&self) -> bool { + self.slots[self.active].is_none() && self.crossfade.is_none() } - /// `mix_target` is a function of exactly two inputs (`enabled`, `slots[active]`'s presence) — - /// see the field's own doc comment for the FR-CHAIN-040 rationale and for why it is - /// deliberately `slots[active]`, not whichever slot a handover is fading into. + /// `mix_target` is a function of `enabled` and of whether *any* slot is contributing wet + /// signal to the output right now — see the field's own doc comment for the FR-CHAIN-040 + /// rationale. + /// + /// **Issue #141 widened the second input.** It used to be `slots[active]`'s presence alone, + /// deliberately the pre-handover active slot; but a fade *into* the inactive slot is audible + /// from its own first sample, so a target that ignores it holds the bypass blend closed over + /// exactly the interval FR-NAM-070 specifies a fade for. `slots[active]` still governs + /// everywhere outside a handover, and still governs `latency_samples`/`telemetry`, which are + /// statements about the settled stage rather than about what is audible this block. fn recompute_mix_target(&mut self) { - self.mix_target = if self.enabled && self.slots[self.active].is_some() { - 1.0 - } else { - 0.0 - }; + let engaged = self.slots[self.active].is_some() + || (self.crossfade.is_some() && self.slots[1 - self.active].is_some()); + self.mix_target = if self.enabled && engaged { 1.0 } else { 0.0 }; } /// The mono-core wet path (FR-CHAIN-050): writes this block's processed result into @@ -1745,6 +1802,102 @@ mod tests { /// Committed red-first: before the fix, the final three assertions all fail — `crossfade` is /// still `Some`, `active` is still 1, and the pen is empty because the outgoing slot is stuck /// in `slots[1]` forever. + /// **Issue #141 at the stage, where the mechanism is visible.** The chain-level probe + /// (`chain_probes.rs`'s `a_first_load_is_audible_inside_its_own_fade_at_every_block_size`) + /// asserts the audible consequence; this pins the two pieces of state that produced it, so a + /// regression names its own cause rather than only its symptom. + /// + /// The defect was **not** that the wet signal went unproduced during a first load's fade — it + /// was produced all along. Measured before the fix, on the first 64-frame block after a first + /// `load_model`: `crossfade_incoming` diverged from the dry input by 2.0e-1 (the whole wet + /// signal), `crossfade_outgoing` by exactly 0e0 (the `None` slot's dry passthrough), the fade + /// advanced 960 -> 896 — and `io` came out **bit-exactly the dry input**, because the shared + /// bypass blend sat at `mix == mix_target == 0.0` for the fade's entire duration and multiplied + /// the equal-power blend away. So the fade was applied and then discarded. + /// + /// Asserted here: the blend is engaged from the first block of a first load, the fade's own + /// first sample is still bit-exactly dry (which is what makes `begin_crossfade`'s snap + /// click-free rather than merely quiet), and the block as a whole is no longer dry. + #[test] + fn a_first_load_engages_the_bypass_blend_for_the_whole_fade() { + let sample_rate = 48_000; + let mut stage = stage(sample_rate, ChannelConfig::Mono); + + assert_eq!(stage.mix, 0.0, "nothing loaded: bypassed"); + assert_eq!(stage.mix_target, 0.0); + + stage.load_model(tiny_model(sample_rate)); + assert_eq!( + stage.mix_target, 1.0, + "a first load's fade must be heard, so the bypass blend's target is engaged when the \ + fade starts -- not when it completes" + ); + assert_eq!( + stage.mix, 1.0, + "and `mix` is snapped there, because a 15 ms one-pole composed on top of the \ + equal-power curve is not the equal-power curve FR-NAM-070 specifies" + ); + + let input: Vec = (0..64).map(|i| 0.2 * ((i as f32) * 0.05).sin()).collect(); + let out = process_signal_in_chunks(&mut stage, &input); + + assert_eq!( + out[0].to_bits(), + input[0].to_bits(), + "the fade's first sample has theta = 0, so it must be the dry input bit-for-bit: that \ + is what makes snapping `mix` a continuation rather than a step" + ); + let divergence = out + .iter() + .zip(input.iter()) + .position(|(a, b)| a.to_bits() != b.to_bits()); + assert_eq!( + divergence, + Some(1), + "the wet signal must appear on the fade's second sample, not at the block boundary \ + after it completes (issue #141)" + ); + assert!( + stage.crossfade.is_some(), + "64 samples is well inside a 960-sample fade" + ); + } + + /// The other half of issue #141's fix: the snap is taken **only** where it cannot be heard. + /// A replacement model faded into an already-engaged stage has a non-transparent wet path + /// (`slots[active]` is `Some`), so nothing is snapped and `mix` is left exactly where it was — + /// which is 1.0 there anyway, the case that always worked. + #[test] + fn a_replacement_load_snaps_nothing() { + let sample_rate = 48_000; + let mut stage = stage(sample_rate, ChannelConfig::Mono); + stage.load_model(tiny_model(sample_rate)); + process_constant_in_chunks(&mut stage, 48_000, 0.1); + assert!( + !stage.wet_path_is_transparent(), + "an engaged stage's wet path is not a passthrough, which is what withholds the snap" + ); + + // Disable the stage and catch `mix` part-way down its 15 ms ramp, so a snap would be + // visible as a jump rather than hidden by an already-settled value. + stage.apply(ParamChange { + id: ENABLED_ID, + value: 0.0, + }); + process_constant_in_chunks(&mut stage, 64, 0.1); + let mid_ramp = stage.mix; + assert!( + mid_ramp > 0.0 && mid_ramp < 1.0, + "the bypass ramp should be mid-flight, got {mid_ramp}" + ); + + stage.load_model(tiny_model(sample_rate)); + assert_eq!( + stage.mix, mid_ramp, + "an install into a stage whose wet path is audible must not move `mix` at all" + ); + } + #[test] fn a_handover_deferred_by_a_full_retire_pen_finalizes_once_the_pen_clears() { const SR: u32 = 48_000; @@ -1826,6 +1979,13 @@ mod tests { /// /// Reached the same way as the test above, but with the pen filled by an unload rather than by /// a prior model, so the deferred handover is the one that first makes a model audible. + /// + /// **Issue #141 changed what this can observe, and the change is recorded rather than papered + /// over.** A first load now engages the bypass blend when its fade *starts*, so the deferral + /// this test constructs no longer has a bypassed interval for the mid-run assertion to catch; + /// what it pins instead is that the deferral is entered without losing audibility and left + /// completely once the pen clears (`crossfade` cleared, `active` flipped, `mix_target` still + /// engaged), which is the state machine #56 was about. #[test] fn a_deferred_first_handover_does_not_leave_the_stage_bypassed_forever() { const SR: u32 = 48_000; @@ -1861,7 +2021,18 @@ mod tests { stage.load_model(tiny_model(SR)); assert!(stage.retired.is_some()); process_constant_in_chunks(&mut stage, PAST_A_FADE, 0.1); - assert_eq!(stage.mix_target, 0.0, "still deferred, still bypassed"); + // **Issue #141 moved this assertion, and moved it in the direction #56 wanted.** It used + // to read `mix_target == 0.0` — "still deferred, still bypassed" — because a first load's + // target was derived from the (empty) outgoing slot alone. `recompute_mix_target` now + // counts the slot being faded *into*, so the deferral no longer costs audibility at all: + // it is the bookkeeping that is outstanding, never the audio. What #56 is about — that the + // deferral is left rather than entered forever — is the `crossfade`/`active`/`retired` + // assertions below, which are unchanged. + assert_eq!( + stage.mix_target, 1.0, + "a deferred first handover must still be audible: the pen being full delays the \ + retirement, not the fade" + ); let (mut producer, _consumer) = crate::ring::ring::(4); { @@ -1869,9 +2040,18 @@ mod tests { stage.collect_retired(&mut sink); } process_constant_in_chunks(&mut stage, 64, 0.1); + assert!( + stage.crossfade.is_none(), + "the deferred handover must finalize once the pen clears, not stay in it forever" + ); + assert_eq!( + stage.active, 1, + "finalization flips `active` onto the slot that faded in" + ); assert_eq!( stage.mix_target, 1.0, - "once the pen clears the stage must become audible again, not stay bypassed forever" + "once the pen clears the stage must be audible on its own account, with the fade over \ + and `slots[active]` holding the model" ); } diff --git a/docs/03-test-plan.md b/docs/03-test-plan.md index 4efd23c..6652c85 100644 --- a/docs/03-test-plan.md +++ b/docs/03-test-plan.md @@ -20,7 +20,7 @@ Machine-generated by `cargo run -p xtask -- traceability --write` (NFR-QUAL-010, | FR-CLAP-030 | I | **PARTIAL** — `namir-clap`: FR-CLAP-030 — the negotiation limb spans one configuration of the three FR-CHAIN-060 names. crates/namir-clap/src/audio_ports_ext.rs declares Stereo alone and implements no audio-ports-config extension, so Mono and Mono→stereo are never offered and no host can request them: there is no vtable entry for a test to call, in this harness or in any other host. Accepted 1.0 scope reduction per FR-CLAP-030's Consequence note (FRS, 2026-08-12), adjudicated at the 1.0 exit gate; closes M8 | | FR-CLAP-040 | I | `namir-clap` | | FR-CLAP-050 | I | **PARTIAL** — `namir-clap`: FR-CLAP-050 — Section 5.9 is FR-STATE-010..090, and the host-driven half is spanned here for the parameter payload, D-11.2's write-back, the params rescan and the failure arm, but not for every clause of every requirement it defers to: the resource half of a document is driven through this extension only in its embedded form (FR-STATE-080), with FR-STATE-070's library-relative and absolute candidates resolved by clap_host_rt_blocking.rs and by namir-worker's own tests rather than here, and FR-STATE-040's compound-method migration (issue #27) has no parser to exercise; closes M8 | -| FR-CLAP-060 | I | **PARTIAL** — `namir-clap`: FR-CLAP-060 — the click-free limb is unspanned, and it is a live defect rather than missing coverage: namir_engine::Chain::set_global_bypass flips a bool with no crossfade, so the transition this file locates to the frame completes in one sample (measured at ~0.5x the settled peak, by this file's last test) where FR-CHAIN-020's per-stage bypass fades over 15 ms; the fix belongs to Chain, in namir-engine, and this crate cannot make it; closes M8 | +| FR-CLAP-060 | I | **PARTIAL** — `namir-clap`: FR-CLAP-060 — the click-free and sample-accuracy limbs are both executed here, but the requirement asks for bypass "equivalent to FR-CHAIN-030", and FR-CHAIN-030's own content is the null against the *delayed* input: nothing in this file loads a model, so the plugin's bypass is only ever observed at zero chain latency, and the compensation limb is exercised engine-side alone; closes M8 | | FR-CLAP-070 | U | `namir-clap` | | FR-CLAP-080 | I | **PARTIAL** — `namir-clap`: FR-CLAP-080 — swept at 154 rates (both endpoints, the six standard rates, a 1 kHz grid and one fractional value), not every rate the requirement's range admits: `src/audio.rs` rounds the host's rate to an integer, so the set a host can present collapses onto ~147 900 distinct `SampleRate` values, of which this file reaches 154. The resource-loaded limb `loaded` adds — where D-9.2's `SlotResampler`, the one rate-dependent subsystem in the chain, is actually engaged — is narrower still: 8 of those 154, the two endpoints, the six standard rates and two off-grid values, since a rate there costs a model load and real inference rather than a pass-through block; closes M8 | | FR-CLAP-090 | I+B | **PARTIAL** — `namir-clap`: FR-CLAP-090 — the B half of "I plus B", that N instances of one model use materially less memory than N separate copies, is measured by nothing: this crate's one bench (benches/plugin_instantiation.rs) times NFR-PERF-040's instantiation window, and no benchmark anywhere in the workspace measures memory at all; closes M8 | From dde8488d56be332b7f4e45ad2ba52c01a030b487 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 17:55:40 +0000 Subject: [PATCH 34/44] CI: open and render the interface with no display (#143) Every other namir-ui test is headless by construction -- they drive egui::Context::run_ui and assert over painted shapes, never touching a windowing stack. That is deliberate, and it left one thing unasserted: that a window opens at all. Until #143 it did not on any machine without a display, and nothing here would have said so. Runs the crate's existing unattended smoke example against Xvfb, which is what turns #143's fallback into something CI checks rather than something verified once. libgl1-mesa-dri is the software rasteriser: the runner has no GPU, so without it there is an X server and no driver to render through. Required rather than informational, because a headless-window check permitted to fail asserts nothing, and the point of #143 is converting "needs a human at a screen" into "runs on every push". Verified with the exact invocation this job runs, under Xvfb on Mesa 25.2.8/llvmpipe: 90 frames, exit 0. NOT executed on a GitHub-hosted runner -- if that image's GL stack differs, this job is where it surfaces. The two panic messages before it succeeds are expected: the fallback learns the framebuffer is unavailable by catching baseview's panic, that library having no fallible open. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29a02bf..fe90e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -241,6 +241,51 @@ jobs: run: cargo run -p xtask -- traceability continue-on-error: true + # M15 (issue #143): does the interface actually open and paint on a machine with no display? + # + # Every other `namir-ui` test is headless by construction -- they drive `egui::Context::run_ui` + # and assert over the shapes `render` painted, which never touches a windowing stack. That is a + # deliberate design (the crate's own tests say so) and it leaves one thing unasserted: that a + # window opens at all. Until #143 it did not, on any machine without a physical display, and + # nothing here would have said so -- `baseview` asked `glXChooseFBConfig` for an sRGB-capable + # framebuffer, which a software X server offers on none of its configs, and the open panicked. + # + # This runs the crate's existing unattended smoke example, which renders a fixed number of frames + # and closes itself, against Xvfb. It is the step that makes #143's fallback a thing CI checks + # rather than a thing someone verified once. + # + # Required, not informational: a headless-window check permitted to fail asserts nothing, and the + # whole value of #143 is turning "needs a human at a screen" into "runs on every push". It has + # NOT been executed on a GitHub-hosted runner -- only under `Xvfb :99 -screen 0 1280x1024x24` on + # Mesa 25.2.8/llvmpipe, where the example panicked before #143 and renders its 90 frames and + # exits 0 after it. If the runner's GL stack differs, this job is where that surfaces. + # + # `libgl1-mesa-dri` is the llvmpipe software rasteriser: the runner has no GPU, so without it + # there is a GLX server and no driver to render through. The build-time X and ALSA headers are + # already satisfied on this image by the build + test job, which compiles the same workspace. + headless-window: + name: headless window smoke (FR-UI-010, issue #143) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Install Xvfb and a software GL driver + run: sudo apt-get update && sudo apt-get install -y xvfb libgl1-mesa-dri + + # `xvfb-run -a` picks a free display rather than hard-coding :99, so this cannot collide with + # anything else the runner starts. The screen is 24-bit because `EguiWindowSettings`' default + # asks for a 24-bit depth buffer. + # + # Two panic messages on stderr before it succeeds are expected, not a failure: #143's + # fallback learns the framebuffer is unavailable by catching `baseview`'s panic, that library + # having no fallible open. The exit status is the assertion. + - name: Open and render the interface with no display (issue #143) + run: xvfb-run -a --server-args="-screen 0 1280x1024x24" cargo run -p namir-ui --example manual_window_smoke + # Covers D-18.1's "cargo-deny licence audit" (NFR-LIC-020). deny.toml already exists at the # repo root; this job just runs it in CI instead of by hand. license-audit: From 636997cbc9270718f95c311636649d884d68007a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:16:51 +0000 Subject: [PATCH 35/44] Wait for the three axes rather than assuming two seconds (#145 CI) The coverage job went red on rt_stress: "library scanning axis never completed a single scan during the run". Root-caused rather than re-run. Not a flake and not the coverage tool. Reproduced under cargo llvm-cov locally, where all three axes report 0/0/0 -- CI happened to name the scan assertion because loads and recalls scraped past theirs. Trunk under the same instrumentation gets 15/26/196 and passes, so this is this branch's. The mechanism is instrumentation, not added work: natively this branch is *faster* than trunk (24/32/248 against 15/26/196). Under -C instrument-coverage every counter is a real memory write, so the per-element loops added on this branch -- namir-nam's finiteness sweep over every weight, the activation parameter bound, the gate's windowed-maximum detector, the meter's non-finite guards -- cost orders more than they do natively. Bisected to confirm: reverting namir-worker's source entirely still fails, so the cost is upstream of it. RUN_FOR was a proxy for "enough of each axis happened" that holds only while a block costs what it costs on an ordinary build. It is now a minimum, and the loop continues until each axis has produced what assertion 5 asks of it, capped at 90 s. Nothing is weakened. Every assertion is unchanged, and on an ordinary build the extra condition is already satisfied when RUN_FOR elapses -- the native run is still 2.04 s. A genuine stall still fails, on the same assertions with the same messages, after the cap. Instrumented: was FAILED at 4.24 s, now passes at 4.47 s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-worker/tests/rt_stress.rs | 37 +++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/namir-worker/tests/rt_stress.rs b/crates/namir-worker/tests/rt_stress.rs index 9c1552c..947ae5d 100644 --- a/crates/namir-worker/tests/rt_stress.rs +++ b/crates/namir-worker/tests/rt_stress.rs @@ -84,6 +84,31 @@ const BLOCK: usize = 64; /// --workspace`'s wall time. const RUN_FOR: Duration = Duration::from_secs(2); +/// The longest this test will keep the audio loop running while waiting for the three axes to +/// produce the work assertion 5 below requires. +/// +/// [`RUN_FOR`] is a *minimum*, not a budget. The properties this test asserts -- no allocation on +/// the audio thread, no dropout window, no uncatalogued error -- are about what happens while the +/// axes run, not about how much they achieve per second, and 2 s of wall clock is a proxy for +/// "enough of each axis happened" that only holds while a block costs what it costs on an +/// ordinary build. +/// +/// Under `cargo llvm-cov`'s `-C instrument-coverage` it does not hold: every counter is a real +/// memory write, so the per-element loops this workspace runs at load time (`namir-nam`'s +/// finiteness sweep over every weight, the activation-parameter bound) and per sample (the gate's +/// windowed-maximum detector, the meter's non-finite guards) cost orders more than they do +/// natively. Measured on one machine: this test completes 24 loads / 32 recalls / 248 scans in +/// `RUN_FOR` natively and **0 / 0 / 0** instrumented, while asserting exactly the same properties. +/// A fixed wall-clock threshold therefore fails on the instrumented build for a reason that says +/// nothing about the code under test -- the same class of mistake as regressing NFR-PERF-010's +/// budget against a shared CI runner, which this project already refuses to do. +/// +/// So the loop below runs for at least `RUN_FOR` and then keeps going until each axis has produced +/// what assertion 5 asks of it, up to this cap. Nothing is weakened: on an ordinary build the +/// extra condition is already satisfied when `RUN_FOR` elapses and the run is unchanged, and a +/// genuine stall still fails, on the same assertions with the same messages, after this cap. +const RUN_AT_MOST: Duration = Duration::from_secs(90); + /// FR-NAM-070's own dropout threshold, reused rather than re-invented -- see this file's module /// doc comment. const DROPOUT_PEAK_THRESHOLD: f32 = 1e-4; @@ -324,7 +349,17 @@ fn nfr_rt_010_three_axes_run_concurrently_with_zero_audio_thread_allocation() { let block_period = Duration::from_secs_f64(BLOCK as f64 / SR as f64); let run_started = Instant::now(); - while run_started.elapsed() < RUN_FOR { + // See [`RUN_AT_MOST`]: `RUN_FOR` is the minimum, and the run extends only while an axis still + // owes assertion 5 the work it is about to be asked for. + let axes_owe_work = |loads: &AtomicUsize, recalls: &AtomicUsize, scans: &AtomicUsize| { + loads.load(Ordering::Relaxed) < 3 + || recalls.load(Ordering::Relaxed) < 3 + || scans.load(Ordering::Relaxed) < 1 + }; + while run_started.elapsed() < RUN_FOR + || (run_started.elapsed() < RUN_AT_MOST + && axes_owe_work(&loads_completed, &recalls_completed, &scans_completed)) + { for s in buf.iter_mut() { *s = 0.5 * phase.sin(); phase += step; From 0f31586045ab7134e69a195838c26e28057be49c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:26:46 +0000 Subject: [PATCH 36/44] Preserve a reference this build could not read (review finding 4) #112's fix removed a slot on is_none() alone, but from_document produces None two ways: the user cleared it, and read_reference failed to parse it. The second degrades to None plus a warning by design, so a document written by a newer build lost its reference on the next save -- the D-11.2 promise the doc comment above that code claimed was unaffected. The CLAP save path is exactly write_onto(&last_document()), so opening a project and re-saving was enough. The distinction is recomputed from the source document rather than carried on State, and that is not a style choice: namir-clap's save builds a brand-new State from its own mirror, never the one load parsed, so anything the reader recorded on that value is discarded before write_onto runs. A carrier on State would have fixed the unit test and not the bug. `onto` is the only thing still in hand that holds the evidence. The predicate is FileRef::from_value itself rather than a cheaper restatement, because it is correct only while it agrees exactly with read_reference, which produced the None being interpreted. #112's own tests pass unmodified: a genuine unload still clears. Two gaps flagged rather than fixed, both outside this crate: namir-app's SaveState builds its document from scratch and retains no source, so the standalone shell drops every unrecognised section on a load-edit-save; and a newer build's extra field inside an otherwise readable reference is still dropped, since FileRef carries no extra. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-state/src/state.rs | 152 +++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 12 deletions(-) diff --git a/crates/namir-state/src/state.rs b/crates/namir-state/src/state.rs index 77c4ece..7bed8d4 100644 --- a/crates/namir-state/src/state.rs +++ b/crates/namir-state/src/state.rs @@ -212,16 +212,42 @@ impl State { /// [`FileRef`]'s doc comment on why an unrecognised field *inside* a single reference object /// is not yet preserved. /// - /// **The one thing this method deletes:** a `references` slot this state does not carry. - /// `merge_section` can only add keys, so `State { nam: None, .. }.write_onto(a document that - /// had one)` used to write the old `references.nam` straight back — the user removes a model, - /// saves, reloads, and it is back (issue #112; the CLAP save path is literally - /// `save() -> write_onto(&last_document())`). §7 of `docs/04-state-and-preset-format.md` says - /// "absent means nothing of that kind is loaded", and merging alone has no way to say it. So - /// `nam`/`ir` are removed explicitly when this state's own field is `None`. This is not a - /// D-11.2 exception: both keys are ones this build fully owns and rewrites on every save, and - /// the removal is per-key (`Document::remove_from_section`), so an unrecognised key - /// alongside them inside `references` still survives untouched. + /// **The one thing this method deletes:** a `references` slot this state does not carry + /// **and this build could read in `onto`**. `merge_section` can only add keys, so + /// `State { nam: None, .. }.write_onto(a document that had one)` used to write the old + /// `references.nam` straight back — the user removes a model, saves, reloads, and it is back + /// (issue #112; the CLAP save path is literally `save() -> write_onto(&last_document())`). + /// §7 of `docs/04-state-and-preset-format.md` says "absent means nothing of that kind is + /// loaded", and merging alone has no way to say it. So `nam`/`ir` are removed explicitly. + /// This is not a D-11.2 exception: both keys are ones this build fully owns and rewrites on + /// every save, and the removal is per-key (`Document::remove_from_section`), so an + /// unrecognised key alongside them inside `references` still survives untouched. + /// + /// **Why `self.nam.is_none()` is not by itself the condition.** [`Self::from_document`] + /// produces `None` for a slot two different ways, and only one of them is a user's decision: + /// the slot was genuinely absent or cleared, *or* [`read_reference`] could not parse what was + /// there and degraded it to `None` plus a warning (P8) — a reference a hand-editor mangled, + /// or one written in a shape only a newer build understands (a future `embedded.encoding`, an + /// algorithm-tagged `hash`). Deleting on `is_none()` alone conflates the two and destroys the + /// second on the very next save, which is the failure D-11.2's rationale names in as many + /// words: "a project saved by a newer Namir and opened by an older one does not silently lose + /// settings on the next save". So the removal is conditioned on `onto`'s own slot being one + /// this build **can** read: absent (nothing to delete), or a well-formed [`FileRef`] whose + /// disappearance from `self` therefore really does mean the user cleared it. + /// + /// **Why the distinction is recomputed from `onto` rather than carried on [`State`].** A + /// third variant on the field, or a set of unreadable keys alongside them, would have to + /// survive from the load that observed the defect to the save that must respect it — and on + /// the path that matters it cannot. `namir-clap`'s `save()` builds a brand-new `State` out of + /// its own parameter mirror and resource slots (`SharedInner::snapshot_state`), never the + /// `State` that `load` parsed; anything the reader had recorded on that value is already gone + /// by the time `write_onto` runs. `onto` — the retained source document — is the one carrier + /// that is still in hand at the point of decision, and it holds the evidence directly, so the + /// predicate here is [`FileRef::from_value`] itself rather than a second, driftable notion of + /// "well-formed". (It re-parses one reference per cleared slot, an embedded copy's base64 + /// included; this is a save path that is about to re-encode the whole document anyway, and + /// paying it buys the guarantee that this check can never disagree with the reader whose + /// behaviour it exists to compensate for.) /// /// **D-10.4:** if `onto` carries a legacy `global` section (D-11.2 tolerance: this build can /// still have read one, via [`Self::from_document`]), it is left exactly as it is here — the @@ -236,16 +262,32 @@ impl State { let mut document = onto.clone(); document.merge_section("parameters", self.params.to_document_section()); document.merge_section("references", references_section(&self.nam, &self.ir)); - if self.nam.is_none() { + if self.nam.is_none() && was_readable(onto, "nam") { document.remove_from_section("references", "nam"); } - if self.ir.is_none() { + if self.ir.is_none() && was_readable(onto, "ir") { document.remove_from_section("references", "ir"); } document } } +/// Whether `onto`'s `references.` is one this build actually reads — the "the user cleared +/// it" half of [`State::write_onto`]'s removal condition, as opposed to "we never managed to read +/// it in the first place". A slot that isn't there at all counts as readable: there is nothing to +/// lose by removing it, and reporting it unreadable would only make the removal a no-op by a +/// second route. +/// +/// Deliberately [`FileRef::from_value`] itself, not a cheaper re-statement of what it accepts: +/// this predicate is only correct while it agrees exactly with [`read_reference`], which is the +/// function that turned the slot into the `None` being interpreted here. +fn was_readable(onto: &Document, key: &str) -> bool { + match onto.section("references").and_then(|s| s.get(key)) { + Some(value) => FileRef::from_value(value).is_ok(), + None => true, + } +} + fn references_section(nam: &Option, ir: &Option) -> Map { let mut obj = Map::new(); if let Some(r) = nam { @@ -434,6 +476,92 @@ mod tests { assert_eq!(restored.nam, None); } + /// The **other** way [`State::from_document`] produces `nam: None`, and the one issue #112's + /// fix could not tell apart from a genuine unload: this build could not *read* the reference + /// that is there. `read_reference` degrades a reference it cannot parse to `None` plus a + /// warning by design (P8), which is exactly what makes a forward-compatible document safe to + /// open — and exactly what would make deleting on `is_none()` alone destroy it on the next + /// save. The shape used here is the realistic one: a newer build's `embedded.encoding` this + /// build refuses, inside an otherwise well-formed reference. + /// + /// D-11.2's promise ("a project saved by a newer Namir and opened by an older one does not + /// silently lose settings on the next save") is the whole point, and the CLAP save path is + /// literally `save() -> write_onto(&last_document())`. + #[test] + fn write_onto_preserves_a_reference_this_build_could_not_read() { + let mut original = Document::empty(); + let mut references = Map::new(); + let Value::Object(mut newer) = a_reference("plexi.nam").to_value() else { + unreachable!("a FileRef always serialises to an object"); + }; + let mut embedded = Map::new(); + // A future encoding this build has never heard of: everything else about the reference + // is well-formed, and a newer build reads it back perfectly. + embedded.insert("encoding".to_string(), Value::from("base64+zstd")); + embedded.insert("data".to_string(), Value::from("eyJmYWtlIjo=")); + newer.insert("embedded".to_string(), Value::Object(embedded)); + let unreadable = Value::Object(newer); + references.insert("nam".to_string(), unreadable.clone()); + original.set_section("references", references); + + // It really does degrade to `None` with a warning rather than failing the document. + let (state, warnings) = State::from_document(original.clone()); + assert_eq!(state.nam, None); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert_eq!(warnings[0].code.id, crate::error_codes::MALFORMED_JSON.id); + + // ... and saving that state back onto the document it came from must not delete it. + let saved = state.write_onto(&original); + assert_eq!( + saved.section("references").and_then(|s| s.get("nam")), + Some(&unreadable), + "a reference this build could not read is data it does not understand: D-11.2 \ + preserves it verbatim rather than deleting it on the next save" + ); + } + + /// The same distinction at the `ir` slot, and with the other unreadable shape — a `hash` in + /// a form this build cannot parse — so the fix cannot be one that happens to key off + /// `embedded` or off `nam` alone. + #[test] + fn write_onto_preserves_an_unreadable_ir_reference_while_clearing_a_readable_nam() { + let mut original = Document::empty(); + let mut references = Map::new(); + references.insert("nam".to_string(), a_reference("plexi.nam").to_value()); + let mut broken_ir = Map::new(); + // A hypothetical newer build's algorithm-tagged hash; this build's `ContentHash` parser + // wants 64 bare hex characters and refuses it. + broken_ir.insert( + "hash".to_string(), + Value::from("blake3:d1f0a4c2b9e8375614a0c3d2e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e"), + ); + broken_ir.insert("display_name".to_string(), Value::from("1960a.wav")); + let unreadable = Value::Object(broken_ir); + references.insert("ir".to_string(), unreadable.clone()); + original.set_section("references", references); + + let (mut state, warnings) = State::from_document(original.clone()); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(state.nam.is_some(), "the nam reference is readable"); + assert_eq!(state.ir, None, "the ir reference degraded to absent"); + + state.nam = None; // the user unloads the model this build *did* read + let saved = state.write_onto(&original); + + let saved_references = saved.section("references").unwrap(); + assert!( + !saved_references.contains_key("nam"), + "a reference this build read and the user then cleared must still be deleted \ + (issue #112): {:?}", + saved_references.get("nam") + ); + assert_eq!( + saved_references.get("ir"), + Some(&unreadable), + "a reference this build never managed to read must survive" + ); + } + /// Clearing a slot must not become a licence to rewrite the `references` section wholesale: /// D-11.2's promise about an unrecognised key inside a section this build owns still holds /// for `references`, exactly as it does for `parameters`. From 65d51d34546eb5b5963220c39c19d197eed58225 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:33:13 +0000 Subject: [PATCH 37/44] Index a symlinked directory once, and refuse an IR that resamples to nothing (review findings 15, 14) Finding 15 is not the race it was hedged as -- it was unconditional for siblings. expand_dir_symlink claimed a link's canonical target during its parent's listing, while a plain directory was only queued and canonicalised in a later step, so a sibling link always won the claim before its own target had been expanded, in either read_dir order. Two LibraryEntry rows, two paths, and a paths_for_hash duplicate that is not one. The claim moves to the moment a queued entry is expanded, whoever queued it. Beyond the reviewer's fix: since only one spelling survives, which one is a real choice, and leaving it to read_dir order would sometimes index the user's models under the link and warn SYMLINK_NOT_FOLLOWED about their real folder. Symlinks now drain from their own queue after the directories, so the real directory always wins and the skipped spelling is always genuinely a symlink -- which is what makes that warning code true of it. Finding 14 reproduced exactly: a 1-frame 192 kHz IR at a 48 kHz engine returns Ok with len_samples = 0 and convolves a full-scale block to silence. Rejected rather than padded. new_length.max(1) manufactures a tap the file does not contain, and a one-tap near-silent cabinet is the same inaudible outcome with the diagnostic removed -- it converts a detectable bug into an undetectable one. EMPTY_IR rather than a new code because it is already a usability judgment rather than a header-shape fact, which wav.rs says in as many words; at the engine rate, the only rate the convolver runs at, these files have nothing in them. The detail names the real cause, and the test reports the silent load if it ever returns Ok again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-ir/src/convolver.rs | 75 +++++++++- crates/namir-ir/src/error_codes.rs | 8 ++ crates/namir-library/src/fs.rs | 24 +++- crates/namir-library/src/scan.rs | 214 ++++++++++++++++++++++------- 4 files changed, 269 insertions(+), 52 deletions(-) diff --git a/crates/namir-ir/src/convolver.rs b/crates/namir-ir/src/convolver.rs index 212b0e9..7f593fe 100644 --- a/crates/namir-ir/src/convolver.rs +++ b/crates/namir-ir/src/convolver.rs @@ -142,7 +142,7 @@ use wide::f32x8; use namir_core::SampleRate; -use crate::error_codes::IrLoadError; +use crate::error_codes::{self, IrLoadError}; use crate::wav; /// `out[t] += w * in_[t]` for every `t`, vectorized 8 lanes at a time with a scalar remainder — @@ -753,6 +753,7 @@ impl PreparedIr { ) -> Result { let decoded = wav::decode(bytes)?; let mut was_truncated = decoded.was_truncated; + let source_frames = decoded.channel_data.first().map(Vec::len).unwrap_or(0); let mut channel_taps: Vec> = Vec::with_capacity(decoded.channel_data.len()); for ch in decoded.channel_data { @@ -766,6 +767,32 @@ impl PreparedIr { was_truncated |= truncate_to_engine_ceiling(&mut channel_taps, engine_rate.hz()); + // A file that *has* frames can still resample to none. `resample_mono` sizes its output + // `round(len * to_hz / from_hz)`, which rounds below 0.5 for an IR of a frame or two at a + // rate far above the engine's (1 frame at 192 kHz into 48 kHz is 0.25; 2 frames at + // 192 kHz into 44.1 kHz is 0.46), and `wav::decode`'s only emptiness guard is the + // *pre*-resample declared frame count. Such a file used to load `Ok` with an empty tap + // array — an empty head and schedule, `len_samples() == 0`, and `process_block` writing + // zeros for the life of the load, with no diagnostic anywhere: a silent cabinet. + // + // `EMPTY_IR` is the right code rather than a new one, and refusal the right answer rather + // than padding to one tap. The code is a convolution-usability judgment — D-9's "an IR + // with nothing in it is not a usable IR", which is why `probe_wav` indexes a zero-frame + // file that `decode` refuses to load — and at the engine rate, which is the only rate the + // convolver ever runs at, this file has nothing in it. `new_length.max(1)` would instead + // manufacture a tap the file does not contain, and the resulting one-tap near-silent + // cabinet is the same inaudible outcome with the diagnostic removed. + if channel_taps.iter().any(Vec::is_empty) { + return Err(IrLoadError { + code: error_codes::EMPTY_IR, + detail: format!( + "{source_frames} frames at {} Hz resample to 0 frames at the {} Hz engine rate", + decoded.sample_rate, + engine_rate.hz() + ), + }); + } + let len_samples = channel_taps.first().map(Vec::len).unwrap_or(0); let channels = channel_taps @@ -1733,6 +1760,52 @@ mod tests { assert_eq!(prepared.len_samples(), 500); } + /// A file that *has* frames but resamples to none must be refused, not loaded as a silent + /// cabinet. + /// + /// `resample_mono` sizes its output `round(len * to_hz / from_hz)`, and `wav::decode`'s only + /// emptiness guard is the *pre*-resample declared frame count — so any IR short enough that + /// its resampled length rounds below 0.5 produced an empty tap array, an empty head and + /// schedule, `len_samples() == 0`, and `process_block` writing zeros forever with no + /// diagnostic at all. The predecessor `SincFixedIn` path truncated identically, so this is + /// older than the FFT resampler, but the invariant `EMPTY_IR` exists to hold — D-9's "an IR + /// with nothing in it is not a usable IR" — is the same one, and at the engine rate these + /// files have nothing in them. Refused rather than padded to one tap: a manufactured tap the + /// file does not contain is still an inaudible cabinet, only without the diagnostic. + #[test] + fn an_ir_that_resamples_to_no_taps_is_refused_rather_than_silently_empty() { + for (source_hz, engine_hz, frames) in + [(192_000u32, 48_000u32, 1usize), (192_000, 44_100, 2)] + { + let bytes = write_mono_wav(source_hz, &delta(frames)); + let engine_rate = SampleRate::new(engine_hz).unwrap(); + // Reported as the silent load it would otherwise be, so a regression names the + // symptom rather than only the missing error. + let err = match PreparedIr::from_wav_bytes(&bytes, engine_rate, 64) { + Ok(prepared) => { + let mut state = prepared.new_state(); + let x = vec![1.0f32; 64]; + let mut y = vec![0f32; 64]; + let mut out_slice = &mut y[..]; + prepared.process_block(&mut state, &x, std::slice::from_mut(&mut out_slice)); + panic!( + "{frames} frames at {source_hz} Hz, engine {engine_hz} Hz: expected a \ + catalogued error, got Ok with len_samples = {} and a full-scale block \ + convolved to {:?}", + prepared.len_samples(), + &y[..4] + ) + } + Err(e) => e, + }; + assert_eq!( + err.code.id, + error_codes::EMPTY_IR.id, + "{frames} frames at {source_hz} Hz, engine {engine_hz} Hz" + ); + } + } + /// The source/engine rate pairs the measurement below covers. /// /// An IR is a `.wav` file, so its rate is whatever a user's file declares rather than anything diff --git a/crates/namir-ir/src/error_codes.rs b/crates/namir-ir/src/error_codes.rs index dcd5bb3..868f617 100644 --- a/crates/namir-ir/src/error_codes.rs +++ b/crates/namir-ir/src/error_codes.rs @@ -43,6 +43,14 @@ pub const INVALID_SAMPLE_RATE: ErrorCode = ErrorCode::new( ); /// The file declares zero audio frames — there is no impulse response to load at all. +/// +/// Raised at two points, for one judgment. `wav::decode` raises it on the declared frame count; +/// `PreparedIr::from_wav_bytes_with_schedule` raises it again after resampling, for a file that +/// *has* frames but has none left at the engine rate — one or two frames at a rate far above the +/// engine's, whose `round(len * to_hz / from_hz)` length rounds below 0.5. The second is the same +/// D-9 judgment as the first ("an IR with nothing in it is not a usable IR"), applied at the only +/// rate the convolver ever runs at; without it such a file loaded successfully and convolved to +/// silence forever with no diagnostic. `detail` names which of the two it was. pub const EMPTY_IR: ErrorCode = ErrorCode::new( "ir.load.empty_ir", Severity::Error, diff --git a/crates/namir-library/src/fs.rs b/crates/namir-library/src/fs.rs index a368f78..5534ea5 100644 --- a/crates/namir-library/src/fs.rs +++ b/crates/namir-library/src/fs.rs @@ -22,8 +22,9 @@ pub struct DirEntryInfo { pub is_dir: bool, /// Whether this entry is a symlink (or, on Windows, a directory reparse point) whose target /// is a directory. Issue #73: symlinking a model collection into the library root is an - /// ordinary user setup, so `scan.rs` follows these — see its `visited_link_targets` set for - /// how a loop is made to terminate now that it is no longer impossible by construction. + /// ordinary user setup, so `scan.rs` follows these — see its `visited_dirs` set for how a + /// loop is made to terminate, and one directory kept to one spelling, now that neither is + /// impossible by construction. pub is_dir_symlink: bool, /// Byte length as the directory listing reports it. For a directory, `0` — meaningless and /// never consulted. @@ -314,6 +315,25 @@ impl FakeFs { path } + /// Registers `name` as an ordinary, listable subdirectory of `parent` — the real folder a + /// [`Self::add_dir_symlink`] target needs when both spellings are inside the scanned tree. + /// [`Self::add_unlistable_dir`] is the same listing entry without the directory behind it. + pub(crate) fn add_dir(&mut self, parent: &Path, name: &str) -> PathBuf { + let path = parent.join(name); + self.dirs + .entry(parent.to_path_buf()) + .or_default() + .push(DirEntryInfo { + path: path.clone(), + is_dir: true, + is_dir_symlink: false, + size: 0, + mtime: FileTime::from_system_time(std::time::UNIX_EPOCH), + }); + self.dirs.entry(path.clone()).or_default(); + path + } + /// Registers `name` in `parent` as a symlink to the directory `target` — listed like a /// directory, canonicalising to `target` (issue #73). pub(crate) fn add_dir_symlink(&mut self, parent: &Path, name: &str, target: &Path) -> PathBuf { diff --git a/crates/namir-library/src/scan.rs b/crates/namir-library/src/scan.rs index 285a0d7..f8fc4d6 100644 --- a/crates/namir-library/src/scan.rs +++ b/crates/namir-library/src/scan.rs @@ -151,6 +151,13 @@ const MTIME_SETTLING_WINDOW_NANOS: i128 = 2_000_000_000; /// The caller-pumped scan step machine. See this module's doc comment. pub struct Scanner { pending_dirs: VecDeque, + /// Directory symlinks discovered but not yet expanded, held apart from [`Self::pending_dirs`] + /// and drained only once that queue is empty — issue #73's second half. Both spellings of one + /// directory cannot be walked (see [`Self::visited_dirs`]), so *which* one survives is a real + /// choice, and making it here rather than leaving it to `read_dir`'s unspecified order is + /// what makes it the user's actual folder every time rather than whichever name the + /// filesystem happened to hand over first. + pending_links: VecDeque, pending_files: VecDeque, /// The previous index's `(size, mtime)` per path, consulted for the incremental rule. Built /// once from the `prior` snapshot passed to [`Self::new`] — this scanner never mutates the @@ -165,11 +172,18 @@ pub struct Scanner { /// becomes the next scan's baseline (issue #67). started_at: FileTime, seen: HashSet, - /// Canonical paths of every directory this scan has expanded, plus the targets of every - /// directory symlink it has followed — issue #73's cycle guard. Consulted only when deciding - /// whether to follow a symlink, so an ordinary directory is never skipped for being in it; - /// each distinct target is followed at most once, so a link that leads back into the tree (or - /// to another link that does) terminates instead of recursing forever. + /// The canonical path of every directory this scan has expanded — issue #73's cycle *and* + /// duplicate guard. Consulted at the moment a queued entry is expanded, whatever queued it, so + /// one directory is walked at most once however many spellings of it the tree contains: a link + /// that leads back into the tree terminates instead of recursing forever, and a link that + /// merely names a directory the walk already covered (`Library/Favourites` -> `Library/Amps`, + /// an entirely ordinary setup) contributes no second copy of every file underneath it. + /// + /// Checking here rather than at the point a link is *resolved* is what makes the second half + /// true. A link is resolved while its parent's listing is being read; a plain directory is + /// only queued then, and canonicalised later — so a sibling link was always resolved before + /// its own target had been expanded, found the target unvisited, and was followed, leaving + /// both spellings walked in either listing order. visited_dirs: HashSet, delta: ScanDelta, files_examined: usize, @@ -187,6 +201,7 @@ impl Scanner { .collect(); Scanner { pending_dirs: roots.into_iter().collect(), + pending_links: VecDeque::new(), pending_files: VecDeque::new(), prior: prior_map, prior_scan_started_at: prior.last_scan_started_at(), @@ -212,7 +227,7 @@ impl Scanner { fn progress(&self) -> ScanProgress { ScanProgress { - dirs_pending: self.pending_dirs.len(), + dirs_pending: self.pending_dirs.len() + self.pending_links.len(), files_seen: self.seen.len() + self.pending_files.len(), files_examined: self.files_examined, files_hashed: self.files_hashed, @@ -231,18 +246,81 @@ impl Scanner { self.expand_dir(fs, &dir); return Step::Progressed(self.progress()); } + // Only once no plain directory is left anywhere: see `pending_links`. + if let Some(link) = self.pending_links.pop_front() { + self.expand_link(fs, &link); + return Step::Progressed(self.progress()); + } self.delta.complete = true; Step::Finished } + /// Expands one plain directory: the visited-set claim first, then the listing. + /// + /// The claim is made here, at expansion, rather than where a link is resolved — see + /// [`Self::visited_dirs`] for why that distinction is the whole of issue #73's duplicate bug. + /// A directory that cannot be canonicalised claims nothing and is listed anyway: the guard + /// degrades to "walk it", which still terminates, rather than to "skip it". + /// + /// A plain directory losing the claim means the scan reached one directory by two names + /// without a symlink of its own in the way — overlapping roots, essentially — so nothing is + /// warned about; it is the same tree, already walked. The prefix is still recorded, because + /// the files under it were `seen` under the *other* spelling and their absence under this one + /// is not evidence that anything was deleted. fn expand_dir(&mut self, fs: &dyn ScanFs, dir: &Path) { - // Recorded before the listing, so a symlink *inside* this directory that points back at - // it is recognised straight away rather than expanding a second copy of it (issue #73). - // A directory that cannot be canonicalised simply isn't recorded — the guard degrades to - // "follow the link", which still terminates, rather than to "skip it". - if let Ok(canonical) = fs.canonical_dir(dir) { - self.visited_dirs.insert(canonical); + if let Ok(canonical) = fs.canonical_dir(dir) + && !self.visited_dirs.insert(canonical) + { + self.delta.unreadable_prefixes.push(dir.to_path_buf()); + return; } + self.list_children(fs, dir); + } + + /// Expands one directory symlink, popped from [`Self::pending_links`] after every plain + /// directory has been expanded. + /// + /// Issue #73: that a symlink is followed at all was never a decision, only a side effect of + /// asking `file_type()` (which does not follow links) and nothing else — and its cost was + /// never recorded: a user who symlinks a model collection into the library root saw an empty + /// library and no diagnostic at all, which is an entirely ordinary setup on Linux and macOS. + /// Following it makes that setup work; [`Self::visited_dirs`] is what replaces the + /// loop-safety the old shape got for free, so a link that points at an ancestor, at a sibling + /// that points back, or at itself is recognised and skipped rather than recursed into. + /// + /// Same claim as [`Self::expand_dir`] makes, then, with the two + /// outcomes a link has that a directory does not: a target that cannot be resolved at all, + /// and a target some other spelling already covered — which, links being expanded last, is + /// always genuinely this link being the redundant name for a directory the user has, so + /// `SYMLINK_NOT_FOLLOWED` is true of it. + fn expand_link(&mut self, fs: &dyn ScanFs, link: &Path) { + let canonical = match fs.canonical_dir(link) { + Ok(canonical) => canonical, + Err(e) => { + self.delta + .warnings + .push(LibraryWarning::new(e.code, e.detail)); + self.delta.unreadable_prefixes.push(link.to_path_buf()); + return; + } + }; + if !self.visited_dirs.insert(canonical) { + self.delta.warnings.push(LibraryWarning::new( + error_codes::SYMLINK_NOT_FOLLOWED, + format!("{}", link.display()), + )); + // Whatever was indexed under this spelling on an earlier scan is still on disk; this + // scan simply reached it by another name. Not a removal. + self.delta.unreadable_prefixes.push(link.to_path_buf()); + return; + } + self.list_children(fs, link); + } + + /// The listing itself, shared by [`Self::expand_dir`] and [`Self::expand_link`] — by this + /// point the directory's claim on [`Self::visited_dirs`] has been made and won, so this is + /// only ever "read one directory and queue what is in it". + fn list_children(&mut self, fs: &dyn ScanFs, dir: &Path) { let listing = match fs.read_dir(dir) { Ok(listing) => listing, Err(e) => { @@ -284,7 +362,10 @@ impl Scanner { continue; } if entry.is_dir_symlink { - self.expand_dir_symlink(fs, &entry.path); + // Queued, not resolved: the visited-set claim belongs at expansion time, and + // deferring it behind every plain directory is what makes the real folder rather + // than the link the spelling that survives (issue #73). + self.pending_links.push_back(entry.path.clone()); continue; } if probe::kind_from_extension(&entry.path).is_some() { @@ -295,42 +376,6 @@ impl Scanner { } } - /// Issue #73: a symlink to a directory is followed, guarded by a visited set of canonical - /// targets. - /// - /// Not following one was never a decision, only a side effect of asking `file_type()` (which - /// does not follow links) and nothing else — and its cost was never recorded: a user who - /// symlinks a model collection into the library root saw an empty library and no diagnostic - /// at all, which is an entirely ordinary setup on Linux and macOS. Following it makes that - /// setup work; the visited set is what replaces the loop-safety the old shape got for free. - /// Each canonical target is followed at most once, so a link that points at an ancestor, at a - /// sibling that points back, or at itself is expanded once and then recognised and skipped — - /// the walk always terminates, and the second spelling is reported rather than silently - /// dropped. - fn expand_dir_symlink(&mut self, fs: &dyn ScanFs, link: &Path) { - let canonical = match fs.canonical_dir(link) { - Ok(canonical) => canonical, - Err(e) => { - self.delta - .warnings - .push(LibraryWarning::new(e.code, e.detail)); - self.delta.unreadable_prefixes.push(link.to_path_buf()); - return; - } - }; - if !self.visited_dirs.insert(canonical) { - self.delta.warnings.push(LibraryWarning::new( - error_codes::SYMLINK_NOT_FOLLOWED, - format!("{}", link.display()), - )); - // Whatever was indexed under this spelling on an earlier scan is still on disk; this - // scan simply reached it by another name. Not a removal. - self.delta.unreadable_prefixes.push(link.to_path_buf()); - return; - } - self.pending_dirs.push_back(link.to_path_buf()); - } - fn examine_file(&mut self, fs: &dyn ScanFs, info: DirEntryInfo) { self.files_examined += 1; self.seen.insert(info.path.clone()); @@ -1085,6 +1130,77 @@ mod tests { ); } + /// **The ordinary case issue #73's guard did not actually cover:** `Library/Favourites` -> + /// `Library/Amps`, both spellings inside the scanned tree. + /// + /// The visited set was consulted only when deciding whether to *follow a link*, and a link is + /// resolved the moment its parent's listing is read, while a plain directory is only *queued* + /// then and canonicalised later. So a sibling link always reached `expand_dir_symlink` before + /// its target had been expanded, found the target's canonical path unvisited, and both + /// spellings were walked — in either listing order, `read_dir`'s order not being guaranteed. + /// Every file underneath got two `LibraryEntry` rows under two paths: each model listed twice + /// in the library, and `paths_for_hash` reporting a duplicate that is not one. + /// + /// The surviving spelling is the real directory, not whichever the listing happened to name + /// first: links are expanded only once every plain directory has been, so the path the user + /// actually has on disk is the one indexed and the skipped one is always genuinely a symlink + /// — which is what makes the `SYMLINK_NOT_FOLLOWED` warning true of it. + #[test] + fn a_symlink_beside_its_own_target_indexes_each_file_once() { + use crate::fs::FakeFs; + for link_listed_first in [true, false] { + let root = PathBuf::from("/fake/root"); + let amps = root.join("amps"); + + let mut fake = FakeFs::new(); + if link_listed_first { + fake.add_dir_symlink(&root, "favourites", &s); + fake.add_dir(&root, "amps"); + } else { + fake.add_dir(&root, "amps"); + fake.add_dir_symlink(&root, "favourites", &s); + } + fake.add_file( + &s, + "amp.nam", + 4, + FileTime::from_system_time(std::time::UNIX_EPOCH), + b"junk".to_vec(), + ); + + let delta = Scanner::new(vec![root.clone()], &Index::empty()).run_to_completion(&fake); + let mut paths: Vec = delta.upserts.iter().map(|e| e.path.clone()).collect(); + paths.sort(); + assert_eq!( + paths, + vec![amps.join("amp.nam")], + "link listed first: {link_listed_first} -- each file indexed once, under the real \ + directory rather than once per spelling" + ); + assert_eq!( + delta.warnings.len(), + 1, + "link listed first: {link_listed_first} -- the second spelling is reported, not \ + silently dropped: {:?}", + delta.warnings + ); + assert_eq!( + delta.warnings[0].code.id, + error_codes::SYMLINK_NOT_FOLLOWED.id + ); + + // And the duplicate is not one the rest of the library has to live with either. + let mut index = Index::empty(); + index.apply(delta); + let hash = namir_core::ContentHash::of(b"junk"); + assert_eq!( + index.paths_for_hash(hash), + [amps.join("amp.nam")], + "link listed first: {link_listed_first} -- one file on disk is one path" + ); + } + } + /// A skipped symlink is not a deletion either: whatever an earlier scan indexed under that /// spelling is still on disk, reached this time under another name. #[test] From 58027e073a36a50b6de99639576d83966c30bdcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:36:48 +0000 Subject: [PATCH 38/44] Run the five unwired gates in CI, and give headless-window teeth (review finding 3 and notes a, b, c) One correction to the finding: only `schema` is new on this branch. `assets`, `error-catalogue`, `feature-guard` and `network-free` were all already unwired before it, having landed at M14 -- four of five predate this PR, not three. All five now run, each step naming what it is the sole mechanism for, with the README lines ci-commands limb 2 requires. `assets` and `schema` gain tests against the real tree, including a negative control that appends a synthetic captured .wav to the real manifest's scan rather than writing to the tree. (b) is the sharpest of the notes and it was demonstrated, not argued: planting a panic! after render made baseview print "Window thread panicked", return normally, and the job print "window closed cleanly" and exit 0. The example now counts frames whose render returned -- reset per attempt, since the sRGB fallback may run the closure twice -- and CI reads the expected count out of the source rather than hard-coding 90, which would go quietly green if the constant shrank. Verified both ways under xvfb-run. (a) needed both offered fixes, because each alone is insufficient. Exact matching everywhere would break `cargo build --workspace` against CI's --all-targets form, a genuine extension, so instead a bare `cargo deny check` is no longer satisfied by a sub-check invocation and is judged by the union of sub-checks the workflow runs. And advisories now actually runs, in its own step. It could not be executed locally -- cargo-deny is not installed here and the advisory DB needs a fetch -- so the first CI run is its first execution. (c) verified: the scan broke only on the next Must, so a Must with a missing method line scanned past the interleaved Shoulds and Coulds and adopted a neighbour's code. The boundary is now any requirement line. No plan movement -- no Must in the FRS was relying on inheritance, so this is a latent-defect fix. Job display names deliberately left stale: renaming a job branch protection may require by name would leave the old check permanently expected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- .github/workflows/ci.yml | 92 ++++++++- .gitignore | 3 + README.md | 36 ++-- .../namir-ui/examples/manual_window_smoke.rs | 57 +++++- xtask/src/ci_commands.rs | 178 ++++++++++++++++-- xtask/src/main.rs | 59 ++++++ xtask/src/traceability.rs | 118 ++++++++++-- 7 files changed, 494 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe90e46..0c5d866 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,9 +141,49 @@ jobs: - name: xtask rt-logging (FR-ERR-030) run: cargo run -p xtask -- rt-logging + # M15 review, finding 3. Five `xtask` subcommands were wired into `xtask/src/main.rs` and + # into no CI step at all: the three steps that follow this comment, `assets` (below + # `params-lock`, beside the other generate-and-diff checks) and `schema` (below `identity`). + # Four of the five -- `network-free`, `error-catalogue`, `feature-guard`, `assets` -- have + # been unwired since M14 built them; `schema` is new on this branch. Each is the *entire* + # mechanism behind a claim made elsewhere in the repository: `crates/namir-fixtures/src/lib.rs` + # carries a plain `// trace: NFR-LIC-050` whose only check is `assets`, `deny.toml` names + # `xtask network-free` as FR-ERR-060's first-party half, and `crates/namir-core/src/error.rs` + # cites `error-catalogue` in eight places. `ci-commands` could not catch this: both its limbs + # compare README.md against this file, so a subcommand named in neither is invisible to it -- + # which is why the README's gate block gained the same five lines in this change. They are in + # this job for the reason every step above is: line-based scans over checked-in source, with + # no OS-specific behaviour to matrix over. + # + # FR-ERR-060/NFR-SEC-030's first-party half. `deny.toml`'s `[bans]` list is the *dependency* + # half (the `network-free` job below runs it); this one refuses any `std::net` name under + # `crates/`, which no dependency ban can see because a first-party crate needs no dependency + # at all to open a socket. + - name: xtask network-free (FR-ERR-060, NFR-SEC-030) + run: cargo run -p xtask -- network-free + + # FR-ERR-020's second conjunct -- "every error path in the code maps to an entry" -- which + # had no artifact before M14 and no gate before this step. + - name: xtask error-catalogue (FR-ERR-020) + run: cargo run -p xtask -- error-catalogue + + # §22 R-17: `--all-features` in any build or release command silently links `clack-host` + # into the shipped cdylib, and `host-ext-tests` must stay non-default. The row's own + # mitigation said "nothing mechanical guards the linkage itself"; this is the mechanism, and + # until now it ran nowhere. + - name: xtask feature-guard (§22 R-17) + run: cargo run -p xtask -- feature-guard + - name: xtask params-lock (FR-PARAM-020) run: cargo run -p xtask -- params-lock + # NFR-LIC-050's whole mechanism: every checked-in asset under `crates/` is in + # `crates/namir-fixtures/assets.lock` with a declared provenance and a matching hash. A + # captured `.wav` committed beside the generated fixtures renders as `unrecorded`, which + # fails this check -- but only if something runs it, which nothing did until now. + - name: xtask assets (NFR-LIC-050) + run: cargo run -p xtask -- assets + # M7: NFR-LIC-030's attribution-file freshness gate. Pure static/text check over # `cargo metadata`'s resolve graph, same reasoning as the two steps above for living in this # job rather than its own -- no OS-specific behaviour to matrix over. @@ -158,6 +198,14 @@ jobs: - name: xtask identity (NFR-DOC-040, NFR-LIC-070) run: cargo run -p xtask -- identity + # FR-STATE-040's `S` half (issue #27), as a build-time check over the hand-authored + # `namir-state` corpus and the sample `xtask preset` writes. `namir-state`'s own + # `tests/schema.rs` runs the same validator under `cargo test --workspace`; this step is the + # form FRS §1.5's `S` names, and the requirement's compound `M plus S` method resolves only + # when both halves are there. + - name: xtask schema (FR-STATE-040) + run: cargo run -p xtask -- schema + # M14 Phase 5: NFR-BUILD-020's *second* half. `identity` above asserts that README.md still # contains the build, run and test commands -- they cannot silently disappear from it. It # compares them against literals in `xtask/src/identity.rs` and against nothing in this file, @@ -282,9 +330,30 @@ jobs: # # Two panic messages on stderr before it succeeds are expected, not a failure: #143's # fallback learns the framebuffer is unavailable by catching `baseview`'s panic, that library - # having no fallible open. The exit status is the assertion. + # having no fallible open. + # + # **The assertion is the frame count, not the exit status alone** (M15 review, note b). This + # step asserted only that the example exited 0, and that was not the property it was here to + # check: `EguiWindow::open_blocking` runs the window on its own thread and joins it with + # `unwrap_or_else(eprintln!)`, and `open_with_srgb_fallback` catches only the *first* + # attempt's panic -- so a `namir_ui::render` that panicked on every single frame would unwind + # that thread, be reported to stderr, return normally, print "window closed cleanly" and + # exit 0. A required job that a totally broken interface passes is not a gate. + # + # So the example counts the frames whose `render` returned, exits 1 unless it drew all of + # them, and prints the count; and this step additionally requires that line, with the number + # read out of the example's own `FRAMES_BEFORE_CLOSE` rather than copied here -- a check that + # hard-codes 90 goes quietly green if the constant changes to something smaller. `pipefail` + # is what keeps `| tee` from masking the example's own status. - name: Open and render the interface with no display (issue #143) - run: xvfb-run -a --server-args="-screen 0 1280x1024x24" cargo run -p namir-ui --example manual_window_smoke + run: | + set -euo pipefail + example=crates/namir-ui/examples/manual_window_smoke.rs + frames=$(sed -n 's/^const FRAMES_BEFORE_CLOSE: u64 = \([0-9][0-9]*\);$/\1/p' "$example") + test -n "$frames" + echo "expecting $frames rendered frames" + xvfb-run -a --server-args="-screen 0 1280x1024x24" cargo run -p namir-ui --example manual_window_smoke 2>&1 | tee smoke.log + grep -qx "manual_window_smoke: rendered $frames of $frames frames; window closed cleanly" smoke.log # Covers D-18.1's "cargo-deny licence audit" (NFR-LIC-020). deny.toml already exists at the # repo root; this job just runs it in CI instead of by hand. @@ -310,6 +379,25 @@ jobs: with: command: check sources + # M15 review, note (a): the **fourth** sub-check, and the last one nothing ran. `cargo deny + # check` -- the command README.md documents, annotated there as an "advisory ... audit" -- + # runs advisories, bans, licenses and sources; CI ran the last three across three steps and + # never the first, and `deny.toml` has no `[advisories]` section, so a RUSTSEC advisory could + # enter Cargo.lock with every gate green. `xtask ci-commands` reported the two files in + # agreement while that was true, because its token-prefix rule read the narrower `check + # licenses` as exercising the broader documented `check`; that rule now refuses a sub-check + # invocation for a bare `cargo deny check` and instead requires the union of the sub-check + # steps to cover all four, which is what this step completes. + # + # No `[advisories]` section is added with it: cargo-deny's own defaults are what the + # documented bare command has always run, and this repository's practice of recording + # `cargo deny check ... green on advisories, bans, licenses and sources` by hand at each + # dependency adoption (`docs/02-architecture.md` §17, §21) is the evidence that the standing + # state is clean. This step is what keeps it so without anyone typing the command. + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check advisories + # M7: D-18.2/FR-ERR-070.5's network-free build gate, deferred here by design since M1 (see this # file's own header comment above) until there was a whole product to check against. FR-ERR-070's # own *Verify* method is S -- "a build-time check that no network-capable dependency is linked diff --git a/.gitignore b/.gitignore index 85d4d10..f9df30a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ trace.etl # running the validator against it. A ~7 MB cdylib copy, reproducible from a build; running that # job locally leaves one behind. staging/ + +# Written by the headless-window smoke step (.github/workflows/ci.yml) when run by hand. +smoke.log diff --git a/README.md b/README.md index 2b1d11e..efa71f0 100644 --- a/README.md +++ b/README.md @@ -93,20 +93,32 @@ its own — is: cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace --no-fail-fast -cargo run -p xtask -- layering # crate dependency-graph and platform-cfg lint -cargo run -p xtask -- rt-logging # no audio-thread module names the logger -cargo run -p xtask -- params-lock # params.lock matches the parameter registry -cargo run -p xtask -- attribution # THIRD-PARTY-NOTICES.md is current -cargo run -p xtask -- identity # brand mark, README and TRADEMARK.md are current -cargo run -p xtask -- ci-commands # this file's commands are the ones CI runs -cargo run -p xtask -- traceability # requirement coverage and generated test-plan diff -cargo deny check # licence, advisory and dependency-ban audit +cargo run -p xtask -- layering # crate dependency-graph and platform-cfg lint +cargo run -p xtask -- rt-logging # no audio-thread module names the logger +cargo run -p xtask -- network-free # no first-party crate names a socket API +cargo run -p xtask -- error-catalogue # every error code is a named entry in a catalogue +cargo run -p xtask -- feature-guard # no --all-features; host-ext-tests stays non-default +cargo run -p xtask -- params-lock # params.lock matches the parameter registry +cargo run -p xtask -- assets # every checked-in test asset declares its provenance +cargo run -p xtask -- attribution # THIRD-PARTY-NOTICES.md is current +cargo run -p xtask -- identity # brand mark, README and TRADEMARK.md are current +cargo run -p xtask -- schema # state and preset documents match the format document +cargo run -p xtask -- ci-commands # this file's commands are the ones CI runs +cargo run -p xtask -- traceability # requirement coverage and generated test-plan diff +cargo deny check # advisory, licence, dependency-ban and source audit ``` -`params-lock`, `attribution`, `identity` and `traceability` take `--write` to regenerate their -artifact instead of verifying it. `traceability` also takes `--allow-uncovered`, which is the form -CI gates on until requirement coverage reaches zero gaps; the plain form runs alongside it as an -informational step. +`params-lock`, `attribution`, `assets`, `identity` and `traceability` take `--write` to regenerate +their artifact instead of verifying it — except that `assets` never writes the provenance column, +which is a human declaration a tool cannot mint. `traceability` also takes `--allow-uncovered`, +which is the form CI gates on until requirement coverage reaches zero gaps; the plain form runs +alongside it as an informational step. `schema` takes zero or more paths and checks the checked-in +corpus when given none. + +`cargo deny check` runs four sub-checks — advisories, bans, licenses and sources — and CI runs each +of them as its own `cargo-deny-action` step rather than the bare command. `xtask ci-commands` +requires those steps to cover all four between them, because a `cargo deny check ` step +runs *less* than the command documented here, not more. A pre-commit hook running the fast half of the gate (`cargo fmt --check` plus `cargo check --workspace --all-targets`) is available; opt in once per clone with: diff --git a/crates/namir-ui/examples/manual_window_smoke.rs b/crates/namir-ui/examples/manual_window_smoke.rs index cab6c84..0d3dfdd 100644 --- a/crates/namir-ui/examples/manual_window_smoke.rs +++ b/crates/namir-ui/examples/manual_window_smoke.rs @@ -20,8 +20,19 @@ //! `Could not fetch framebuffer config: CreationFailed(NoValidFBConfig)` before that change and //! renders its 90 frames and exits 0 after it. `DISPLAY=:99 cargo run --example //! manual_window_smoke -p namir-ui` is the whole invocation. +//! +//! **Its exit status is an assertion about frames rendered, not about reaching the end of `main`** +//! (M15 review, note b). `EguiWindow::open_blocking` runs the window on its own thread and joins it +//! with `unwrap_or_else(eprintln!)`, and `open_with_srgb_fallback` catches only the *first* +//! attempt's panic -- so a `namir_ui::render` that panicked on every frame would unwind that +//! thread, return here as if the window had closed, and exit 0. Everything the CI job driving this +//! example asserts would have held while the interface drew nothing at all. So the frames are +//! counted outside the window, [`FRAMES_BEFORE_CLOSE`] of them are required, and the final line +//! this prints names the count so a caller can assert on it too. use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use egui_baseview::{EguiWindow, EguiWindowSettings}; use namir_library::{FileTime, Index, ItemKind, ItemMetadata, LibraryEntry, Origin}; @@ -53,14 +64,10 @@ mod error_codes { /// A host that hands back a fixed, representative snapshot -- enough on-screen content (a couple /// of library entries, a notice, non-silent meters) to actually eyeball FR-UI-020's layout, rather /// than an empty screen. -struct SmokeHost { - frames: u64, -} +struct SmokeHost; impl UiHost for SmokeHost { fn snapshot(&mut self) -> UiSnapshot { - self.frames += 1; - let mut index = Index::empty(); index.upsert(LibraryEntry { path: PathBuf::from("marshall/plexi.nam"), @@ -127,13 +134,25 @@ fn main() { ..Default::default() }; + // Frames whose `namir_ui::render` call *returned*, counted here rather than inside the window + // state so it survives the window thread. See this file's header: nothing else in this program + // can tell "the interface drew ninety frames and closed itself" from "the render panicked and + // baseview swallowed it", and the two must not share an exit status. + let rendered = Arc::new(AtomicU64::new(0)); + let counter = Arc::clone(&rendered); + // Through `namir_ui::open_with_srgb_fallback`, exactly as `namir_ui::open_blocking` and // `open_parented` do, so this example opens under a headless X server too (issue #143) -- // which is the whole point of an unattended smoke test. Note what that costs: the closure may // run twice, so the host and view state are built *inside* it rather than moved in from // outside, since the first attempt's copies are dropped with `baseview`'s window thread. - namir_ui::open_with_srgb_fallback(settings, |settings| { - let mut host = SmokeHost { frames: 0 }; + namir_ui::open_with_srgb_fallback(settings, move |settings| { + // A retry counts from zero. The first attempt fails while opening the window, so it has + // drawn nothing -- but adding two partial attempts together would be the one arithmetic + // that could satisfy the assertion below without a single complete run. + counter.store(0, Ordering::Relaxed); + let frames = Arc::clone(&counter); + let mut host = SmokeHost; let mut view = ViewState::default(); EguiWindow::open_blocking( @@ -150,15 +169,33 @@ fn main() { for intent in intents { host.dispatch(intent); } + // After `render` returned, never before it: a frame that panicked half-way through + // painting is not a frame this example may count. + let drawn = frames.fetch_add(1, Ordering::Relaxed) + 1; ui.ctx().request_repaint(); - if host.frames >= FRAMES_BEFORE_CLOSE { - println!("rendered {} frames; closing", host.frames); + if drawn >= FRAMES_BEFORE_CLOSE { + println!("rendered {drawn} frames; closing"); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } }, ); }); - println!("window closed cleanly"); + let drawn = rendered.load(Ordering::Relaxed); + if drawn < FRAMES_BEFORE_CLOSE { + eprintln!( + "manual_window_smoke: rendered {drawn} of {FRAMES_BEFORE_CLOSE} frames -- the window \ + closed before the interface had been drawn. A panic inside namir_ui::render unwinds \ + baseview's window thread, which open_blocking joins and reports without failing, so \ + this is what a broken render looks like from outside the window." + ); + std::process::exit(1); + } + + // The line CI greps for. It names both numbers so the assertion is on the count rather than on + // this program having reached its last statement. + println!( + "manual_window_smoke: rendered {drawn} of {FRAMES_BEFORE_CLOSE} frames; window closed cleanly" + ); } diff --git a/xtask/src/ci_commands.rs b/xtask/src/ci_commands.rs index 959ea4e..4ecb828 100644 --- a/xtask/src/ci_commands.rs +++ b/xtask/src/ci_commands.rs @@ -32,6 +32,20 @@ //! covered because `ci.yml` also runs the plain form, not because this check would have caught it. //! An addition that only weakens is a thing a reader of `ci.yml` has to notice. //! +//! **`cargo deny check` is the one narrowing case that is not left to a reader** (M15 review). Its +//! extra argument is not a flag at all — it *selects a sub-check*, so `cargo deny check licenses` +//! runs strictly **less** than the documented bare `cargo deny check`, which runs all four of +//! advisories, bans, licenses and sources. The token-prefix rule certified the narrow invocation as +//! exercising the broad one, and the consequence was concrete rather than theoretical: `ci.yml` ran +//! `licenses`, `sources` and `bans` and never `advisories`, and `deny.toml` has no `[advisories]` +//! section, so a RUSTSEC advisory could enter `Cargo.lock` with this gate reporting agreement and +//! the README's own annotation calling itself an "advisory ... audit". Requiring an exact match +//! everywhere was rejected — it would break `cargo build --workspace` against CI's +//! `--all-targets` form, which is a genuine extension — so the narrowing is named where it happens: +//! [`is_exercised_by`] refuses to satisfy a bare `cargo deny check` with a sub-check invocation, +//! and [`check_documented_are_run`] satisfies it from the **union** of the sub-checks `ci.yml` +//! runs, reporting by name any of [`DENY_SUBCHECKS`] that no step runs. +//! //! **Limb 2 is limited to `xtask` subcommands, deliberately.** Requiring *every* `cargo` command in //! `ci.yml` to appear in the README would demand that the README document a coverage run, three //! cross-build targets and two benchmark invocations, which is not what NFR-BUILD-020 asks of a @@ -67,6 +81,14 @@ pub const README_PATH: &str = "README.md"; /// like any other invocation. const DENY_ACTION: &str = "EmbarkStudios/cargo-deny-action"; +/// Every sub-check a bare `cargo deny check` runs, which is what `README.md` documents. +/// +/// `cargo deny`'s own default when `check` is given no argument. Named here rather than inferred, +/// because the whole point is that a documented bare `check` is only exercised when CI's +/// invocations *between them* cover all four — and the one that was missing until M15, +/// `advisories`, is the one whose absence nothing else in the repository would have shown. +pub const DENY_SUBCHECKS: [&str; 4] = ["advisories", "bans", "licenses", "sources"]; + /// Commands the README documents that no GitHub-hosted runner can execute, each with the reason it /// is here. An exemption list rather than a silent skip: this is precisely the residue /// NFR-BUILD-020's `uncovered:` field has to name, so it is enumerated in one place a reader can @@ -139,10 +161,24 @@ pub fn workflow_commands(doc: &Yaml) -> Result, String> { Ok(commands) } +/// Whether the command line is exactly `cargo deny check`, whose extra arguments select a +/// sub-check and therefore *narrow* it rather than extending it. +pub fn is_bare_deny_check(command: &str) -> bool { + command.split_whitespace().collect::>() == ["cargo", "deny", "check"] +} + /// Whether `ci` runs `documented`: the same command, or the same command with extra trailing /// arguments. Token-wise, never by substring — `cargo test --workspace` must not be satisfied by /// `cargo test --workspace-does-not-exist`, and a prefix test on the raw strings would say it is. +/// +/// One exception, and it is inside the predicate rather than at a call site so that no caller can +/// forget it: a bare `cargo deny check` is **not** exercised by `cargo deny check `. +/// Those trailing tokens select one of [`DENY_SUBCHECKS`] and run less than the documented command, +/// not more. [`check_documented_are_run`] is where the union of them is judged instead. pub fn is_exercised_by(documented: &str, ci: &str) -> bool { + if is_bare_deny_check(documented) && !is_bare_deny_check(ci) { + return false; + } let mut documented_tokens = documented.split_whitespace(); let mut ci_tokens = ci.split_whitespace(); loop { @@ -171,24 +207,66 @@ pub fn xtask_subcommand(command: &str) -> Option<&str> { tokens.get(separator + 1).copied() } -/// Limb 1: every documented command is run by CI, or is in [`UNEXERCISABLE`] with its reason. -pub fn check_documented_are_run(documented: &[String], ci: &[String]) -> Vec { - documented +/// The sub-checks of [`DENY_SUBCHECKS`] that no invocation in `ci` runs, empty when a bare +/// `cargo deny check` covers all four at once. +/// +/// One invocation may name several (`cargo deny check bans sources` is legal), so the answer is +/// the union over every `cargo deny check ...` line, not a per-line comparison. +pub fn missing_deny_subchecks(ci: &[String]) -> Vec<&'static str> { + if ci.iter().any(|run| is_bare_deny_check(run)) { + return Vec::new(); + } + DENY_SUBCHECKS .iter() - .filter(|command| { - !UNEXERCISABLE - .iter() - .any(|(exempt, _)| exempt == &command.as_str()) - && !ci.iter().any(|run| is_exercised_by(command, run)) - }) - .map(|command| { - format!( - "README.md documents `{command}`, which no step in {WORKFLOW_PATH} runs. {REMEDY}" - ) + .copied() + .filter(|sub| { + !ci.iter().any(|run| { + let tokens: Vec<&str> = run.split_whitespace().collect(); + tokens.len() > 3 + && tokens[..3] == ["cargo", "deny", "check"] + && tokens[3..].contains(sub) + }) }) .collect() } +/// Limb 1: every documented command is run by CI, or is in [`UNEXERCISABLE`] with its reason. +/// +/// `cargo deny check` is judged by the union rule above rather than by [`is_exercised_by`] — see +/// this module's header for why its sub-check arguments narrow the command instead of extending +/// it, and what that hid until M15. +pub fn check_documented_are_run(documented: &[String], ci: &[String]) -> Vec { + let mut violations = Vec::new(); + for command in documented { + if UNEXERCISABLE + .iter() + .any(|(exempt, _)| exempt == &command.as_str()) + { + continue; + } + if is_bare_deny_check(command) { + let missing = missing_deny_subchecks(ci); + if !missing.is_empty() { + violations.push(format!( + "README.md documents `{command}`, which runs all of {}, but no step in \ + {WORKFLOW_PATH} runs: {}. A `cargo deny check ` step narrows the \ + documented command rather than extending it, so the documented one is only \ + exercised when the steps between them cover every sub-check. {REMEDY}", + DENY_SUBCHECKS.join(", "), + missing.join(", ") + )); + } + continue; + } + if !ci.iter().any(|run| is_exercised_by(command, run)) { + violations.push(format!( + "README.md documents `{command}`, which no step in {WORKFLOW_PATH} runs. {REMEDY}" + )); + } + } + violations +} + /// Limb 2: every `xtask` subcommand CI runs is documented in the README. pub fn check_run_are_documented(documented: &[String], ci: &[String]) -> Vec { let documented_subcommands: Vec<&str> = documented @@ -402,7 +480,79 @@ jobs: .unwrap(); let commands = workflow_commands(&doc).unwrap(); assert_eq!(commands, vec!["cargo deny check licenses".to_string()]); - assert!(check_documented_are_run(&["cargo deny check".to_string()], &commands).is_empty()); + } + + /// M15 review, note (a). The assertion above this one used to continue "...and that satisfies + /// the documented `cargo deny check`", which was the defect: `check licenses` runs one of the + /// four sub-checks the bare command runs, so certifying it as exercising the bare command let + /// `ci.yml` skip `advisories` — with `deny.toml` carrying no `[advisories]` section — while + /// this gate reported the two files in agreement. + #[test] + fn a_sub_check_alone_does_not_exercise_the_bare_cargo_deny_check() { + let violations = check_documented_are_run( + &["cargo deny check".to_string()], + &[ + "cargo deny check licenses".to_string(), + "cargo deny check sources".to_string(), + "cargo deny check bans".to_string(), + ], + ); + assert_eq!(violations.len(), 1, "{violations:#?}"); + assert!( + violations[0].contains("runs: advisories"), + "{violations:#?}" + ); + assert_eq!( + missing_deny_subchecks(&[ + "cargo deny check licenses".to_string(), + "cargo deny check sources".to_string(), + "cargo deny check bans".to_string(), + ]), + vec!["advisories"], + "only the sub-check nothing runs is named" + ); + } + + /// And the union is what satisfies it: four steps between them, in any order, and one step + /// naming two sub-checks counts for both. + #[test] + fn the_union_of_the_sub_checks_exercises_the_bare_cargo_deny_check() { + for ci in [ + vec![ + "cargo deny check advisories".to_string(), + "cargo deny check bans".to_string(), + "cargo deny check licenses".to_string(), + "cargo deny check sources".to_string(), + ], + vec![ + "cargo deny check bans sources".to_string(), + "cargo deny check advisories licenses".to_string(), + ], + vec!["cargo deny check".to_string()], + ] { + assert!( + check_documented_are_run(&["cargo deny check".to_string()], &ci).is_empty(), + "{ci:#?}" + ); + assert!(missing_deny_subchecks(&ci).is_empty(), "{ci:#?}"); + } + } + + /// The narrowing rule lives inside [`is_exercised_by`] so no caller can forget it, and it is + /// narrow itself: an ordinary extending flag is still an extension. + #[test] + fn a_narrowing_argument_is_refused_where_an_extending_one_is_not() { + assert!(!is_exercised_by( + "cargo deny check", + "cargo deny check bans" + )); + assert!(is_exercised_by("cargo deny check", "cargo deny check")); + assert!(is_exercised_by( + "cargo build --workspace", + "cargo build --workspace --all-targets" + )); + assert!(is_bare_deny_check("cargo deny check")); + assert!(!is_bare_deny_check("cargo deny check advisories")); } /// M14 Phase 5: NFR-BUILD-020's second half against the real pair of files. Its first half — diff --git a/xtask/src/main.rs b/xtask/src/main.rs index da30f3c..c680c66 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1238,6 +1238,65 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + // --- NFR-LIC-050: every checked-in asset declares its provenance ------------------------- + + /// The gate as CI should run it, over the real tree — the shape `rt-logging`, `network-free`, + /// `error-catalogue` and `feature-guard` already had and this one did not (M15 review, finding + /// 3). `assets.rs`'s own tests all run against a two-file scratch tree, so **nothing executed + /// this check against the repository it exists to police**, while + /// `crates/namir-fixtures/src/lib.rs` carries a plain `// trace: NFR-LIC-050` asserting it + /// does. A captured `.wav` committed under `crates/` renders as `unrecorded`, which fails + /// here; before this test and the CI step beside it, it failed nowhere. + #[test] + fn every_checked_in_asset_in_the_real_tree_declares_its_provenance() { + assert!(run_assets(&repo_root(), false)); + } + + /// The negative control, and deliberately over the **real** manifest rather than a scratch + /// one: the file this check reads is `crates/namir-fixtures/assets.lock` as committed, and one + /// captured `.wav` dropped in beside the generated fixtures is exactly what NFR-LIC-050 and + /// D-19.1 exist to catch. Nothing is written to the tree — the planted asset is added to the + /// scan's result, which is the same input `check_or_write` would have built had the file been + /// there. + #[test] + fn a_planted_captured_asset_fails_the_real_manifest() { + let root = repo_root(); + let manifest = std::fs::read_to_string(root.join(assets::ASSET_MANIFEST_PATH)).unwrap(); + let recorded = assets::parse(&manifest).unwrap(); + let mut actual = assets::scan(&root, &recorded).unwrap(); + assert!( + assets::violations(&recorded, &actual).is_empty(), + "the real tree must start clean" + ); + + actual.push(assets::AssetEntry { + path: "crates/namir-fixtures/assets/captured-cab.wav".to_string(), + bytes: 12, + hash: namir_core::ContentHash::of(b"RIFF....WAVE").to_string(), + provenance: assets::UNRECORDED.to_string(), + }); + let violations = assets::violations(&recorded, &actual); + assert_eq!(violations.len(), 1, "{violations:#?}"); + assert!( + violations[0].contains("captured-cab.wav"), + "{violations:#?}" + ); + assert!( + violations[0].contains("not in the manifest"), + "{violations:#?}" + ); + } + + // --- FR-STATE-040's S half: every checked-in state document matches the format document ---- + + /// The same shape for `schema`, wired into CI by the same change. Its validator has unit tests + /// in `namir-state` and an integration test over the corpus there; what had no artifact was + /// the subcommand itself — the build-time face FRS §1.5's `S` names — against the real corpus. + #[test] + fn every_checked_in_state_document_in_the_real_tree_matches_the_format() { + assert!(schema::run(&repo_root(), &[])); + } + // --- §22 R-17 (issue #25): the --all-features guard, wired to the real tree ----------------- /// The gate as CI should run it, against the real repository. Doubles as the existence check diff --git a/xtask/src/traceability.rs b/xtask/src/traceability.rs index dca1b4c..3dc7fc2 100644 --- a/xtask/src/traceability.rs +++ b/xtask/src/traceability.rs @@ -77,6 +77,16 @@ pub struct Requirement { /// not treated as a parse boundary). A requirement line with no `*Verify:*` found before either /// the next requirement or end of file is a malformed-FRS error, surfaced rather than silently /// dropped, since a silently-skipped requirement would defeat the whole point of this check. +/// +/// **The forward scan stops at the next requirement of *any* priority** ([`extract_requirement`]), +/// not at the next Must. It stopped at the next Must until M15, which made the error above +/// unreachable across a run of non-Must neighbours: a Must whose `*Verify:*` line was missing, +/// misspelt (`*Verify*`, `*Verify :*`) or demoted to prose scanned straight through the following +/// `(Should)`/`(Could)` requirements and adopted the first method it found -- silently recording +/// that neighbour's code as the Must's own. Since 31 Shoulds and 3 Coulds are interleaved with the +/// 130 Musts, that is not a hypothetical: the bar `docs/03-test-plan.md` states for the Must would +/// have been whatever the Should asked for, and nothing anywhere would have said so. Inheriting a +/// method is never right, so the boundary is every requirement line. pub fn parse_must_requirements(frs_text: &str) -> Result, String> { let lines: Vec<&str> = frs_text.lines().collect(); let mut out = Vec::new(); @@ -89,9 +99,11 @@ pub fn parse_must_requirements(frs_text: &str) -> Result, Strin } if let Some(id) = extract_must_id(lines[i]) { let mut verify = None; + let mut next_requirement = None; let mut j = i + 1; while j < lines.len() { - if extract_must_id(lines[j]).is_some() { + if let Some((next_id, priority)) = extract_requirement(lines[j]) { + next_requirement = Some(format!("{next_id} ({priority})")); break; } if let Some(head) = verify_line_text(lines[j]) { @@ -127,10 +139,14 @@ pub fn parse_must_requirements(frs_text: &str) -> Result, Strin section: current_section.clone(), }), None => { + let stopped_at = next_requirement + .map_or_else(|| "the end of the file".to_string(), |r| format!("**{r}**")); return Err(format!( - "no *Verify:* line found for {id} before the next requirement or end of \ - file -- the FRS is malformed, or this parser's assumptions about its \ - layout no longer hold" + "no *Verify:* line found for {id} before {stopped_at} -- the FRS is \ + malformed (a missing or misspelt `*Verify:*` marker), or this parser's \ + assumptions about its layout no longer hold. The scan stops at the next \ + requirement of any priority precisely so a Must cannot inherit a \ + following Should's or Could's method" )); } } @@ -141,20 +157,34 @@ pub fn parse_must_requirements(frs_text: &str) -> Result, Strin Ok(out) } -/// `"**FR-CHAIN-010 (Must)** — ..."` -> `Some("FR-CHAIN-010")`. `None` for `(Should)`/`(Could)`/ -/// `(Won't)` lines, and for anything not starting with a bolded `FR-`/`NFR-` id. -fn extract_must_id(line: &str) -> Option { +/// `"**FR-CHAIN-010 (Must)** — ..."` -> `Some(("FR-CHAIN-010", "Must"))`, and +/// `"**FR-CFG-040 (Should)** — ..."` -> `Some(("FR-CFG-040", "Should"))`. `None` for anything not +/// starting with a bolded `FR-`/`NFR-` id and its priority. +/// +/// Priority-blind on purpose: this is the boundary [`parse_must_requirements`]'s forward scan +/// stops at, and a Must must not adopt a neighbour's `*Verify:*` whatever that neighbour's +/// priority is. [`extract_must_id`] is the Must-only filter over it. +fn extract_requirement(line: &str) -> Option<(String, String)> { let rest = line.strip_prefix("**")?; let end = rest.find("**")?; let inside = &rest[..end]; let (id_part, tag_part) = inside.split_once(" (")?; - if tag_part.trim_end_matches(')') != "Must" { - return None; - } if !(id_part.starts_with("FR-") || id_part.starts_with("NFR-")) { return None; } - Some(id_part.to_string()) + Some(( + id_part.to_string(), + tag_part.trim_end_matches(')').to_string(), + )) +} + +/// `"**FR-CHAIN-010 (Must)** — ..."` -> `Some("FR-CHAIN-010")`. `None` for `(Should)`/`(Could)`/ +/// `(Won't)` lines, and for anything not starting with a bolded `FR-`/`NFR-` id. +fn extract_must_id(line: &str) -> Option { + match extract_requirement(line) { + Some((id, priority)) if priority == "Must" => Some(id), + _ => None, + } } /// `"## 4. Product configurations"` -> `Some("4")`, `"### 5.1 Signal chain (CHAIN)"` -> @@ -1954,6 +1984,72 @@ mod tests { assert!(err.contains("FR-X-010")); } + /// The gap the M15 review found: the forward scan broke on the next **Must**, so a Must with + /// no method line of its own read straight through the `(Should)` between them and adopted + /// **its** `*Verify:*`. `FR-X-010` here would have been recorded as `Verify: B` -- a code the + /// FRS never wrote for it -- and `docs/03-test-plan.md` would have stated that bar with + /// nothing anywhere saying where it came from. Inheritance is never the right answer, so this + /// is an error. + #[test] + fn a_must_does_not_inherit_a_following_shoulds_verify_line() { + let frs = "**FR-X-010 (Must)** — text whose *Verify* marker was misspelt.\n\ + *Verify* U — no colon, so this line is not a method line.\n\ + **FR-X-020 (Should)** — a neighbour that does have one.\n\ + *Verify:* B.\n"; + let err = parse_must_requirements(frs).unwrap_err(); + assert!(err.contains("FR-X-010"), "{err}"); + assert!(err.contains("FR-X-020 (Should)"), "{err}"); + } + + /// The same for a `(Could)`, and for the end of the file with no neighbour at all -- the two + /// other ways the scan can terminate. The message names what it stopped at in each case. + #[test] + fn the_scan_stops_at_a_could_and_at_the_end_of_the_file() { + let after_could = parse_must_requirements( + "**FR-X-010 (Must)** — no method line.\n\ + **FR-X-020 (Could)** — a neighbour that has one.\n\ + *Verify:* U.\n", + ) + .unwrap_err(); + assert!(after_could.contains("FR-X-020 (Could)"), "{after_could}"); + + let at_eof = parse_must_requirements( + "**FR-X-010 (Must)** — no method line, and nothing after it.\n", + ) + .unwrap_err(); + assert!(at_eof.contains("the end of the file"), "{at_eof}"); + } + + /// And the property the boundary must not break: a Should sitting between a Must and its own + /// `*Verify:*` line does not exist in the FRS, but a Should *after* a complete Must is + /// everywhere in it. The Must keeps its own method and the Should is still ignored. + #[test] + fn a_should_after_a_complete_must_changes_nothing() { + let frs = "**FR-X-010 (Must)** — text.\n\ + *Verify:* G.\n\ + **FR-X-020 (Should)** — a neighbour.\n\ + *Verify:* B.\n\ + **FR-X-030 (Must)** — text.\n\ + *Verify:* S.\n"; + let reqs = parse_must_requirements(frs).unwrap(); + assert_eq!(reqs.len(), 2, "{reqs:#?}"); + assert_eq!(reqs[0].verify, vec!['G']); + assert_eq!(reqs[1].verify, vec!['S']); + } + + /// The real FRS parses under the stricter boundary: no Must in it depends on inheriting a + /// neighbour's method, which is the empirical half of the claim above. + #[test] + fn every_must_in_the_real_frs_states_its_own_verify_line() { + let frs = include_str!("../../docs/01-functional-requirements.md"); + let reqs = parse_must_requirements(frs).unwrap(); + assert_eq!( + reqs.len(), + 130, + "the FRS's Must count moved; update this figure" + ); + } + fn ids(source: &str) -> Vec { scan_annotations(source) .unwrap() From b7466909269c0ddd7fa00919406d6f9bdc34bfe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:37:34 +0000 Subject: [PATCH 39/44] Make the log ceiling a property of the file, and refuse Win32 device names (review findings 11, 12) Finding 11 is worse than described, and both halves are now measured rather than asserted. Each writer's counter tracked only its own appends, so neither measured the file; and once one process renamed namir.log the other kept appending into the renamed generation until a later rotation renamed a third generation over a file still being written. Two writers, 400 records: 85 of 400 survived with holes mid-history, largest generation 8,270,888 bytes against a 4 MiB cap. After: 148 of 400, contiguous, nothing past the cap. FileTarget now holds three paths and no state -- open, read the length of the file you opened, rotate if this line would pass the cap, write, close -- and rotate re-stats immediately before the renames, abandoning it if the file shrank because another writer got there first. 4.4 us per record, and none of the twelve record call sites is in a loop or reachable from the audio thread. Rejected with reasons: per-process files contradict D-16.5's own three-file set and its 12 MiB arithmetic; an advisory lock would need a fourth file where clause_3 asserts three, and would let a diagnostic writer block on another process's stuck rotation. The module doc had claimed the writer "keeps its handle and retries" -- code that never existed, since rotate dropped the handle before either rename. D-16.5 gains a consequence note recording both corrections, the measurements, and what is still open. Its "inferred, not measured" line about renaming over a file another process holds open is now measured, so Windows CI exercises it. Finding 12: sanitise_name accepted CON, NUL, COM1 and the rest, so a save succeeded against the device, wrote nothing, and produced a preset that never appeared in the recall list. Refused on every platform, matched the way Win32 resolves them -- before the first dot, trailing spaces ignored, so CON.old and "nul " go too, including the superscript COM folds. Near-misses stay legal. Case collision is documented at the function rather than dropped: it is given a name and no directory, so it cannot see one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-platform/src/logging.rs | 193 +++++++++++++++---------- crates/namir-platform/src/presets.rs | 131 ++++++++++++++++- crates/namir-platform/tests/logging.rs | 122 ++++++++++++++++ docs/02-architecture.md | 34 +++++ 4 files changed, 403 insertions(+), 77 deletions(-) diff --git a/crates/namir-platform/src/logging.rs b/crates/namir-platform/src/logging.rs index d701899..a802990 100644 --- a/crates/namir-platform/src/logging.rs +++ b/crates/namir-platform/src/logging.rs @@ -38,9 +38,11 @@ //! //! **What this module deliberately does not have.** No `BufWriter`: a half-flushed buffer loses //! precisely the records written in the moments a crash makes interesting, so a record is exactly -//! one `write_all` of a complete line. No logger thread: NFR-PORT-030 forbids assuming a process -//! can spawn unlimited threads, and a thread parked inside a `.clap` the host may unload would -//! need a shutdown handshake the synchronous design needs not at all. No `#[cfg(target_os)]`: +//! one `write_all` of a complete line. No open handle and no byte counter held between records +//! either — see "Two writers, one file" below, which is the reason. No logger thread: +//! NFR-PORT-030 forbids assuming a process can spawn unlimited threads, and a thread parked +//! inside a `.clap` the host may unload would need a shutdown handshake the synchronous design +//! needs not at all. No `#[cfg(target_os)]`: //! every platform difference is absorbed by [`crate::log_file_path`], which yields `None` on //! Android and iOS, and on `None` this module builds a **no-op sink** — the level check still //! runs, every record is dropped, no file is created, no error is raised. No dependency either: @@ -48,12 +50,36 @@ //! a date library. And no `unsafe`, so this crate's `unsafe_code = "deny"` is satisfied without a //! third designated module beside `denormal.rs` and `thread_priority.rs`. //! -//! **Two processes share one file.** The standalone application and a DAW hosting the plugin both -//! write `namir.log`; every plugin instance inside one DAW is covered by the process-global mutex, -//! but two processes are not. Records stay attributable because each carries its pid. A failed -//! `fs::rename` is therefore an ordinary outcome here, never an `unwrap` — the writer keeps its -//! handle and retries the size check on the next record, so the 12 MiB ceiling can be exceeded -//! transiently by a losing process but not indefinitely. +//! **Two writers, one file.** The standalone application and a DAW hosting the plugin both write +//! `namir.log`, and a user running both at once is ordinary rather than exotic. Every plugin +//! instance inside one DAW is covered by the process-global mutex; two processes are not, and no +//! lock here can cover them. Records stay attributable because each carries its pid. What makes +//! the *file* survive the arrangement is that this writer keeps no state about it: an admitted +//! record opens the path, reads the real length from the file it opened, rotates if this line +//! would carry that length past [`LOG_MAX_BYTES`], writes exactly one line, and closes. So the cap +//! is measured against every writer's bytes rather than against one writer's tally of its own, and +//! each record lands in the file that bears the name at the moment it is written. +//! +//! That is not a refinement of an earlier design, it is the repair of a real data-loss path (#145, +//! finding 11). A writer that seeds a counter at open and increments it by its own writes +//! rotates late by exactly the other writer's contribution — measured at roughly double the 4 MiB +//! cap — and a writer that keeps its handle goes on appending into whatever generation another +//! process's rotation renamed its file into, until its own counter fires and it renames the live +//! file over the one it was itself writing. Two writers alternating 400 records through the +//! previous design left **85** of them in the three files, with holes in the middle of the +//! history; the same run through this one leaves 148, contiguous, newest-first, inside the ceiling. +//! +//! What is *not* claimed. Rotation re-measures the file immediately before its renames and gives +//! up when another writer has already rotated, but the window between that measurement and the +//! rename is not closed: two writers crossing the cap within microseconds of each other can still +//! rotate twice in a row, which retires one generation early — history is shortened, nothing is +//! destroyed. Closing that would take an advisory lock on a file whose name never changes, which +//! means a fourth file in a directory D-16.5 specifies as three, so it is not taken here. A record +//! whose write is in flight when another process renames the file lands in the renamed +//! generation — one record, still on disk. A failed `fs::rename` is an ordinary outcome, never an +//! `unwrap`: no rotation happens for that record and the size check runs again on the next one. +//! And the 12 MiB ceiling holds for any number of writers up to the one oversized record each may +//! have in flight, since each file is measured as it is written rather than remembered. use std::ffi::OsStr; use std::fmt; @@ -364,7 +390,7 @@ impl Logger { return; }; let SinkState { target, scratch } = &mut *state; - let Some(target) = target.as_mut() else { + let Some(target) = target.as_ref() else { return; }; format_record( @@ -387,15 +413,14 @@ struct SinkState { } /// The file sink and its two retained generations. +/// +/// Deliberately holds **no** open handle and **no** byte counter between records: see +/// [`FileTarget::write_line`]. The whole struct is three paths, so a second writer — in this +/// process or another — cannot make anything it caches wrong, because it caches nothing. struct FileTarget { path: PathBuf, generation_1: PathBuf, generation_2: PathBuf, - /// `None` whenever the file is not currently open — before the first admitted record, between - /// a rotation and its reopen, and after a write or open failure. Every one of those is - /// retried on the next record rather than latched. - file: Option, - written: u64, } impl FileTarget { @@ -404,54 +429,73 @@ impl FileTarget { generation_1: generation(&path, 1), generation_2: generation(&path, 2), path, - file: None, - written: 0, } } - /// Opens the sink if it is not open, adopting whatever length the file already has so a second - /// session appending to a part-full file still rotates at the right point. A failure to create - /// the directory or open the file is not raised anywhere — there is, by construction, nowhere - /// to report it *to* — it simply drops this record and is retried on the next one. - fn ensure_open(&mut self) -> bool { - if self.file.is_some() { - return true; + /// Opens whatever file currently bears the sink's name, for appending, creating it — and its + /// directory, if the first attempt failed for want of one — when it is not there. + /// + /// The directory is created only on the failure path rather than checked every record: the + /// common case is one `open`, and `create_dir_all` on an existing directory is a syscall this + /// module would otherwise make per record for nothing. + /// + /// A failure to create the directory or to open the file is not raised anywhere — there is, by + /// construction, nowhere to report it *to* — it simply drops this record and is retried on the + /// next one, so a disk that filled up and was then freed starts logging again on its own. + fn open(&self) -> Option { + let opened = OpenOptions::new() + .append(true) + .create(true) + .open(&self.path); + if let Ok(file) = opened { + return Some(file); } - if let Some(parent) = self.path.parent() - && !parent.as_os_str().is_empty() - && fs::create_dir_all(parent).is_err() - { - return false; + let parent = self.path.parent()?; + if parent.as_os_str().is_empty() || fs::create_dir_all(parent).is_err() { + return None; } - let Ok(file) = OpenOptions::new() + OpenOptions::new() .append(true) .create(true) .open(&self.path) - else { - return false; - }; - self.written = file.metadata().map(|m| m.len()).unwrap_or_default(); - self.file = Some(file); - true + .ok() } /// Writes one complete line, rotating first if it would carry the file past /// [`LOG_MAX_BYTES`]. /// - /// The `written > 0` guard means a single record larger than the whole cap is written rather - /// than rotated around forever; that is bounded by the record, not unbounded growth, and - /// rotating an empty file would not help. - fn write_line(&mut self, bits: u8, line: &str) { - if !self.ensure_open() { + /// **The file is opened, measured, written and closed inside this one call, every record.** + /// That is what makes the size check and the rotation correct when a second process is writing + /// the same path (#145, finding 11), and it costs one `open` plus one `metadata` per + /// admitted record — paid by a writer whose records are per user action, and never by the + /// audio thread, which cannot reach this module at all. + /// + /// Two properties follow, and neither is available to a writer that keeps a handle. The length + /// is read from the file itself, so it counts *every* writer's bytes rather than this one's + /// tally of its own — a per-writer counter fires late by exactly the other writer's + /// contribution, which measured at roughly double the cap. And each record goes to the file + /// that bears the name *now*, so a writer whose file was renamed away by another process's + /// rotation cannot go on appending into a retired generation — which is what previously let + /// the next rotation rename a live session over the file the other writer was in. + /// + /// The `len > 0` guard means a single record larger than the whole cap is written rather than + /// rotated around forever; that is bounded by the record, not unbounded growth, and rotating + /// an empty file would not help. + fn write_line(&self, bits: u8, line: &str) { + let Some(mut file) = self.open() else { return; - } - let rotated = if self.written > 0 && self.written + line.len() as u64 > LOG_MAX_BYTES { - self.rotate() - } else { - None }; - if !self.ensure_open() { - return; + let len = file.metadata().map(|m| m.len()).unwrap_or_default(); + let mut rotated = None; + if len > 0 && len + line.len() as u64 > LOG_MAX_BYTES { + // Closed before the renames: a rename over a file the *same* process holds open is + // the case most likely to fail. + drop(file); + rotated = self.rotate(len); + let Some(reopened) = self.open() else { + return; + }; + file = reopened; } if let Some(previous) = rotated && admits(bits, LOG_ROTATED.severity) @@ -469,45 +513,46 @@ impl FileTarget { file_label(&self.generation_1), ), ); - self.append(¬ice); + append(&mut file, ¬ice); } - self.append(line); + append(&mut file, line); } /// Rolls `namir.log.1` to `namir.log.2` and `namir.log` to `namir.log.1`, returning the - /// rotated file's length on success. + /// rotated file's length when this call is the one that rotated. /// - /// The handle is dropped before the rename because a rename over a file the *same* process - /// holds open is the case most likely to fail; whether it succeeds over a file *another* - /// process holds open is inferred rather than measured (D-16.5's honest limitation), which is - /// exactly why a failure here returns `None` and leaves the caller to reopen and retry the - /// size check on the next record instead of panicking. Never a fourth generation: the first - /// rename overwrites `namir.log.2`, so at most three files ever exist. - fn rotate(&mut self) -> Option { - let previous = self.written; - self.file = None; + /// `observed` is the length that decided the rotation. The file at the name is re-measured + /// here, immediately before the renames, and the rotation is abandoned when it has since + /// *shrunk*: that means another writer rotated in between, and rotating again would retire a + /// generation that is one record old and discard the one behind it for nothing. The window + /// this leaves — between this measurement and the rename below — is not closed, and cannot be + /// without a lock file this module deliberately does not create; see the module doc. + /// + /// A failure to rename is an ordinary outcome, never an `unwrap`: it means no rotation this + /// record, the line is written to the file as it stands, and the size check runs again on the + /// next record. Never a fourth generation: the first rename overwrites `namir.log.2`, so at + /// most three files ever exist. + fn rotate(&self, observed: u64) -> Option { + let current = fs::metadata(&self.path) + .map(|m| m.len()) + .unwrap_or_default(); + if current < observed { + return None; + } // May legitimately fail because .1 does not exist yet; the outcome that matters is the // second rename. let _ = fs::rename(&self.generation_1, &self.generation_2); if fs::rename(&self.path, &self.generation_1).is_err() { return None; } - self.written = 0; - Some(previous) + Some(current) } +} - fn append(&mut self, line: &str) { - let Some(file) = self.file.as_mut() else { - return; - }; - if file.write_all(line.as_bytes()).is_ok() { - self.written += line.len() as u64; - } else { - // Drop the handle so the next record reopens: a disk that filled up and was then - // freed should start logging again on its own. - self.file = None; - } - } +/// One `write_all` of one complete line. A failed write drops the record: the handle is closed at +/// the end of the record either way, so there is no latched failure state to clear. +fn append(file: &mut File, line: &str) { + let _ = file.write_all(line.as_bytes()); } /// `namir.log` + `.1` / `.2`. Appends to the whole file name rather than replacing an extension, diff --git a/crates/namir-platform/src/presets.rs b/crates/namir-platform/src/presets.rs index b2ad875..1daaa08 100644 --- a/crates/namir-platform/src/presets.rs +++ b/crates/namir-platform/src/presets.rs @@ -20,7 +20,11 @@ //! //! `UiIntent::SavePreset` carries "a name, not a path", already trimmed and non-empty, and says //! that a name illegal as a filename is *the host's* to reject. [`sanitise_name`] is that rule, -//! shared so that a name one product accepts is never one the other refuses. +//! shared so that a name one product accepts is never one the other refuses. It is held to +//! Windows's naming rules on every platform — illegal characters *and* the reserved device names +//! `CON`/`NUL`/`COM1`/… — because a preset one platform can write and another cannot open is +//! exactly the interchangeability FR-STATE-030 claims, failing quietly. What it deliberately does +//! not cover, and why, is on [`sanitise_name`] itself: names that differ only in case. use std::path::{Path, PathBuf}; @@ -62,10 +66,23 @@ pub fn preset_path(dir: &Path, name: &str) -> Option { /// directory. /// /// Rejected: anything empty once trimmed, anything containing a path separator of either platform -/// (so a name can never reach a sibling directory), anything that is `.` or `..`, and anything -/// containing a character Windows refuses in a filename. The last is checked on every platform on +/// (so a name can never reach a sibling directory), anything that is `.` or `..`, anything +/// containing a character Windows refuses in a filename, and anything Win32 resolves as a device +/// rather than as a file (`names_a_win32_device`, below). The last two are checked on every platform +/// on /// purpose: a preset saved on Linux under a name Windows cannot represent would be a preset the /// other half of FR-STATE-030's interchangeability claim cannot open. +/// +/// # What this rule does *not* cover +/// +/// Two names differing only in case — `Crunch` and `crunch` — are two files on Linux and one file +/// on Windows and on a default-configured macOS. This function cannot see that: it is given a name +/// and no directory, so it has nothing to compare against. Saving `crunch` where `Crunch` already +/// exists therefore silently replaces it on those platforms, and the recall list shows whichever +/// spelling the filesystem kept. Closing that needs a directory listing and a decision about what +/// to do when a collision is found (refuse, or ask the user to confirm an overwrite), both of which +/// belong to the shells' save flow rather than to a naming predicate. Recorded here rather than +/// silently left, so the limit is visible at the function every save goes through. #[must_use] pub fn sanitise_name(name: &str) -> Option<&str> { let name = name.trim(); @@ -77,9 +94,61 @@ pub fn sanitise_name(name: &str) -> Option<&str> { }) { return None; } + if names_a_win32_device(name) { + return None; + } Some(name) } +/// Whether Win32 would resolve `name` as one of its reserved device names rather than as a file. +/// +/// `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9`, matched +/// case-insensitively against the part of the name *before its first `.`* and ignoring trailing +/// spaces — because that is how Win32 itself resolves them. The extension is irrelevant +/// (`CON.namirpreset` is the console), and so is the directory +/// (`%APPDATA%\Namir\Presets\NUL.namirpreset` is the null device). A save under such a name +/// succeeds against the device, writes nothing to disk, and produces a preset that never appears in +/// the recall list — a silent data loss, which is why the name is refused before a path is built +/// rather than after the write appears to succeed. +/// +/// `COM¹`/`COM²`/`COM³` (and the `LPT` equivalents) are included because Windows folds those +/// superscript digits onto `COM1`/`COM2`/`COM3`; they are the one non-ASCII case, and they cost one +/// `matches!` arm rather than an argument about whether anyone would type them. +/// +/// Checked on every platform, not behind `#[cfg(windows)]`: this function was hoisted into +/// `namir-platform` precisely so both shells hold one rule, and a preset a Linux user saves under a +/// name Windows cannot open is FR-STATE-030's interchangeability failing in the direction nobody +/// tests for. +fn names_a_win32_device(name: &str) -> bool { + // Win32 stops at the first '.' and ignores trailing spaces, so `CON.old` and `CON ` are both + // the console. `split('.')` always yields at least one item, so the `unwrap_or` is unreachable + // and present only to keep this total. + let stem = name.split('.').next().unwrap_or(name).trim_end_matches(' '); + if ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"] + .iter() + .any(|device| stem.eq_ignore_ascii_case(device)) + { + return true; + } + let mut chars = stem.chars(); + let (Some(c0), Some(c1), Some(c2), Some(c3), None) = ( + chars.next(), + chars.next(), + chars.next(), + chars.next(), + chars.next(), + ) else { + return false; + }; + let com = c0.eq_ignore_ascii_case(&'C') + && c1.eq_ignore_ascii_case(&'O') + && c2.eq_ignore_ascii_case(&'M'); + let lpt = c0.eq_ignore_ascii_case(&'L') + && c1.eq_ignore_ascii_case(&'P') + && c2.eq_ignore_ascii_case(&'T'); + (com || lpt) && matches!(c3, '0'..='9' | '\u{b9}' | '\u{b2}' | '\u{b3}') +} + /// Every `.namirpreset` directly inside `dir` as a `(name, path)` pair, named by file stem, sorted /// by name. /// @@ -153,6 +222,62 @@ mod tests { assert_eq!(sanitise_name(" Crunch "), Some("Crunch")); } + /// Win32 resolves a reserved device stem before it ever reaches the filesystem, whatever the + /// extension and whatever the directory, so `CON.namirpreset` opens the console rather than + /// creating a file. A save against one of these reports success and leaves nothing behind, and + /// the preset never appears in the recall list. Checked on every platform for the same reason + /// the illegal-character set is: a name Linux accepts and Windows cannot represent breaks + /// FR-STATE-030's interchangeability. + #[test] + fn a_name_windows_resolves_as_a_device_is_refused_on_every_platform() { + for name in [ + "CON", + "con", + "Con", + "NUL", + "nul", + "PRN", + "aux", + "COM1", + "com9", + "COM0", + "LPT1", + "lpt9", + "LPT0", + "CONIN$", + "conout$", + "CON.old", + "com1.backup", + "nul ", + " NUL ", + "CON.namirpreset", + ] { + assert_eq!(sanitise_name(name), None, "{name:?} must be refused"); + assert_eq!(preset_path(Path::new("/presets"), name), None, "{name:?}"); + } + + // Near misses that are ordinary names and must still be accepted -- the rule is the whole + // stem, not a prefix. + for name in [ + "CONTROL", + "COM", + "COM10", + "COMA", + "Console", + "NULL", + "Crunch", + "LPT", + "my CON", + "CON2", + "AUXILIARY", + ] { + assert!( + sanitise_name(name).is_some(), + "{name:?} is not a device name and must be accepted" + ); + } + } + #[test] fn listing_names_by_stem_sorted_and_ignores_everything_else() { let dir = temp_dir("listing"); diff --git a/crates/namir-platform/tests/logging.rs b/crates/namir-platform/tests/logging.rs index 39d00c3..2f1d264 100644 --- a/crates/namir-platform/tests/logging.rs +++ b/crates/namir-platform/tests/logging.rs @@ -17,6 +17,13 @@ //! its own section comment for why a `OnceLock` global resolved from the real environment cannot //! be driven in-process without `unsafe`. //! +//! **Clause eight was added for #145's review finding 11**, and is not one of D-16.5's six: it is +//! the boundedness clause read against the arrangement the decision's own "two processes share one +//! file" limitation describes. It drives two independent writers over one path — the honest +//! in-process stand-in for the standalone application and a DAW running at once — and asserts the +//! two properties that failed before the fix: no generation past the cap by more than the record +//! that crossed it, and no record destroyed by another writer's rotation. +//! //! Apart from clause seven, the logger is driven against a caller-supplied temporary path //! throughout, never the process-global one: the same "pure logic, wired to the real world only at //! the edge" split `paths.rs`'s `config_dir_from` uses. Nothing in *this* process touches the real @@ -783,6 +790,120 @@ fn clause_7_the_per_user_location_through_the_real_init() { ); } +// --------------------------------------------------------------------------------------------- +// Clause 8 (added for #145's review finding 11) -- two writers against one path. +// +// The standalone application and a DAW hosting the plugin write the same `namir.log`, and the +// process-global mutex covers neither of them against the other. The honest reproduction of that +// is two processes; what can be driven in-process is two independent `Logger`s over one path, +// which is the same thing minus the process boundary -- each has its own `SinkState`, and before +// the fix each had its own open handle and its own byte counter, neither of which could see the +// other's. What it +// cannot reproduce is a *rename* racing a *write* at instruction granularity; part (a) drives the +// rename explicitly instead, from the test thread, which is the ordering that actually loses data. +// --------------------------------------------------------------------------------------------- + +fn clause_8_two_writers_against_one_path() { + // (a) A writer must follow the name, not the file it opened. Another process rotating is + // exactly this: `namir.log` is renamed away underneath a writer that is mid-session. A writer + // holding its handle keeps appending into the renamed generation -- so its records land in a + // file that is supposed to be closed history, and the next rotation renames the *live* file + // over the one it was writing into. + let scratch = Scratch::new("follows-the-name"); + let logger = Logger::new(Some(scratch.sink()), LevelChoice::at(LogLevel::Info)); + logger.record(INFO, "before-the-rename"); + let gen1 = with_suffix(&scratch.sink(), ".1"); + fs::rename(scratch.sink(), &gen1).expect( + "a rename over a file this process holds open must succeed -- Rust opens with \ + FILE_SHARE_DELETE on Windows, which D-16.5 inferred and this asserts", + ); + logger.record(INFO, "after-the-rename"); + + let live = read_lines(&scratch.sink()); + let rotated = read_lines(&gen1); + assert!( + live.iter().any(|line| line.contains("after-the-rename")), + "a record written after the file was renamed away must land in the file that now bears \ + the name, not in the renamed generation: live={live:?} rotated={rotated:?}" + ); + assert!( + !rotated.iter().any(|line| line.contains("after-the-rename")), + "nothing may be appended to a generation another writer has already rotated out: \ + {rotated:?}" + ); + + // (b) Two writers, one path: the cap is a property of the file, not of one writer's tally of + // its own bytes. ~8.75 MiB alternating between them is two cap crossings and short of the + // third, so with two retained generations every record must still be somewhere. + let scratch = Scratch::new("two-writers"); + let first = Logger::new(Some(scratch.sink()), LevelChoice::at(LogLevel::Info)); + let second = Logger::new(Some(scratch.sink()), LevelChoice::at(LogLevel::Info)); + let records = 140; + for marker in 0..records { + let writer = if marker % 2 == 0 { &first } else { &second }; + writer.record(INFO, &bulk_detail(marker)); + } + + let mut names: Vec = fs::read_dir(scratch.logs_dir()) + .expect("read log directory") + .map(|entry| { + entry + .expect("directory entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + assert_eq!( + names, + vec![ + "namir.log".to_owned(), + "namir.log.1".to_owned(), + "namir.log.2".to_owned() + ], + "two writers may not produce a fourth file either" + ); + + // No file may be past the cap by more than the one record that crossed it. A per-writer + // counter cannot see the other writer's bytes, so it fires late by exactly that contribution + // -- here, roughly double the cap. + for name in &names { + let path = scratch.logs_dir().join(name); + let len = fs::metadata(&path).expect("metadata").len(); + assert!( + len <= LOG_MAX_BYTES + BULK_DETAIL_BYTES as u64, + "{} is {len} bytes, past the {LOG_MAX_BYTES}-byte cap by more than one record", + path.display() + ); + } + + // ...and nothing written was destroyed on the way: two writers rotating one path must not + // rename a live file over the file they were themselves writing into. + let mut seen: Vec = Vec::new(); + for name in &names { + for line in read_lines(&scratch.logs_dir().join(name)) { + let detail = assert_well_formed(&line); + if let Some(rest) = detail.strip_prefix("marker=") { + seen.push( + rest.split(';') + .next() + .expect("marker field") + .parse() + .expect("marker is numeric"), + ); + } + } + } + seen.sort_unstable(); + let missing: Vec = (0..records).filter(|m| !seen.contains(m)).collect(); + assert!( + missing.is_empty(), + "{} of {records} records are in none of the three generations: {missing:?}", + missing.len() + ); +} + // --------------------------------------------------------------------------------------------- // The covering test. // --------------------------------------------------------------------------------------------- @@ -801,4 +922,5 @@ fn the_diagnostic_log_is_configurable_and_bounded() { clause_5_none_path_is_a_silent_no_op(); clause_6_the_namir_log_parser(); clause_7_the_per_user_location_through_the_real_init(); + clause_8_two_writers_against_one_path(); } diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 5b9ee86..549f047 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -2075,6 +2075,40 @@ The writer must therefore treat a failed rename as an ordinary outcome — keep retry the size check on the next record — never an `unwrap`. The 12 MiB ceiling can consequently be exceeded transiently by a losing process; it cannot be exceeded indefinitely. +*Consequence (added M15, 2026-08-30, from PR #145's review finding 11 — the paragraph above was +optimistic on both of its claims, and both are now measured).* Two things it states are false of the +code it describes. "The writer must therefore treat a failed rename as an ordinary outcome — keep +the current handle, retry the size check on the next record" described a design that was never +built: `rotate()` dropped the handle *before* attempting either rename. And "cannot be exceeded +indefinitely" understated the failure: with each writer's byte counter tracking only its own +appends, neither counter measured the file, so the cap fired late by the other process's whole +contribution, and once one process renamed `namir.log` the other kept appending into the renamed +generation until a later rotation renamed a third generation over a file still being written. +Measured with two writers over one path, 400 records: **85 of 400 records survived**, with holes +mid-history, and the largest generation reached **8,270,888 bytes against a 4 MiB cap**. + +`FileTarget` now holds three paths and no state: each admitted record opens the path, reads the +length of the file it opened, rotates if the line would carry that length past the cap, writes, and +closes; `rotate` re-stats immediately before the renames and abandons the rotation if the file has +shrunk, meaning another writer got there first. The cap becomes a property of the file rather than +of one writer's tally. Same measurement after: **148 of 400 records, contiguous**, no generation +past the cap. Cost is 4.4 µs per record, and none of the workspace's twelve `record` call sites is +in a loop or reachable from the audio thread. + +*Rejected:* per-process files, which contradict this decision's own three-file artifact set and the +12 MiB arithmetic derived from it, and would need a pruning policy nobody has designed; and an +advisory lock (`File::lock`, stable well below this workspace's MSRV), which to be correct must sit +on a file whose name never changes — a fourth file where `clause_3` asserts exactly three — and +would let a diagnostic writer block on another process's stuck rotation. + +*What is still open, stated rather than implied:* the window between the pre-rename stat and the +rename is not closed, so two writers crossing the cap within microseconds rotate twice in a row — +one generation retired early, nothing destroyed. A record whose write is in flight when another +process renames lands in the renamed generation. The ceiling holds to within one oversized +in-flight record per writer. Separately, this decision's "inferred, not measured" note about +`fs::rename` over a file another process holds open is now **measured**: the test asserts it, so +Windows CI exercises it on every run. + *Honest limitation — UTC only.* `std` carries no timezone database, so local time is unavailable without the dependency D-16.4 declined. Timestamps are UTC and labelled `Z`; a mislabelled local time would be worse than a correctly labelled foreign one. From a2e28dbf2c747c383d6fc69afa1bd6aa6f8b13ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:38:46 +0000 Subject: [PATCH 40/44] Show the recalled name, guard an overwrite, probe as the app opens (review findings 10, 13 and the list-devices note) #10: the two Loaded arms did nothing, so a recall swapped the engine over while the label still named the displaced file -- reproduced as left: Some("fender.wav") right: Some("marshall.wav"). The loaded case now reads FR-STATE-070's display_name out of the recalled state in this host's mirror, which the worker installs before it posts StateLoaded, so it is the recalled state that is read. No worker.rs change needed. Failed and NotDelivered still leave the label alone, now with the reason stated: a failed load leaves the previous resource playing, so the previous name is what is audible. #13: guard built rather than the claim deleted, as confirm-on-second-press in the host. A dialog is out (namir-ui cannot open one; NFR-PORT-030 rules out a modal). A plain refusal would be worse than the bug in the other direction -- re-saving under the same name is how you update a preset, so refusing trades data loss for a workflow only completable by deleting files behind Namir's back. And it belongs in the host, not the view, because only this side knows which file a name resolves to: sanitise_name's own doc records the collision a view comparing typed names could never see, and parks it in the shells' save flow. Arming is per name and expires, so reacting to the notice by typing a different name cannot overwrite a third preset with one press, and an arming that never disarms cannot decay into "save always overwrites after the first attempt". The namir-ui doc now states the obligation concretely and says plainly that namir-clap does not yet meet it, so the gap is visible rather than implied. The example probed every device at 2 channels while app::run negotiates from a minimum of 1 for input, so a mono capture endpoint that grants exclusive mode at 1 ch was printed shared-only in the output fr-io-020's script is told to trust. It now calls the app's own negotiate_channels -- agreement by construction, not a restated rule -- and prints the count. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-app/examples/list_devices.rs | 70 +++-- crates/namir-app/src/host.rs | 329 +++++++++++++++++++++- crates/namir-ui/src/host.rs | 15 + 3 files changed, 394 insertions(+), 20 deletions(-) diff --git a/crates/namir-app/examples/list_devices.rs b/crates/namir-app/examples/list_devices.rs index eb6bc7a..c7be62c 100644 --- a/crates/namir-app/examples/list_devices.rs +++ b/crates/namir-app/examples/list_devices.rs @@ -12,9 +12,11 @@ //! ``` //! //! * **Default** — one line per device: the endpoint name, whether it is the host default, and -//! whether it would grant exclusive mode at `` (48000 unless given). This is the -//! form the FR-UI-070 and FR-IO-020 scripts want, because both need a device *name* and one -//! needs a device that **refuses** exclusive mode. +//! whether it would grant exclusive mode at `` (48000 unless given), **at the +//! channel count `namir_app::app::run` would actually open that endpoint with** (see +//! [`app_channel_count`]: 1 for a mono capture endpoint, not the 2 this listing hard-coded until +//! that function existed). This is the form the FR-UI-070 and FR-IO-020 scripts want, because +//! both need a device *name* and one needs a device that **refuses** exclusive mode. //! * **`--verbose`** — additionally prints every configuration the device reports and sweeps //! [`PROBE_RATES_HZ`] for exclusive-mode support at every channel count reported. Run this on //! the reference machine before `docs/manual-tests/fr-io-020-wasapi-exclusive-mode.md`: it @@ -125,34 +127,68 @@ fn report( println!(" {direction} devices ({}):", devices.len()); for device in devices { let default = if device.is_default { " [default]" } else { "" }; - let exclusive = match backend.supports_exclusive(host, device, probe_params(rate, 2)) { + // Enumerated for every device now, not only under `--verbose`: the concise line's probe + // needs a channel count, and the only honest one is the count `namir_app::app::run` would + // negotiate for this endpoint (below). + let configs = match direction { + "input" => backend.input_configs(host, device), + _ => backend.output_configs(host, device), + }; + let configs = match configs { + Ok(configs) => configs, + Err(e) => { + // Exactly what `app::run` does with this failure -- it calls + // `configs_of(..).unwrap_or_default()` -- so the negotiation below falls through to + // the same one-channel answer the app would open with. The error is still + // printed: a device whose formats cannot be read is worth seeing in a manual run. + println!(" configs error: {e}"); + Vec::new() + } + }; + let channels = app_channel_count(direction, &configs, rate); + let exclusive = match backend.supports_exclusive(host, device, probe_params(rate, channels)) + { ExclusiveModeOutcome::Engaged => "exclusive ok", ExclusiveModeOutcome::Unsupported => "shared-only", }; println!( - " \"{}\"{default} -- {exclusive} at {rate} Hz", + " \"{}\"{default} -- {exclusive} at {rate} Hz, {channels} ch", device.name ); if !verbose { continue; } - let configs = match direction { - "input" => backend.input_configs(host, device), - _ => backend.output_configs(host, device), - }; - match configs { - Ok(configs) => { - for c in &configs { - print_config(c); - } - print_exclusive_sweep(backend, host, device, &configs); - } - Err(e) => println!(" configs error: {e}"), + for c in &configs { + print_config(c); } + print_exclusive_sweep(backend, host, device, &configs); } } +/// The channel count [`namir_app::app::run`] would open this endpoint with at `rate` — the whole +/// point of the concise listing's probe, which asked with a hard-coded `2` until this pass. +/// +/// **Why that was wrong and not merely approximate.** `app::run` negotiates each direction's +/// channel count from that device's own reported configurations +/// ([`namir_app::device_state::negotiate_channels`]), asking for the smallest count that meets the +/// engine's minimum: **1** for the capture side, **2** for playback. A mono capture endpoint — an +/// instrument input, which is the device this product is for — is therefore opened at one channel +/// by the application and was probed at two by this example, and a device that grants exclusive +/// mode at one channel and refuses it at two was reported `shared-only`. That output is what +/// `docs/manual-tests/fr-io-020-wasapi-exclusive-mode.md` tells its reader to trust when choosing +/// a device, so the error propagated into a manual run's conclusions. +/// +/// Calls `negotiate_channels` rather than restating its rule, and mirrors `app::run`'s own +/// `.unwrap_or(1)` for the case where no reported configuration covers `rate` — the two must agree +/// by construction, since agreeing is the only property this function has. +fn app_channel_count(direction: &str, configs: &[SupportedConfigRange], rate: u32) -> u16 { + // `app::run`'s two literals: 1 for the input's mono capture read, 2 for the stereo output + // write. Named here rather than inlined so the asymmetry is legible at the call site. + let minimum = if direction == "input" { 1 } else { 2 }; + namir_app::device_state::negotiate_channels(configs, rate, minimum).unwrap_or(1) +} + /// What the probe asks with: the backend's own default buffer size, and `ShareMode::Exclusive` /// (ignored by the probe -- the question *is* whether exclusive mode is possible). fn probe_params(sample_rate_hz: u32, channels: u16) -> StreamParams { diff --git a/crates/namir-app/src/host.rs b/crates/namir-app/src/host.rs index 03ed398..0f24002 100644 --- a/crates/namir-app/src/host.rs +++ b/crates/namir-app/src/host.rs @@ -27,7 +27,7 @@ //! worker thread (`LoadLibraryEntry`/`RescanLibraryRequested`/`CancelScanRequested`) — see //! [`crate::worker`]'s module doc comment for why load/scan don't also go through the direct path. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -117,6 +117,20 @@ pub(crate) mod local_error_codes { "Choose a name without a slash, backslash, colon, asterisk, question mark, quote, angle \ bracket or vertical bar. Nothing was written, and your settings are unchanged.", ); + + /// A save was asked for under a name that already names a preset file. **`Warning`, and a + /// refusal, not a failure:** nothing has been written, the preset on disk is untouched, and + /// the same name pressed again goes through — see + /// [`super::AppHost::needs_overwrite_confirmation`] for why the confirmation is a second press + /// rather than a dialog. + pub const PRESET_EXISTS: ErrorCode = ErrorCode::new( + "app.host.preset_exists", + Severity::Warning, + "A preset named {detail} already exists, so nothing was saved.", + "Press Save again to replace it, or type a different name. The preset on disk is still \ + whatever it was, and your current settings are unchanged either way.", + ); + pub const PRESET_LOCATION_UNKNOWN: ErrorCode = ErrorCode::new( "app.host.preset_location_unknown", Severity::Warning, @@ -207,6 +221,16 @@ const STREAM_FAILURE_DRAIN_BATCH: usize = 8; /// `SharedInner::presets_snapshot` uses, for the same reason. const PRESET_LISTING_MAX_AGE: Duration = Duration::from_secs(1); +/// How long a "that preset already exists" refusal stays armed for the second, confirming press +/// (see [`AppHost::needs_overwrite_confirmation`]). +/// +/// Bounded rather than held for the session because the arming is a *loaded* destructive action: +/// a user who reads the notice, leaves the name in the box and comes back an hour later is a user +/// who should be warned again, not one whose next press silently replaces a preset. Thirty seconds +/// is long enough to read the notice and press Save again deliberately, and short enough that the +/// confirmation belongs to that gesture rather than to the session. +const OVERWRITE_CONFIRM_WINDOW: Duration = Duration::from_secs(30); + /// The UI-thread end of [`crate::stream`]'s two error callbacks: one bounded ring per direction, /// plus the device names a notice has to name (issue #44). /// @@ -317,6 +341,10 @@ pub struct AppHost { /// not both queue one. presets: Vec, presets_listed_at: Option, + /// The preset name whose overwrite was refused, and when — the armed half of + /// [`AppHost::needs_overwrite_confirmation`]'s confirm-on-second-press guard. `None` whenever + /// no refusal is outstanding, which is every save that names a file that does not exist yet. + pending_overwrite: Option<(String, Instant)>, /// FR-IO-070's stream-failure reports, when this host is driving a real duplex path. `None` /// on `crate::app`'s `open_window_without_audio` path, where there is no stream to fail. stream_failures: Option, @@ -361,6 +389,7 @@ impl AppHost { preset_dir: None, presets: Vec::new(), presets_listed_at: None, + pending_overwrite: None, stream_failures: None, streams: None, thread_priority: None, @@ -420,6 +449,62 @@ impl AppHost { self.worker.send(AppCommand::ListPresets(dir)); } + /// Whether writing `path` now would replace an existing preset **that the user has not asked + /// to replace**, arming (or consuming) the confirmation as it decides. + /// + /// # Why a second press, and not a dialog or a refusal + /// + /// [`namir_ui::UiIntent::SavePreset`] documents an overwriting name as the host's to reject and + /// to report, and until this guard existed no host did: `Save` over the name of an existing + /// preset destroyed it with no confirmation, no notice and no undo, and the user's only + /// feedback was the same success path as a fresh save. The three ways out of that, and why + /// this is the one taken: + /// + /// - **A confirmation dialog** is unavailable. `namir-ui` cannot open one (D-5.1 puts + /// `namir-platform` out of its reach), and NFR-PORT-030's "no blocking dialog on the path of + /// any audio-affecting operation" rules a modal out of the row that also carries the recall + /// control, which *is* audio-affecting. + /// - **A plain refusal** would make overwriting impossible from inside the application, and + /// re-saving a preset under the name it already has is the ordinary way to update one — the + /// guard would then have traded a data-loss path for a workflow the user can only complete + /// by deleting files behind Namir's back. + /// - **A second, deliberate press** costs a fresh save nothing, needs no new intent and no view + /// state (so it holds for the plugin's copy of the same screen too), and is keyboard-operable + /// — Enter twice in the name box, FR-UI-030. + /// + /// # Why here rather than in the view + /// + /// Only this side knows which *file* a name resolves to. `namir_platform::presets::preset_path` + /// sanitises the name first, and `sanitise_name`'s own doc comment records the collision a view + /// comparing typed names against [`namir_ui::UiSnapshot::presets`] could never see: `Crunch` + /// and `crunch` are two files on Linux and one on Windows and on a default-configured macOS. + /// `Path::exists` is the filesystem's own answer to that question, so it is asked here. + /// + /// **The one `stat` this host does on the UI thread**, and deliberately: it is on a Save + /// gesture, not on a frame ([`UiHost::snapshot`]'s no-I/O contract is untouched — see + /// [`PRESET_LISTING_MAX_AGE`] for the listing, which is still the worker's job), and the cached + /// listing it would otherwise consult cannot answer the case-folding question and can be up to + /// [`PRESET_LISTING_MAX_AGE`] stale over a directory two products and a second copy of this one + /// write into. + fn needs_overwrite_confirmation(&mut self, path: &Path, name: &str) -> bool { + if !path.exists() { + // Nothing to lose, so nothing to confirm -- and any arming for another name goes with + // it, since the gesture that armed it has been abandoned. + self.pending_overwrite = None; + return false; + } + let confirmed = self + .pending_overwrite + .as_ref() + .is_some_and(|(armed, at)| armed == name && at.elapsed() < OVERWRITE_CONFIRM_WINDOW); + if confirmed { + self.pending_overwrite = None; + return false; + } + self.pending_overwrite = Some((name.to_string(), Instant::now())); + true + } + /// Wires D-13.2's thread-elevation outcome in (issue #76). Called by [`crate::app::run`] once, /// with [`crate::stream::RunningStreams::thread_priority`]'s report; the outcome does not exist /// yet at that point, because it is produced by the output callback's *first* invocation, so @@ -640,14 +725,39 @@ impl AppHost { } } + /// Folds one recall's outcome into the two name labels `namir-ui` renders as Model and + /// Impulse Response, and into FR-STATE-070's missing-reference notices. + /// + /// **Where a recalled name comes from.** [`crate::worker::RecallOutcomeSummary`] carries a + /// display name only for the *missing* case, so the loaded case reads FR-STATE-070's + /// [`namir_state::FileRef::display_name`] out of the recalled `State` itself — the same field + /// the missing case reports, and the one the preset actually stores. `crate::worker` installs + /// that state in this host's shared mirror *before* it posts `AppEvent::StateLoaded`, so it is + /// the recalled state that is read here, not the displaced one. Both names are taken under one + /// guard, for the reason [`AppHost::snapshot`] gives: two labels out of one state, never a + /// half-applied pair. + /// + /// `Failed`/`NotDelivered` deliberately leave the label alone: a load that failed or missed + /// the handover deadline left the previous resource playing, so the previous name is still + /// what is audible. fn apply_recall_summary(&mut self, outcome: crate::worker::RecallOutcomeSummary) { + let (nam_name, ir_name) = { + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + ( + state.nam.as_ref().map(|r| r.display_name.clone()), + state.ir.as_ref().map(|r| r.display_name.clone()), + ) + }; match outcome.nam { - LoadOutcomeSummary::Loaded { .. } => {} + // A `Loaded` outcome always came from a reference, so this is `Some`; assigned rather + // than guarded so that if it ever is not, the label empties instead of keeping a name + // that names nothing. + LoadOutcomeSummary::Loaded { .. } => self.loaded_model_name = nam_name, LoadOutcomeSummary::Unloaded => self.loaded_model_name = None, _ => {} } match outcome.ir { - LoadOutcomeSummary::Loaded { .. } => {} + LoadOutcomeSummary::Loaded { .. } => self.loaded_ir_name = ir_name, LoadOutcomeSummary::Unloaded => self.loaded_ir_name = None, _ => {} } @@ -788,6 +898,19 @@ impl UiHost for AppHost { self.push_notice(local_error_codes::PRESET_NAME_REFUSED, name); return; }; + // A preset the user is about to lose is worth one refusal first -- see + // [`Self::needs_overwrite_confirmation`] for the whole argument, including why + // the confirmation is a second press of the same button rather than a dialog. + if self.needs_overwrite_confirmation(&path, &name) { + self.push_notice(local_error_codes::PRESET_EXISTS, name); + return; + } + // The refusal's own notice has been answered, so it stops being displayed -- + // matched on the name it carries, so a warning outstanding about *another* preset + // survives this save. + self.notices.retain(|n| { + n.code.id != local_error_codes::PRESET_EXISTS.id || n.detail != name + }); self.worker.send(AppCommand::SaveState(path)); // The list the user is about to look at must contain what they just saved. self.presets_listed_at = None; @@ -1721,6 +1844,206 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// **A successful recall must put the recalled file's name on screen.** Until this pass + /// `apply_recall_summary`'s two `Loaded` arms did nothing at all: only an `Unloaded` recall + /// touched `loaded_model_name`/`loaded_ir_name`, so recalling a preset swapped the engine over + /// while the window went on showing whatever had been loaded *before* it — the Model and + /// Impulse Response labels disagreeing with what was audible until the next manual library + /// load. The wait is on the parameter value rather than on the name, so a regression here + /// fails on the assertion instead of timing out on the poll. + #[test] + fn recalling_a_preset_puts_the_recalled_resource_name_on_screen() { + let dir = temp_dir("preset_recall_names"); + let (mut host, _engine) = build_host(&dir); + host.watch_presets(crate::presets::preset_dir_under(&dir)); + let key = namir_params::stages::trim::GAIN_DB.key; + + // Two *different* IRs: FR-STATE-070 resolves by content hash, so two files with identical + // bytes would be interchangeable and the test could pass by accident. + let first = dir.join("marshall.wav"); + std::fs::write( + &first, + namir_fixtures::ir::to_mono_wav_bytes(&namir_fixtures::ir::delta(64), 48_000), + ) + .unwrap(); + let second = dir.join("fender.wav"); + std::fs::write( + &second, + namir_fixtures::ir::to_mono_wav_bytes( + &namir_fixtures::ir::delayed_delta(64, 3), + 48_000, + ), + ) + .unwrap(); + + host.dispatch(UiIntent::SetParam { key, value: -18.0 }); + host.dispatch(UiIntent::LoadLibraryEntry(first)); + let snapshot = snapshot_until(&mut host, |s| s.loaded_ir_name.is_some()); + assert_eq!( + snapshot.loaded_ir_name.as_deref(), + Some("marshall.wav"), + "{:?}", + snapshot.notices + ); + host.dispatch(UiIntent::SavePreset { + name: "Marshall".to_string(), + }); + let snapshot = snapshot_until(&mut host, |s| !s.presets.is_empty()); + let path = snapshot.presets[0].path.clone(); + + host.dispatch(UiIntent::SetParam { key, value: -3.0 }); + host.dispatch(UiIntent::LoadLibraryEntry(second)); + let snapshot = snapshot_until(&mut host, |s| { + s.loaded_ir_name.as_deref() == Some("fender.wav") + }); + assert_eq!(snapshot.loaded_ir_name.as_deref(), Some("fender.wav")); + + host.dispatch(UiIntent::RecallPreset { path }); + let snapshot = snapshot_until(&mut host, |s| s.params.get(key) == Some(-18.0)); + assert_eq!( + snapshot.params.get(key), + Some(-18.0), + "the recall itself must land first: {:?}", + snapshot.notices + ); + assert_eq!( + snapshot.loaded_ir_name.as_deref(), + Some("marshall.wav"), + "the recalled IR's name must replace the one it displaced: {:?}", + snapshot.notices + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **A save that would replace an existing preset is refused once, and only a second, + /// deliberate press writes it.** [`UiIntent::SavePreset`]'s own doc comment has always said an + /// overwriting name "is the host's to reject (and to report through a `UiNotice`)"; until this + /// pass no host did, so typing the name of a preset that already existed destroyed it with no + /// confirmation, no notice and no undo — the same success path as a fresh save. + /// + /// The confirmation is a second press rather than a dialog: `namir-ui` cannot open one and + /// NFR-PORT-030 forbids one on this path in the plugin, and it is `AppHost` rather than the + /// view that decides, because only this side knows which *file* a name resolves to (see + /// [`AppHost::needs_overwrite_confirmation`]). + #[test] + fn saving_over_an_existing_preset_is_refused_until_a_second_press_confirms_it() { + let dir = temp_dir("preset_overwrite"); + let (mut host, _engine) = build_host(&dir); + host.watch_presets(crate::presets::preset_dir_under(&dir)); + let key = namir_params::stages::trim::GAIN_DB.key; + let saved_gain = |path: &std::path::Path| -> Option { + let bytes = std::fs::read(path).ok()?; + let (state, _warnings) = namir_state::State::read(&bytes).ok()?; + state.params.get(key) + }; + + host.dispatch(UiIntent::SetParam { key, value: -18.0 }); + host.dispatch(UiIntent::SavePreset { + name: "Rock".to_string(), + }); + let snapshot = snapshot_until(&mut host, |s| !s.presets.is_empty()); + let path = snapshot.presets[0].path.clone(); + assert_eq!(saved_gain(&path), Some(-18.0), "{:?}", snapshot.notices); + assert!(snapshot.notices.is_empty(), "{:?}", snapshot.notices); + + // The same name again, over a different value: refused, reported, and nothing written. + host.dispatch(UiIntent::SetParam { key, value: -3.0 }); + host.dispatch(UiIntent::SavePreset { + name: "Rock".to_string(), + }); + let snapshot = host.snapshot(); + assert_eq!(snapshot.notices.len(), 1, "{:?}", snapshot.notices); + assert_eq!( + snapshot.notices[0].code.id, + local_error_codes::PRESET_EXISTS.id + ); + assert!( + snapshot.notices[0].detail.contains("Rock"), + "the notice must name the preset it is about to replace: {:?}", + snapshot.notices[0] + ); + assert_eq!( + saved_gain(&path), + Some(-18.0), + "the refused save must not have touched the file" + ); + + // The second press is the confirmation, and it writes. + host.dispatch(UiIntent::SavePreset { + name: "Rock".to_string(), + }); + let mut written = None; + for _ in 0..400 { + written = saved_gain(&path); + if written == Some(-3.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!( + written, + Some(-3.0), + "a confirmed overwrite must actually replace the preset" + ); + // ... and the stale "already exists" notice goes with it. + let snapshot = snapshot_until(&mut host, |s| s.notices.is_empty()); + assert!(snapshot.notices.is_empty(), "{:?}", snapshot.notices); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The confirmation is armed for **one** name: typing a different one after a refusal starts + /// over rather than carrying the arming across, so a user who reacts to the notice by choosing + /// another name cannot then overwrite a *third* preset with one press. + #[test] + fn an_overwrite_confirmation_does_not_carry_across_to_another_name() { + let dir = temp_dir("preset_overwrite_other_name"); + let (mut host, _engine) = build_host(&dir); + host.watch_presets(crate::presets::preset_dir_under(&dir)); + + for name in ["Rock", "Blues"] { + host.dispatch(UiIntent::SavePreset { + name: name.to_string(), + }); + let count = if name == "Rock" { 1 } else { 2 }; + snapshot_until(&mut host, |s| s.presets.len() == count); + } + + host.dispatch(UiIntent::SavePreset { + name: "Rock".to_string(), + }); + host.dispatch(UiIntent::SavePreset { + name: "Blues".to_string(), + }); + let snapshot = host.snapshot(); + assert_eq!( + snapshot + .notices + .iter() + .filter(|n| n.code.id == local_error_codes::PRESET_EXISTS.id) + .count(), + 2, + "each name is refused on its own first press: {:?}", + snapshot.notices + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A name that does not exist yet is written on the first press -- the guard costs an ordinary + /// save nothing. + #[test] + fn a_preset_name_that_is_free_is_saved_on_the_first_press() { + let dir = temp_dir("preset_free_name"); + let (mut host, _engine) = build_host(&dir); + host.watch_presets(crate::presets::preset_dir_under(&dir)); + host.dispatch(UiIntent::SavePreset { + name: "Fresh".to_string(), + }); + let snapshot = snapshot_until(&mut host, |s| !s.presets.is_empty()); + assert_eq!(snapshot.presets.len(), 1); + assert!(snapshot.notices.is_empty(), "{:?}", snapshot.notices); + let _ = std::fs::remove_dir_all(&dir); + } + /// FR-UI-070: dismissing a notice removes exactly that one. #[test] fn dismiss_notice_removes_only_the_named_notice() { diff --git a/crates/namir-ui/src/host.rs b/crates/namir-ui/src/host.rs index 0bc94e3..784a39f 100644 --- a/crates/namir-ui/src/host.rs +++ b/crates/namir-ui/src/host.rs @@ -244,6 +244,21 @@ pub enum UiIntent { /// name that is illegal as a filename, or one that would overwrite an existing preset, is the /// host's to reject (and to report through a [`UiNotice`]), since only the host knows the /// filesystem it is about to write to. + /// + /// **What "reject an overwrite" has to mean here, since no dialog is available.** This crate + /// cannot open one -- D-5.1 puts `namir-platform` out of its reach -- and NFR-PORT-030's "no + /// blocking dialog on the path of any audio-affecting operation" rules a modal out of the row + /// that also carries the recall control. So a host cannot ask "replace it?" and wait, and this + /// intent deliberately carries no `overwrite` flag for it to answer with. The obligation is + /// instead: the **first** `SavePreset` naming a preset that already exists writes nothing and + /// reports a [`UiNotice`]; the **same name dispatched again** is the confirmation, and writes. + /// That keeps a destructive save deliberate with no modal, no second intent and no view state, + /// which is also what lets one view serve both products. + /// + /// The obligation was documented long before either host met it: until it was built in + /// `namir-app`'s `AppHost`, typing the name of an existing preset destroyed it with no + /// confirmation, no notice and no undo. `namir-clap`'s `ClapUiHost` still saves on the first + /// press -- a real gap against this paragraph, stated here rather than left to be rediscovered. SavePreset { /// The preset's name, as typed. name: String, From ba436e25ad35c1142827fa5b71c3a3a1b15107dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 18:39:10 +0000 Subject: [PATCH 41/44] Narrow the producer hold, and bound the lock wait (review submit note) The note is right about submit.rs's contract: submit_with_deadline held the producer across its entire spin-then-sleep loop, so while a worker was inside it every try_submit was refused regardless of the ring's state. Reproduced 5/5 -- eight-slot ring, four slots freed by a live consumer, and the knob turn still came back Err(Timeout) purely because another thread held the mutex. The hold is now per attempt: the mutex is taken and dropped around one is_abandoned and one try_push, never across a sleep. try_submit then retries try_lock for a bounded number of spins before giving the command back -- an iteration count, not a duration, so the wait is bounded by nothing another thread can extend, which is the property lock() cannot offer and the one FR-UI-060 needs. Order matters: the spin is defensible only because the hold was narrowed first. A correction to this PR's own claim about #106, which the reviewer's note led to. The user-visible symptom is NOT fixed by this branch -- it was fixed one mutex too deep. Instance owns its submitter by value and every method takes &mut self, so both shells reach it only through an outer Mutex; two threads are never inside CommandSubmitter at once and try_lock never sees WouldBlock in production. The live D-15.3/FR-UI-060 violation is the GUI thread blocking on that outer mutex while a worker holds it across a whole Instance::load. That is in four files this change does not own, and fixing it is what would make producer contention reachable for the first time -- so this hardening is a prerequisite for that fix, not an alternative to it. #106's own test passes unchanged, but the narrowing removes its teeth, checked rather than assumed: with try_submit reverted to a blocking lock() it still goes green, because a lock() now acquires in nanoseconds. A new test pins the contract against the only thing that can falsify it -- something holding the producer for a long time -- with the test itself as the holder, releasing on a timer so a regression fails rather than hangs. What is given up: strict first-come ordering between two concurrent blocking submitters on a full ring. No caller can reach that case, and the commands carry no ordering relation. D-7.2's requirement is serialised access, which is intact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-worker/src/submit.rs | 233 +++++++++++++++++++++++++++--- 1 file changed, 210 insertions(+), 23 deletions(-) diff --git a/crates/namir-worker/src/submit.rs b/crates/namir-worker/src/submit.rs index 44b6792..80fc093 100644 --- a/crates/namir-worker/src/submit.rs +++ b/crates/namir-worker/src/submit.rs @@ -26,6 +26,26 @@ //! audio callback drains once per block, which is 1.333 ms at NFR-PERF-010's own condition, so //! sub-block granularity is what a retry needs, and sleeping rather than spinning avoids burning a //! core the audio thread may want. +//! +//! # How long the mutex is held (revised after issue #106's fix) +//! +//! **The mutex is held for one push, never across a wait.** [`CommandSubmitter::submit`] used to +//! take it once and keep it for its whole deadline, sleeping under it; the retry loop now takes it +//! per attempt and drops it before each sleep. The difference is not a micro-optimisation, it is +//! what makes [`CommandSubmitter::try_submit`]'s non-blocking answer honest: with the old hold, +//! "the producer is busy" meant "a worker is somewhere inside a two-second deadline", which is a +//! *state*, and refusing a command for the duration of a state silently drops every parameter +//! change made during it. With the hold narrowed to a single `try_push`, "the producer is busy" +//! means "another thread is between two instructions", which is the momentary miss `try_submit`'s +//! contract was always written against. +//! +//! What is given up is the strict first-come ordering the old convoy gave two *concurrent* +//! blocking submitters on a full ring: whichever thread wins the lock when room appears goes +//! first. No caller depends on that — an `Instance` owns its submitter and is reached through +//! `&mut self`, so both shells already serialise every submitter access through their own +//! `Mutex` — and the commands in question (a parameter change, a resource offer) carry +//! no ordering relation to each other. Serialised *access*, which is what D-7.2 asks for, is +//! unchanged: every push still happens under the mutex. use std::sync::{Mutex, MutexGuard, PoisonError, TryLockError}; use std::time::{Duration, Instant}; @@ -39,6 +59,14 @@ const SPIN_ATTEMPTS: u32 = 64; /// Sub-block granularity against a 1.333 ms block period — see this module's doc comment. const RETRY_BACKOFF: Duration = Duration::from_micros(500); +/// How many times [`CommandSubmitter::try_submit`] re-tries the *mutex* before giving the command +/// back. Deliberately an iteration count and not a duration: the wait it bounds is then bounded by +/// this many `pause` instructions **whatever any other thread does**, which is the property the GUI +/// thread needs and a `lock()` cannot offer. It is sized against the longest hold that now exists — +/// one `is_abandoned` plus one `try_push`, tens of nanoseconds — with enough headroom to ride out a +/// worker in its own `SPIN_ATTEMPTS` phase, which re-takes the mutex rapidly for a few microseconds. +const LOCK_SPIN_ATTEMPTS: u32 = 256; + /// How long a blocking submit keeps trying before giving the command back. pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(2); @@ -98,14 +126,37 @@ impl CommandSubmitter { /// frame) as a ring that happened to be full. Nothing is dropped, and the promise the callers /// in `namir-clap/src/ui_host.rs` and `namir-app/src/host.rs` cite is one this method keeps /// against a contended producer as well as a full ring. + /// + /// # Why one `try_lock` was not enough (the #106 fix's own regression) + /// + /// "Not now, try again" is only a fair answer if the callers *do* try again, and they do not: + /// both cite this method precisely so they can discard the result with `let _ =`, having + /// already written the new value into their own snapshot state. A refusal is therefore a + /// silently lost gesture — the knob moves on screen and the audio does not follow. That is + /// tolerable for the miss this method was designed around (a ring the audio thread has not + /// drained *this* block, which the next gesture or the next block resolves) and not tolerable + /// for a mutex another thread holds across a two-second deadline, which is what a single + /// `try_lock` was actually reporting before [`Self::submit_with_deadline`] stopped holding it + /// that way. + /// + /// Both halves of that are fixed, and the order matters. The hold is narrowed first (see this + /// module's doc comment), so the longest contention that can exist is one `try_push`; this + /// method then re-tries the mutex [`LOCK_SPIN_ATTEMPTS`] times against that, which is bounded + /// by a fixed number of `pause` instructions and by nothing another thread can extend. A + /// [`SubmitError::Timeout`] from here once again means what the callers assume it means: the + /// ring itself had no room. pub fn try_submit(&self, command: Command) -> Result<(), SubmitError> { - let Some(mut producer) = self.try_lock() else { - return Err(SubmitError::Timeout(command)); - }; - if producer.is_abandoned() { - return Err(SubmitError::Abandoned(command)); + for _ in 0..LOCK_SPIN_ATTEMPTS { + let Some(mut producer) = self.try_lock() else { + std::hint::spin_loop(); + continue; + }; + if producer.is_abandoned() { + return Err(SubmitError::Abandoned(command)); + } + return producer.try_push(command).map_err(SubmitError::Timeout); } - producer.try_push(command).map_err(SubmitError::Timeout) + Err(SubmitError::Timeout(command)) } /// Blocks until the audio thread makes room, or the default deadline expires. **Worker threads @@ -116,12 +167,18 @@ impl CommandSubmitter { /// As [`Self::submit`], with an explicit deadline. /// - /// The mutex is held across the wait, which is what makes the producer side single at any - /// instant. Two worker threads submitting to a full ring therefore form a bounded convoy: one - /// sleeps against the ring, the other against the mutex. That is acceptable because submitters - /// are per-instance (unrelated instances never contend), the wait is deadline-bounded, and it - /// sleeps rather than spins. [`Self::try_submit`] is deliberately **not** part of that convoy - /// — see its own doc comment for why the caller it serves may not join one. + /// **The mutex is taken per attempt and released before every sleep** — it is *not* held + /// across the wait. It was, until the #106 fix's own regression made the cost of that plain: + /// see this module's "How long the mutex is held" section for the argument, and + /// [`Self::try_submit`] for the caller it was costing. What the mutex still guarantees is the + /// one thing D-7.2 asks of it — that no two threads touch the producer at once — because every + /// push happens under it. + /// + /// Two worker threads submitting to a full ring therefore interleave rather than convoy, and + /// whichever wins the mutex when room appears goes first. Submitters are per-instance + /// (unrelated instances never contend) and an `Instance` is reached through `&mut self`, so in + /// both shells that case does not arise at all; where it could, the two commands carry no + /// ordering relation. /// /// **The one hard rule for callers: never hold the resource cache's lock across this call.** /// A full ring on one instance would otherwise stall every other instance's cache lookup, which @@ -133,27 +190,22 @@ impl CommandSubmitter { deadline: Duration, ) -> Result<(), SubmitError> { let started = Instant::now(); - let mut producer = self.lock(); let mut command = command; for _ in 0..SPIN_ATTEMPTS { - if producer.is_abandoned() { - return Err(SubmitError::Abandoned(command)); - } - match producer.try_push(command) { + match self.attempt(command) { Ok(()) => return Ok(()), - Err(back) => command = back, + Err(SubmitError::Abandoned(back)) => return Err(SubmitError::Abandoned(back)), + Err(SubmitError::Timeout(back)) => command = back, } std::hint::spin_loop(); } loop { - if producer.is_abandoned() { - return Err(SubmitError::Abandoned(command)); - } - match producer.try_push(command) { + match self.attempt(command) { Ok(()) => return Ok(()), - Err(back) => command = back, + Err(SubmitError::Abandoned(back)) => return Err(SubmitError::Abandoned(back)), + Err(SubmitError::Timeout(back)) => command = back, } if started.elapsed() >= deadline { return Err(SubmitError::Timeout(command)); @@ -162,6 +214,17 @@ impl CommandSubmitter { } } + /// One locked attempt, guard taken and dropped inside. The whole retry policy lives in the + /// callers precisely so that no wait — no sleep, no spin, no deadline test — happens under the + /// mutex. + fn attempt(&self, command: Command) -> Result<(), SubmitError> { + let mut producer = self.lock(); + if producer.is_abandoned() { + return Err(SubmitError::Abandoned(command)); + } + producer.try_push(command).map_err(SubmitError::Timeout) + } + /// Recovers from poisoning rather than propagating it, for the same P8 reason /// `cache::lock` documents: a submitter that failed forever after one unrelated panic would be /// a total failure, not degradation. The producer's own invariants are `rtrb`'s, and a panic @@ -297,6 +360,130 @@ mod tests { let _ = worker.join().unwrap(); } + /// **The regression `try_submit`'s #106 fix introduced.** A `try_lock` that gives up on first + /// contention is only harmless if contention is brief, and before this test's fix it was not: + /// [`CommandSubmitter::submit_with_deadline`] held the producer mutex across its *entire* + /// sleep loop, so for as long as a worker was inside it — up to [`DEFAULT_DEADLINE`] — every + /// `try_submit` returned `Timeout`, whatever the ring's actual state. The production callers + /// (`namir-clap/src/ui_host.rs`, `namir-app/src/host.rs`) discard that with `let _ =` after + /// having already written the new value into their own snapshot state, so the user sees the + /// knob move and hears nothing change. + /// + /// The ring here has **four free slots** at the moment of the attempt and a live consumer, so + /// nothing about the ring justifies refusing the command: the only thing in the way is the + /// other thread's mutex, and that is not a reason to drop a user's gesture. + #[test] + fn a_change_the_ring_had_room_for_lands_even_while_a_worker_is_mid_deadline() { + let (tx, mut rx) = ring::(8); + let submitter = std::sync::Arc::new(CommandSubmitter::new(tx)); + for i in 0..8 { + submitter + .try_submit(param(i)) + .expect("the ring starts empty"); + } + + let entered = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker = { + let submitter = std::sync::Arc::clone(&submitter); + let entered = std::sync::Arc::clone(&entered); + std::thread::spawn(move || { + entered.store(true, std::sync::atomic::Ordering::Release); + submitter.submit_with_deadline(param(100), DEFAULT_DEADLINE) + }) + }; + while !entered.load(std::sync::atomic::Ordering::Acquire) { + std::thread::yield_now(); + } + // Long enough for the worker to be past its spin phase and settled into the sleep loop, + // which is where the pre-fix version parked holding the mutex. + std::thread::sleep(Duration::from_millis(50)); + + // Room appears. The worker will claim one slot at its next wake (<= RETRY_BACKOFF), which + // still leaves three, so this is not a contest for the last slot. + for _ in 0..4 { + rx.try_pop().expect("eight were queued"); + } + + let started = Instant::now(); + let result = submitter.try_submit(param(42)); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_millis(250), + "try_submit waited {elapsed:?} -- #106's property must survive this fix" + ); + assert!( + result.is_ok(), + "a knob turn was refused while four slots were free: {result:?}. Mere producer-mutex \ + contention is not a full ring, and every production caller discards this error" + ); + + worker + .join() + .unwrap() + .expect("the worker's own command had room too"); + let mut ids = Vec::new(); + while let Some(command) = rx.try_pop() { + if let Command::Param(change) = command { + ids.push(change.id.0); + } + } + assert!( + ids.contains(&42), + "`try_submit` said Ok, so the change must be queued for the audio thread; the ring \ + held {ids:?}" + ); + } + + /// **What is left of issue #106 once the hold is narrowed.** The test above asserts the right + /// property but, since `submit_with_deadline` stopped holding the mutex across its deadline, + /// no longer *discriminates*: with the longest hold reduced to one `try_push`, a `try_submit` + /// written back as a blocking `lock()` acquires in nanoseconds, finds the same full ring, and + /// returns the same `Timeout` well inside that test's 250 ms bound. Checked rather than + /// assumed — reverting `try_submit` to `lock()` leaves the whole module's suite green. + /// + /// So the contract is pinned here instead, against the only thing that can ever make it false: + /// *something* holding the producer for a long time. The holder is this test, so the guarantee + /// no longer depends on any other method's retry policy — which is what let the property quietly + /// lose its guard in the first place. + #[test] + fn try_submit_does_not_wait_for_whoever_holds_the_producer() { + let (tx, _rx) = ring::(4); + let submitter = std::sync::Arc::new(CommandSubmitter::new(tx)); + + // The hold is released on a timer rather than on a flag this thread sets *after* the call + // below: a regression must fail this test, not hang it, and a `lock()` here would never + // reach a flag it is itself blocking. + let held = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let holder = { + let submitter = std::sync::Arc::clone(&submitter); + let held = std::sync::Arc::clone(&held); + std::thread::spawn(move || { + let _guard = submitter.lock(); + held.store(true, std::sync::atomic::Ordering::Release); + std::thread::sleep(Duration::from_millis(400)); + }) + }; + while !held.load(std::sync::atomic::Ordering::Acquire) { + std::thread::yield_now(); + } + + let started = Instant::now(); + let result = submitter.try_submit(param(1)); + let elapsed = started.elapsed(); + holder.join().unwrap(); + + assert!( + elapsed < Duration::from_millis(250), + "try_submit waited {elapsed:?} for a held producer -- it is documented never to \ + block, and the UI thread calls it" + ); + assert!( + matches!(result, Err(SubmitError::Timeout(_))), + "a contended producer must hand the command back rather than dropping it" + ); + } + /// If the audio side is gone entirely, say so distinctly rather than waiting out the deadline. #[test] fn an_abandoned_ring_is_reported_immediately() { From b8bcc21f973c5fed65a01de3db0efe5140d3020e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 19:04:26 +0000 Subject: [PATCH 42/44] Hold a resampled handover until its incoming slot has primed (review findings 5, 6, 7) - chain: delete `capture_dry`'s early return outright. Dropping only the `delay == 0` conjunct leaves `if !capture { return; }`, which is #59's original bug. A latency rise with bypass engaging in the same block read 0.20556 at frame 639 (-7.7 dB) before this. - chain: refuse a non-finite `ParamChange::value` at `Chain::apply`, the one funnel both `apply_param_direct` and the ring's `Command::Param` route through. A NaN ceiling panicked in `f32::clamp` on the audio thread; a NaN *reaching* a stage was the worse half, silencing every subsequent block for the session with no biquad able to recover once its `z` history was NaN. Refused, not clamped (D-16.3) -- there is no sensible clamp for "NaN dB". - nam: `Crossfade` gains `prime`, set to the incoming slot's own `latency_samples()`. While nonzero the fade holds at theta == 0 with both slots running, which is what primes the incoming one. Without it the first fade ran against the resampler's priming silence: worst 64-frame window vs settled went -4.89 dB before, +0.00 dB after. The third is the macOS CI failure on this branch, reproduced bit-exact -- forcing a second install into the measurement window gives RMS 0.12507376 against CI's 0.1250737. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-engine/src/chain.rs | 240 +++++++++++++++++++++++- crates/namir-engine/src/chain_probes.rs | 184 ++++++++++++++++++ crates/namir-engine/src/stages/nam.rs | 48 ++++- 3 files changed, 463 insertions(+), 9 deletions(-) diff --git a/crates/namir-engine/src/chain.rs b/crates/namir-engine/src/chain.rs index c1839ba..a7357bd 100644 --- a/crates/namir-engine/src/chain.rs +++ b/crates/namir-engine/src/chain.rs @@ -230,19 +230,25 @@ impl CrossCuttingState { /// the first time), so engaging bypass emitted `delay` samples of stale content followed by a /// hard discontinuity, and disengaging dropped the same number of samples — a click at both /// ends of every transition, which is exactly what FR-CLAP-060 forbids. Feeding it always - /// costs one pass over the block on the non-bypassed path (nothing at all when the chain - /// reports zero latency, which is the whole of 1.0 with no resampled model loaded) and makes - /// the transition sample-accurate in both directions. + /// costs one pass over the block on the non-bypassed path and makes the transition + /// sample-accurate in both directions. + /// + /// **Including the blocks on which the chain reports zero latency (issue #145 finding 5).** + /// This method used to return early on `delay == 0 && !capture`, on the reasoning that a line + /// nothing reads and whose delay is zero can only hand back what it is given. That is true of + /// *this* block and false of the next one: zero latency is the whole of 1.0 until a + /// rate-mismatched model is installed, so the early return meant the write index had not + /// moved all session, and the moment `NamStage` raised its declared latency 0 -> 640 + /// (FR-CLAP-040) the read index addressed 640 samples of buffer nothing had ever written. + /// Engaging bypass inside that 13 ms window fed the crossfade's dry term silence — the + /// stale-content dropout issue #59 exists to remove, kept alive behind a shortcut whose own + /// doc comment called it free. The line is cheap and the exception was not: there is no + /// early return. /// /// `delay` is read from the chain's *current* `latency_samples()` on every block rather than /// cached at preparation, so a model change that alters the reported latency (FR-CLAP-040) /// moves the compensation with it — issue #58. fn capture_dry(&mut self, io: &mut StageIo<'_>, delay: usize, capture: bool) { - if delay == 0 && !capture { - // Nothing to record and nothing to hand back: the line can only ever return what it - // is given, so skipping it is not a state divergence. - return; - } let frames = io.frames(); for ((line, dry), channel) in self .delay_lines @@ -642,7 +648,37 @@ impl Chain { /// A change that matches neither is broadcast to every stage. RD-2's per-instance parameter /// addressing (D-10.2) is future work by design — 1.0's fixed chain has no ambiguity to /// resolve, so each stage just ignores ids it doesn't own. + /// + /// # A non-finite value is refused here, and only here (issue #145 finding 6) + /// + /// [`ParamChange::value`] is a bare `f32` and the host is what fills it in: `namir-clap`'s + /// `audio.rs` hands `ev.value() as f32` straight to + /// [`AudioEngine::apply_param_direct`](crate::AudioEngine::apply_param_direct), and both that + /// method and the command ring's `Command::Param` arm reach a stage only through this method + /// — so this is the *single* boundary every parameter change in the engine crosses, whichever + /// thread it came from. + /// + /// Checking it here rather than at each consumer is deliberate, and the panic is the smaller + /// half of the reason. A `NaN` ceiling panics visibly, inside `f32::clamp`, on the audio + /// thread (D-16.3 forbids exactly that) — but a `NaN` reaching any *stage* is worse for being + /// silent: `db_to_linear(NaN)` is `NaN`, every sample the stage then produces is `NaN`, + /// FR-CHAIN-080 contains the fault by silencing the whole block, and it keeps doing so for + /// the rest of the session — a stage carrying filter state does not recover even when a valid + /// value arrives later, because its own `z` history is `NaN` by then. One `is_finite` at the + /// boundary covers both, and covers every parameter added after this one; a clamp at the + /// ceiling would have covered the panic alone. + /// + /// **Refused, not clamped, and silently.** There is no sensible clamp for "NaN dB", so the + /// last value the host set validly stays in force, which is the only degradation that leaves + /// the chain doing what it was asked to. Silently because this runs on the audio thread and + /// FR-ERR-030 leaves no logger there; a host emitting non-finite automation is out of + /// contract, not a condition the user can act on. + /// + /// **RT-safe:** one `f32::is_finite`. pub fn apply(&mut self, change: ParamChange) { + if !change.value.is_finite() { + return; + } if change.id == GLOBAL_BYPASS_ID { // Stepped param value is the index as f32 (`ParamChange`'s own doc comment); index 1 // is "On" per `GLOBAL_BYPASS`'s descriptor -- the same `>= 0.5` convention @@ -1292,6 +1328,194 @@ mod tests { } } + /// **Issue #145 finding 6.** `f32::clamp` asserts `min <= max`, and `-NaN <= NaN` is false, so + /// both of this module's clamp sites — `blend`'s wet term and `scan_and_clamp`'s ceiling pass + /// — **panic** on a `NaN` `output_ceiling_linear`. `global.output_ceiling_db` is + /// host-automatable and arrives as a bare `f32`: `namir-clap`'s `audio.rs` passes + /// `ev.value() as f32` straight to `AudioEngine::apply_param_direct` with no finiteness check + /// of its own, and `Chain::apply` handed it to `set_output_ceiling_db`, which is a bare + /// `db_to_linear`. A host emitting a `NaN` therefore panicked *inside* `process`, on the audio + /// thread, which under D-16.3 ("degrade, don't panic") is the one thing this crate may not do. + /// + /// All three non-finite values, not only `NaN`: `-inf` dB converts to a linear `0.0`, which + /// never panics and silences every block instead, which is not better. Both clamp sites, too + /// — settled-engaged (`scan_and_clamp`) and mid-crossfade (`blend`) — since a host can + /// automate the ceiling and the bypass in the same block. + /// + /// What "refused" has to mean is the last good value surviving: there is no sensible clamp + /// for "NaN dB", and keeping what the user last set is the only degradation that leaves the + /// chain doing what it was asked to. + /// + /// Committed red-first: before the fix the first `process` after the `NaN` panics inside + /// `f32::clamp` with "min > max, or either was NaN". + #[test] + fn a_non_finite_ceiling_is_refused_rather_than_panicking_on_the_audio_thread() { + const BLOCK: usize = 64; + const CEILING_DB: f32 = -6.0; + let ceiling = namir_core::db_to_linear(CEILING_DB); + let gain = namir_core::db_to_linear(6.0); + let expected = (0.4 * gain).min(ceiling); + + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let stage = FixedGainPrep { gain_db: 6.0 }.prepare(&ctx()).unwrap(); + let mut chain = Chain::new(vec![Box::new(stage)]); + chain.prepare_crosscutting(&ctx()); + chain.apply(ParamChange { + id: OUTPUT_CEILING_DB_ID, + value: CEILING_DB, + }); + chain.apply(ParamChange { + id: OUTPUT_CEILING_DB_ID, + value: bad, + }); + + // Settled engaged: `scan_and_clamp`'s ceiling pass. + let input = vec![0.4f32; BLOCK * 4]; + let engaged = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + for (n, out) in engaged.iter().enumerate() { + assert!( + (out - expected).abs() < 1e-5, + "{bad}: sample {n} is {out}, not the +6 dB stage output clamped to the \ + {CEILING_DB} dB ceiling the chain was last *validly* set to -- a refused \ + value must leave the last good one in place" + ); + } + + // Mid-crossfade: `blend`'s wet term, the other clamp site. One block after engaging + // bypass is well inside the 15 ms fade. + chain.set_global_bypass(true); + let fading = run_blocks(&mut chain, &input[..BLOCK], BLOCK, |_, _| {}); + for (n, out) in fading.iter().enumerate() { + // Between the clamped wet term (the ceiling) and the dry it is fading toward, + // which is the whole range `blend` can produce here. + assert!( + out.is_finite() && (0.4 - 1e-5..=ceiling + 1e-5).contains(out), + "{bad}: sample {n} of the crossfade is {out}, outside the blend of a \ + {CEILING_DB} dB-clamped wet term and a 0.4 dry one" + ); + } + assert_eq!(chain.fault_count(), 0, "{bad}: nothing here is a fault"); + } + } + + /// Finding 6's other half, and the reason the fix is at the boundary rather than at the + /// clamp that happened to panic: a non-finite value reaching a *stage* is not merely a + /// different panic, it is silent, persistent breakage that no clamp on the ceiling addresses. + /// + /// `FixedGainStage::apply` does `db_to_linear(change.value)`, which is `NaN` for a `NaN` — + /// the same shape every real stage's gain, coefficient and time-constant setter has. Every + /// sample it then produces is `NaN`, FR-CHAIN-080 dutifully contains the fault by silencing + /// the whole block, and it does so **on every block for the rest of the session**: the + /// parameter is not going to un-corrupt itself, and a stage carrying filter state (the EQ's + /// biquads) would not recover even when a later valid value arrived, because a biquad's own + /// `z` history is `NaN` by then. What the user hears is a plugin that went silent and stays + /// silent, with a fault counter climbing once per block and nothing naming the cause. + /// + /// Committed red-first: before the fix every sample of every block is 0.0 and `fault_count` + /// reaches one per block processed. + #[test] + fn a_non_finite_stage_parameter_never_reaches_the_stage_that_would_be_poisoned_by_it() { + const BLOCK: usize = 64; + let gain = namir_core::db_to_linear(6.0); + + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let stage = FixedGainPrep { gain_db: 6.0 }.prepare(&ctx()).unwrap(); + let mut chain = Chain::new(vec![Box::new(stage)]); + chain.prepare_crosscutting(&ctx()); + chain.apply(ParamChange { + id: GAIN_PARAM_ID, + value: bad, + }); + + let input = vec![0.4f32; BLOCK * 4]; + let out = run_blocks(&mut chain, &input, BLOCK, |_, _| {}); + for (n, sample) in out.iter().enumerate() { + assert!( + (sample - 0.4 * gain).abs() < 1e-5, + "{bad}: sample {n} is {sample}, not the +6 dB the stage was last validly set \ + to -- the value reached the stage and poisoned it" + ); + } + assert_eq!( + chain.fault_count(), + 0, + "{bad}: the chain silenced {} blocks containing a non-finite sample it should \ + never have been able to produce", + chain.fault_count() + ); + } + } + + /// **Issue #59's remaining half.** `capture_dry` returned early on `delay == 0 && !capture`, + /// which is every block of a session in which nothing has raised the chain's latency yet — + /// the whole of 1.0 until a rate-mismatched model is installed. So the line's write index + /// never moved, and the moment `NamStage` raised its declared latency 0 -> 640 (FR-CLAP-040, + /// `stages/nam.rs`'s `SlotResampler`) the read index addressed 640 samples of buffer nothing + /// had ever written. Engaging bypass inside that window fed the crossfade's dry term silence, + /// which is exactly the stale-content dropout #59 was fixed to remove — the early return had + /// simply kept a case of it alive behind the zero-latency shortcut the doc comment above + /// called free. + /// + /// A **constant** input, deliberately: with the signal flat, the wet term (a no-op stage) and + /// the dry term (that same signal, delayed by any amount) are the same number, so a settled + /// output, a fading one and every blend in between are all exactly `LEVEL`. Any departure is + /// the line handing back something it was never given. Alignment is the other #58/#59 tests' + /// job and needs a signal that varies; content is this one's, and needs one that does not. + /// + /// Committed red-first: before the fix the output falls to 0.2057 (LEVEL x 0.411, -7.7 dB) + /// 640 samples after the transition and then jumps back to 0.5 in a single sample as the + /// line's real content finally comes into reach — a 13 ms dip ended by a click. + #[test] + fn a_latency_rise_leaves_the_line_holding_signal_rather_than_never_written_zeros() { + const BLOCK: usize = 64; + /// The latency a 44.1 kHz model installed into a 48 kHz engine declares + /// (`stages/nam.rs`'s `SlotResampler`), which is the transition this is about. + const LATENCY: usize = 640; + /// Frames run before the transition. Longer than `LATENCY`, so a line that *was* being + /// fed has real content everywhere the read index can reach. + const BEFORE: usize = 4 * BLOCK * 8; + /// Frames run after it. Past `LATENCY` and past the blend's own settling window. + const AFTER: usize = 8_192; + const LEVEL: f32 = 0.5; + + let mut chain = Chain::new(vec![Box::new(VariableLatency { latency: 0 })]); + chain.prepare_crosscutting(&ctx()); + assert_eq!(chain.latency_samples(), 0); + + let input = vec![LEVEL; BEFORE + AFTER]; + let change_at_block = BEFORE / BLOCK; + let output = run_blocks(&mut chain, &input, BLOCK, |i, chain| { + if i == change_at_block { + // Both in the same block, which is the window the defect lives in: the handover + // that raises the latency and the bypass engaged within 640 samples of it. + chain.apply(ParamChange { + id: LATENCY_PARAM_ID, + value: LATENCY as f32, + }); + chain.set_global_bypass(true); + } + }); + + assert_eq!(chain.latency_samples(), LATENCY as u32); + let (at, worst) = + output[BEFORE..] + .iter() + .enumerate() + .fold( + (0usize, LEVEL), + |acc, (i, s)| { + if *s < acc.1 { (i, *s) } else { acc } + }, + ); + assert!( + worst >= LEVEL * (1.0 - 1e-4), + "the bypass dry term dropped to {worst} at frame {at} of a constant {LEVEL} signal, \ + a {:.1} dB dropout: the compensation line is handing back addresses nothing ever \ + wrote, because it was not fed while the chain reported zero latency", + 20.0 * (worst / LEVEL).max(f32::MIN_POSITIVE).log10() + ); + } + /// **Issue #36.** `process` used to gate the bypass on `cross_cutting.is_some()` /// (`if !bypassed || !prepared { run every stage }`), so a chain that never had /// `prepare_crosscutting` called on it **ran the whole chain while nominally bypassed** — a diff --git a/crates/namir-engine/src/chain_probes.rs b/crates/namir-engine/src/chain_probes.rs index f32763e..531baf8 100644 --- a/crates/namir-engine/src/chain_probes.rs +++ b/crates/namir-engine/src/chain_probes.rs @@ -15,6 +15,8 @@ //! Everything shared with the stage-level probes — the signals, the runner, the fixture loaders, //! the estimators — lives in [`crate::probe`] and is not duplicated here. +use std::sync::Arc; + use namir_core::{ChannelConfig, db_to_linear}; use namir_fixtures::nam::WaveNetShape; use namir_params::stages::{eq, gate, ir, nam, out, trim}; @@ -1276,3 +1278,185 @@ fn a_first_load_is_audible_inside_its_own_fade_at_every_block_size() { ); } } + +// --------------------------------------------------------------------------------------------- +// Issue #145 finding 7 — a handover into a rate-mismatched model, and the silence its resampler +// is primed with. +// --------------------------------------------------------------------------------------------- + +/// The declared rate that engages D-9.2's `SlotResampler` in a 48 kHz engine, and therefore the +/// only configuration in which a slot's own pipeline latency is nonzero. +const MISMATCHED_MODEL_RATE: u32 = 44_100; + +/// Frames run to settle a handover before anything is measured: past the 960-frame equal-power +/// fade, its 640-frame priming hold, and every gain ramp in the chain. +const HANDOVER_SETTLE_FRAMES: usize = 4_096; + +/// Builds the probe chain both limbs below use: a real default chain with the gate held open, so +/// the only thing that moves the output level is the handover under test. +fn handover_probe_chain(ctx: &PrepareContext) -> Chain { + let mut chain = build_default_chain(ctx).unwrap(); + probe::set_param(&mut chain, gate::THRESHOLD_DB.id, -70.0); + chain +} + +/// **Issue #145 finding 7, and the defect behind PR #145's red `clap_host_sample_rates` job.** +/// A handover *into* a rate-mismatched model must not crossfade against the silence that model's +/// resampler is primed with. +/// +/// # The mechanism +/// +/// `SlotResampler::new` puts one engine block of silence in the incoming slot's output FIFO — +/// deliberately, and load-bearing since M9b: it is what makes the slot's actual delay equal the +/// 640 samples it reports. The consequence nothing had measured is that the slot's first 640 +/// outputs *are* that silence, so an equal-power fade started at the install spends its first two +/// thirds blending against nothing: `outgoing * cos(theta)` alone, reaching `cos(60 deg)` = 0.5 +/// at frame 640. A −6 dB, 13 ms sag in the middle of every handover into a resampled model, +/// which is FR-NAM-070's "shall not ... glitch" clause failing on precisely the path D-9.2's +/// resampler exists for. +/// +/// # Why the same model on both sides +/// +/// Because it makes the correct answer exact rather than approximate. FR-NAM-070 specifies an +/// **equal-power** crossfade; between two identical, sample-aligned signals that law gives +/// `level * (cos(theta) + sin(theta))`, which is `>= level` everywhere on `[0, pi/2]` and peaks +/// at `sqrt(2)`. So the envelope of a reload of the same model may rise, and may not fall — no +/// tolerance-chasing, and no dependence on how two different models happen to compare in level. +/// It is also exactly what `crates/namir-clap/tests/clap_host_sample_rates.rs`'s loaded sweep +/// does (its activation replay and its own `state_ext::load` both recall the same document), which +/// is how this reached CI: with the sag inside that test's 960-frame measurement window its RMS +/// came out 1.76 dB low. +/// +/// Committed red-first. Before the fix the envelope of a same-model reload runs +/// `0.1743 0.1743 ... 0.0993 → 0.1454` — a monotone sag to 0.5x over 640 frames ended by a 47% +/// single-block jump when the incoming slot's real output finally arrives. Measured as the worst +/// 64-frame window peak against the settled level: **−4.89 dB before, +0.00 dB after** (the +/// instantaneous minimum is the `cos(60 deg)` the mechanism predicts, −6.0 dB at frame 640; the +/// window this metric quantises to straddles it). +/// **Carries no trace tag**, deliberately: FR-NAM-070 already resolves through +/// `engine.rs`'s `fr_nam_070_swapping_models_under_a_sine_has_no_discontinuity_or_dropout`, and +/// this is regression evidence for one defect on that path rather than a second reading of the +/// requirement — a tag here would add a resolution site and move the generated plan without +/// changing what is actually verified. +#[test] +fn a_handover_into_a_resampled_model_never_fades_against_its_priming_silence() { + const FRAMES: usize = 8_192; + const BLOCK_N: usize = 256; + /// The window the fade and its priming hold occupy, generously: 640 + 960 frames plus a + /// block of margin, rounded up. + const FADE_WINDOW: usize = 2_048; + /// Envelope resolution. 64 frames is 1.3 cycles of the 1 kHz probe, so a window's peak is its + /// envelope, and 30 windows span the fade. + const ENVELOPE_WINDOW: usize = 64; + /// How far under the settled level the envelope may sit. An equal-power blend of a signal + /// with itself cannot go under it at all; this is float and gain-ramp slack, two orders + /// tighter than the 6 dB the defect produces. + const SAG_TOLERANCE_DB: f32 = 0.2; + + let ctx = probe::ctx_at(SR, BLOCK_N, ChannelConfig::Mono); + let signal = probe::sine(FRAMES, 1_000.0, SR, 0.25); + let input = probe::duplicated(&signal, 1); + let model = probe::nam_model(WaveNetShape::Nano, 11, MISMATCHED_MODEL_RATE); + + let mut chain = handover_probe_chain(&ctx); + probe::load_nam(&mut chain, Arc::clone(&model), &ctx); + let settling = probe::duplicated(&signal[..HANDOVER_SETTLE_FRAMES], 1); + probe::run(&mut chain, &settling, BLOCK_N); + assert!( + chain.latency_samples() > 0, + "the first model never engaged D-9.2's resampler, so this probe is measuring a handover \ + with no priming silence in it and proves nothing" + ); + + // The same model again, installed on the first block of the measured run: a replacement + // handover, both of whose sides carry the identical 640-sample delay. + let out = probe::run_with(&mut chain, &input, BLOCK_N, |i, chain| { + if i == 0 { + probe::load_nam(chain, Arc::clone(&model), &ctx); + } + }); + let rendered = &out[0]; + + let settled = probe::peak(&rendered[FRAMES - HANDOVER_SETTLE_FRAMES..]); + assert!( + settled > 1e-3, + "the settled level is {settled:e}, so there is no signal here to detect a sag in" + ); + let worst = probe::min_window_peak(&rendered[..FADE_WINDOW], ENVELOPE_WINDOW); + let sag_db = 20.0 * (worst / settled).log10(); + assert!( + sag_db >= -SAG_TOLERANCE_DB, + "reloading the same rate-mismatched model dipped the output envelope to {worst:e} \ + against a settled {settled:e} ({sag_db:+.2} dB) inside its own handover. An equal-power \ + fade between a signal and itself cannot go below that level at all, so what the fade is \ + blending against for the first 640 frames is the incoming SlotResampler's priming \ + silence, not its output" + ); +} + +/// Finding 7's other half, stated as an equality rather than a level: until the incoming slot has +/// produced a real sample there is nothing to fade *to*, so the stage's output must be its dry +/// input **bit for bit** — the outgoing side alone, which is what `theta == 0` already evaluates +/// to. +/// +/// A first load, so the outgoing side is a pure dry passthrough and "the outgoing side alone" is +/// something a baseline chain with nothing loaded reproduces exactly. The two runs are built from +/// the same seeds and driven with the same input, so the first frame at which they differ is the +/// first frame at which the model contributed anything. +/// +/// **Not in tension with [`a_first_load_is_audible_inside_its_own_fade_at_every_block_size`]**, +/// which asserts the opposite bound — divergence within 8 frames — because that probe loads a +/// model declaring the *engine's own* rate and says so: with no `SlotResampler` there is no +/// pipeline to prime, this hold is zero frames long, and issue #141's onset is unchanged. The two +/// together say the fade starts as early as it can and no earlier. +/// +/// Committed red-first: before the fix the two runs diverge at **frame 2**, 638 frames before the +/// incoming slot can produce anything (frames 0 and 1 agree only because `cos(theta)` is still 1.0 +/// to the bit that early). The model's contribution there is a scaling of the dry signal by +/// `cos(theta)` — an attenuation dressed up as a fade. +#[test] +fn a_resampled_first_load_is_the_dry_signal_until_its_pipeline_has_primed() { + const FRAMES: usize = 8_192; + const BLOCK_N: usize = 256; + /// How soon after the hold the wet signal must appear. The same bound, and the same + /// reasoning, as [`ONSET_TOLERANCE_FRAMES`]: one frame is what the fix produces. + const ONSET_TOLERANCE_FRAMES: usize = 8; + + let ctx = probe::ctx_at(SR, BLOCK_N, ChannelConfig::Mono); + let signal = probe::sine(FRAMES, 220.0, SR, 0.25); + let input = probe::duplicated(&signal, 1); + let model = probe::nam_model(WaveNetShape::Nano, 11, MISMATCHED_MODEL_RATE); + + let mut baseline_chain = handover_probe_chain(&ctx); + let baseline = probe::run(&mut baseline_chain, &input, BLOCK_N); + + let mut loaded_chain = handover_probe_chain(&ctx); + let loaded = probe::run_with(&mut loaded_chain, &input, BLOCK_N, |i, chain| { + if i == 0 { + probe::load_nam(chain, Arc::clone(&model), &ctx); + } + }); + + // Read once the handover has settled: `NamStage::latency_samples` reports the *outgoing* slot + // for the whole fade, so this is the figure only after `active` has flipped onto the model. + let hold = loaded_chain.latency_samples() as usize; + assert!( + hold > 0, + "the model never engaged D-9.2's resampler, so there is no priming hold to measure" + ); + + let onset = first_divergence(&baseline[0], &loaded[0]) + .expect("the loaded run never diverged from the baseline at all"); + assert!( + onset >= hold, + "a model whose pipeline cannot produce a real sample for {hold} frames changed the \ + output at frame {onset}. What it contributed there was the incoming SlotResampler's \ + priming silence, faded in at cos(theta) against the dry signal" + ); + assert!( + onset <= hold + ONSET_TOLERANCE_FRAMES, + "the wet signal first appears at frame {onset}, {} frames after the {hold}-frame hold \ + its own pipeline needs — the fade is starting later than it can", + onset - hold + ); +} diff --git a/crates/namir-engine/src/stages/nam.rs b/crates/namir-engine/src/stages/nam.rs index c369825..177ad60 100644 --- a/crates/namir-engine/src/stages/nam.rs +++ b/crates/namir-engine/src/stages/nam.rs @@ -620,6 +620,34 @@ struct Crossfade { /// The fade's total duration in samples, fixed at construction /// (`NamStage::crossfade_total_samples`, from [`HANDOVER_CROSSFADE_MS`]). total: u32, + /// Samples the fade is held at `theta == 0` before it starts, so it never blends against a + /// signal the incoming slot has not produced yet — the incoming slot's own + /// [`NamSlot::latency_samples`], and therefore `0` for every slot without a + /// [`SlotResampler`]. + /// + /// # Why (issue #145's finding 7) + /// + /// A rate-mismatched slot's [`SlotResampler`] is built with one engine block of silence in + /// its output FIFO — deliberately, so its actual delay equals the 640 samples it reports (see + /// `SlotResampler::new`'s M9b note). Its first `latency_samples` outputs are therefore + /// silence, and an equal-power fade that starts at the install blends *that* silence in: + /// `outgoing * cos(theta)` alone for the first 640 of the fade's 960 samples, reaching + /// `cos(60 deg)` = 0.5 — a −6 dB, 13 ms level sag in the middle of every handover into a + /// rate-mismatched model, which is FR-NAM-070's "shall not ... glitch" clause failing on the + /// one path D-9.2's resampler exists for. + /// + /// Holding `theta` at zero for exactly that many samples is continuous at both ends (the + /// fade's own first sample already has `cos(0) == 1`, `sin(0) == 0`, so the hold *is* the + /// fade's first sample repeated) and costs nothing on the path that has no resampler, where + /// this is `0` and every arithmetic below is what it always was. Both slots still run for + /// every sample of the hold — that is what primes the incoming one. + /// + /// **It stays inside FR-NAM-070's 50 ms ceiling**, which the hold does lengthen: the largest + /// latency any 1.0 slot reports is a `SlotResampler`'s, and across the rates + /// `clap_host_sample_rates.rs` sweeps that peaks at 2 560 samples (a 44.1 kHz model at a + /// 192 kHz engine) = 13.3 ms, so hold plus [`HANDOVER_CROSSFADE_MS`] reaches about 33 ms at + /// its worst against the requirement's 50. + prime: u32, } /// RT-safe NAM stage: up to two [`NamSlot`]s, equal-power-crossfaded between per FR-NAM-070's @@ -824,6 +852,11 @@ impl NamStage { self.crossfade = Some(Crossfade { remaining: self.crossfade_total_samples, total: self.crossfade_total_samples, + // The slot being faded *into* — `install` has already put it there, and `unload` + // leaves it `None`, which is a dry passthrough with no pipeline to prime. + prime: self.slots[1 - self.active] + .as_ref() + .map_or(0, |slot| slot.latency_samples()), }); self.recompute_mix_target(); if mix_is_unobservable { @@ -956,6 +989,16 @@ impl NamStage { .zip(self.crossfade_outgoing[..n].iter()) .zip(self.crossfade_incoming[..n].iter()) { + // The incoming slot has not produced a real sample yet, so there is nothing to fade + // into: emit the outgoing side alone, which is what `theta == 0` already evaluates + // to. See `Crossfade::prime`. Written as its own arm rather than folded into + // `progress` so the fade's own arithmetic is untouched on the (far commoner) path + // where `prime` is zero from the start. + if crossfade.prime > 0 { + crossfade.prime -= 1; + *o = outgoing; + continue; + } let progress = (total - crossfade.remaining).min(total); let theta = (progress as f32 / total as f32) * FRAC_PI_2; *o = outgoing * theta.cos() + incoming * theta.sin(); @@ -1932,7 +1975,10 @@ mod tests { stage.crossfade, Some(Crossfade { remaining: 0, - total: stage.crossfade_total_samples + total: stage.crossfade_total_samples, + // `tiny_model(SR)` declares the engine's own rate, so no `SlotResampler` is built + // and there is no pipeline to prime. + prime: 0 }), "the fade should have reached zero and deferred its finalization" ); From c442eeccffe3ad98af8c3640849b6eafcfbaf1ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 19:04:43 +0000 Subject: [PATCH 43/44] Bound the reads, wait for the index, correct a stale latency claim (review findings 1, 2, 8) - worker: `read_file_bounded` becomes `pub`, and errors travel whole so `worker.file.too_large` and `worker.file.not_regular` stay distinct (#39). Four call sites did a bare `fs::read`; three of them read a document or need the bytes to take a ContentHash, which `LoadSource::File` yields neither of, so hiding the check there would have forced three hand-rolled copies of the ceiling. Two of the four are pre-existing, not new on this branch. Sharpest pre-fix reading: the clap library entry produced no notice at all, hashed 256 MiB and recorded it as the instance's nam_ref. - worker/clap: `LibraryService::loader()` and `ensure_loaded()` in both recall jobs. M14's deferred index load left `spawn_recall` and `library_target` resolving against an empty index for the first fraction of a second, so a preset's reference reported as unrecognised and FR-STATE-070's hash candidate could never fire. Routed outside the SharedInner mutex the GUI takes every frame -- calling it through that lock would reintroduce the ~161 ms stall M14 removed. Pre-existing; this branch fixed the roots half (#96) and claimed shell parity while leaving this open. - clap: a carried latency figure is now an outstanding claim, discharged either by the engine's reading moving or by the replay finishing and still disagreeing with what the host was told. Deleting a model while inactive previously left the host compensating a delay the chain did not have, for the session. Comparing against the announced figure directly does not work -- it republishes the transient zero and reopens #93's restart loop, which is why this waits on a new `worker_instance_epoch` instead. - clap: `emit_gui_param_changes` no longer leaves a `ParamGestureBegin` unmatched when the host's event buffer fills mid-pair; `GestureState` closes it on the next call. Observed [Begin, Value, Begin, Value, End] across two blocks before. - clap: `apply_direct_and_mirror` refuses a non-finite value. The engine guard above cannot protect the mirror, where a host's NaN would reach the editor and be written back into state. - clap test: the sample-rate landing gate settles on the signal itself. Every activation after the first dispatches two recall jobs; the second installs the same model at the same latency, so no latency reading distinguishes it, and one landing inside the window averaged an equal-power crossfade into the measurement. Reads block peak rather than RMS -- 256 frames at 191 100 Hz is 1.34 cycles, so a settled tone's RMS still swings 10.5% on window phase alone, against the 41% this must catch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-app/src/worker.rs | 164 ++++++++- crates/namir-clap/src/audio.rs | 160 +++++++- crates/namir-clap/src/main_thread.rs | 7 +- crates/namir-clap/src/params_ext.rs | 346 ++++++++++++++++-- crates/namir-clap/src/shared.rs | 121 +++++- crates/namir-clap/src/worker_jobs.rs | 202 +++++++++- crates/namir-clap/tests/clap_host_latency.rs | 194 +++++++++- .../tests/clap_host_sample_rates.rs | 94 ++++- crates/namir-worker/src/lib.rs | 19 +- crates/namir-worker/src/library.rs | 35 ++ 10 files changed, 1277 insertions(+), 65 deletions(-) diff --git a/crates/namir-app/src/worker.rs b/crates/namir-app/src/worker.rs index 6c126ef..142dc42 100644 --- a/crates/namir-app/src/worker.rs +++ b/crates/namir-app/src/worker.rs @@ -387,16 +387,27 @@ fn run(ctx: WorkerContext, commands: mpsc::Receiver, events: mpsc::S // `namir-clap`'s `worker_jobs::spawn_load_library_entry` already had exactly this // shape; this is the same three lines, so the two shells record the same reference // for the same file. - let bytes = match std::fs::read(&path) { + // + // **`read_file_bounded`, not `std::fs::read` (#145).** Reading the bytes here + // rather than inside `Instance::load` took this path off `LoadSource::File`, which + // was the only route through NFR-SEC-020's ceiling *and* through the `is_file()` + // check issue #107 added — so the hash came at the price of both. It does not have + // to: the bound belongs to the read, not to the `LoadSource`, and + // `namir_worker::read_file_bounded` is `pub` for exactly this caller. Without it a + // 4 GB `.wav` under a library root is read whole into memory before a parser + // rejects it, and a named pipe at that path blocks this thread — the *only* worker + // thread — leaving every later `SaveState`/`ListPresets`/`RescanLibrary` queued + // behind it for good. + let bytes = match namir_worker::read_file_bounded(&path) { Ok(b) => b, Err(e) => { let _ = events.send(AppEvent::LoadFinished { target, source: source_desc, - outcome: LoadOutcomeSummary::Failed(namir_worker::WorkerError::new( - namir_worker::error_codes::FILE_UNREADABLE, - e.to_string(), - )), + // Whole, with its own catalogue id (issue #39): `read_file_bounded` + // already distinguishes unreadable from too-large from not-a-regular- + // file, and flattening the three back into one would undo that. + outcome: LoadOutcomeSummary::Failed(e), }); continue; } @@ -472,7 +483,12 @@ fn run(ctx: WorkerContext, commands: mpsc::Receiver, events: mpsc::S let _ = events.send(AppEvent::StateSaved { path, error }); } AppCommand::LoadState(path) => { - let bytes = match std::fs::read(&path) { + // A user-chosen path, so exactly as untrusted as a library entry and read through + // the same bounded reader (#145). `namir_state::Document::parse` does enforce + // `MAX_DOCUMENT_BYTES`, but only once the whole file is already in memory — which + // is the allocation NFR-SEC-020 exists to refuse — and it says nothing at all + // about a path that is not a regular file. + let bytes = match namir_worker::read_file_bounded(&path) { Ok(b) => b, Err(e) => { let _ = events.send(AppEvent::StateLoaded { @@ -509,3 +525,139 @@ fn run(ctx: WorkerContext, commands: mpsc::Receiver, events: mpsc::S } } } + +#[cfg(test)] +mod tests { + use super::*; + use namir_core::{ChannelConfig, SampleRate}; + use namir_engine::{PrepareContext, RingCapacities, build_default_chain, split}; + use namir_worker::{EngineConfig, Instance, MAX_FILE_BYTES, ResourceCache}; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "namir-app-worker-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A real worker thread wired to a real (no-hardware) engine — the same construction + /// `crate::host`'s own tests use, minus the `AppHost` on top, because these tests drive + /// [`AppCommand`]s directly. + /// + /// The `AudioEngine` is returned rather than dropped: dropping it retires the ring the + /// worker's `Instance` submits into, and every load would then report `NotDelivered`. + fn spawn_worker(dir: &Path) -> (WorkerHandle, namir_engine::AudioEngine) { + let ctx = PrepareContext::new(SampleRate::new(48_000).unwrap(), 64, ChannelConfig::Stereo) + .unwrap(); + let chain = build_default_chain(&ctx).unwrap(); + let (engine, endpoint) = split(chain, RingCapacities::default()); + let instance = SharedInstance::new(Instance::new(EngineConfig { ctx }, endpoint)); + let (library, _warnings) = LibraryService::open_at(dir); + let roots = library.roots().to_vec(); + let handle = WorkerHandle::spawn(WorkerContext { + instance, + cache: Arc::new(ResourceCache::new()), + library: Arc::new(library), + pool: ThreadPool::with_threads(1), + library_roots: roots, + state: Arc::new(Mutex::new(State::defaults())), + }); + (handle, engine) + } + + /// Waits for the first event the worker reports that `pick` accepts. The worker thread is + /// asynchronous by construction, so a test has to wait for it rather than assume; five seconds + /// is far longer than any of these commands takes and short enough to fail rather than hang CI. + fn wait_for(worker: &WorkerHandle, mut pick: impl FnMut(&AppEvent) -> Option) -> T { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + for event in worker.drain_events() { + if let Some(found) = pick(&event) { + return found; + } + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the worker reported no matching event within the deadline"); + } + + /// A sparse file one byte past NFR-SEC-020's ceiling. `set_len` rather than writing 256 MiB: + /// the bound is checked against the file's *length*, and every filesystem this project targets + /// leaves the extension unallocated — the same construction `namir-worker`'s own + /// `read_file_bounded` test uses. + fn oversized_file(path: &Path) { + let file = std::fs::File::create(path).unwrap(); + file.set_len(MAX_FILE_BYTES as u64 + 1).unwrap(); + } + + /// **Issue #145, finding 2.** `LoadLibraryEntry` reads the file itself (it needs the bytes' + /// `ContentHash` for FR-STATE-060/-070's `FileRef`, which `LoadSource::File` never hands back) + /// and that read has to be `namir_worker::read_file_bounded`, not a bare `std::fs::read`. + /// With the bare read, a 4 GB `.wav` in a library root was pulled whole into memory and only + /// then rejected by a parser; NFR-SEC-020's ceiling has to refuse it before a byte is read. + // + // Only the byte-ceiling half is asserted here: the non-regular-file half (a FIFO or character + // device, which blocks this thread forever and with it every later command queued behind it) + // has no portable construction, and D-5.1 confines `#[cfg(unix)]` to `namir-platform`. No + // `trace:` tag either -- NFR-SEC-020's ledger entry is not this test's to move. + #[test] + fn an_oversized_library_entry_is_refused_before_it_is_read() { + let dir = temp_dir("oversized_entry"); + let (worker, _engine) = spawn_worker(&dir); + let path = dir.join("Library").join("huge.nam"); + oversized_file(&path); + + worker.send(AppCommand::LoadLibraryEntry(path)); + let error = wait_for(&worker, |event| match event { + AppEvent::LoadFinished { + outcome: LoadOutcomeSummary::Failed(e), + .. + } => Some(e.clone()), + _ => None, + }); + + assert_eq!( + error.code.id, + namir_worker::error_codes::FILE_TOO_LARGE.id, + "an oversized library entry must be refused by NFR-SEC-020's ceiling, not read \ + whole into memory and then refused by a parser: got {error}" + ); + drop(worker); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **Issue #145, finding 2**, the preset half: `LoadState` reads a user-chosen path, so it is + /// exactly as untrusted as a library entry and goes through the same bounded reader. + /// `namir_state::Document::parse` does check `MAX_DOCUMENT_BYTES`, but only *after* the whole + /// file is already in memory — which is the allocation NFR-SEC-020 exists to prevent, and is + /// no defence at all against a path that is not a regular file. + // + // Only the byte-ceiling half is asserted here: the non-regular-file half (a FIFO or character + // device, which blocks this thread forever and with it every later command queued behind it) + // has no portable construction, and D-5.1 confines `#[cfg(unix)]` to `namir-platform`. No + // `trace:` tag either -- NFR-SEC-020's ledger entry is not this test's to move. + #[test] + fn an_oversized_preset_is_refused_before_it_is_read() { + let dir = temp_dir("oversized_preset"); + let (worker, _engine) = spawn_worker(&dir); + let path = dir.join("huge.namirpreset"); + oversized_file(&path); + + worker.send(AppCommand::LoadState(path)); + let error = wait_for(&worker, |event| match event { + AppEvent::StateLoaded { error, .. } => error.clone(), + _ => None, + }); + + assert!( + error.contains(namir_worker::error_codes::FILE_TOO_LARGE.id), + "an oversized preset must be refused by NFR-SEC-020's ceiling before the read, not \ + after `Document::parse` has already been handed 256 MiB: got {error}" + ); + drop(worker); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/namir-clap/src/audio.rs b/crates/namir-clap/src/audio.rs index d61d08f..2185190 100644 --- a/crates/namir-clap/src/audio.rs +++ b/crates/namir-clap/src/audio.rs @@ -72,6 +72,27 @@ //! requirement Namir has no way around, not an engine defect; FR-NAM-070's glitch-free crossfade //! still holds for every model swap that does *not* change latency, which is the common case. See //! `docs/manual-tests/fr-clap-040-latency-restart.md`. +//! +//! ## The figure an activation carries is a prediction, and predictions get checked (issue #145) +//! +//! `SharedInner::carried_latency` (issue #93) lets `activate` keep announcing the figure the host +//! already has instead of the zero its freshly built engine reports, because the replay it is about +//! to dispatch is expected to put the same model — and the same latency — straight back. That is a +//! prediction about work that has not happened yet, and it can be wrong: the model may have been +//! deleted or replaced with a session-rate one while the plugin was inactive, in which case the +//! replay converges on **zero**, which is exactly what the fresh engine already reported. "Differs +//! from the last block" is then false forever, and the host is left compensating for a delay the +//! chain does not have — for the rest of the session, since nothing else will ever wake the main +//! thread about it. +//! +//! So a carried figure is tracked as an outstanding claim ([`CarriedLatency`]) until it is +//! discharged one of two ways: the engine's own reading moves (the prediction came true, or came +//! true differently, and the ordinary change path handles it), or the replay finishes with the +//! engine and the reading still disagrees with what the host was *told*, in which case the figure +//! is corrected downwards and the same restart machinery renegotiates it. "The replay finished" is +//! not something the engine can report — a replay that loads nothing leaves no trace on it — so it +//! comes from `SharedInner::worker_instance_epoch`, with `CARRY_SETTLE_MS` covering the bounded +//! remainder between a command being submitted and the handover crossfade completing. use clack_plugin::events::Event; use clack_plugin::events::spaces::CoreEventSpace; @@ -104,11 +125,47 @@ pub struct NamirAudioProcessor<'a> { /// this while an activation's replay is still in flight (issue #93; see /// `SharedInner::carried_latency`). last_seen_latency: u32, + /// The unconfirmed half of that difference, while there is one — see [`CarriedLatency`] and + /// [`Self::publish_latency`]. `None` for an activation that adopted the engine's own reading, + /// which is the common case and needs no bookkeeping at all. + carried: Option, /// This activation's sample rate, kept so [`Self::publish_latency`] can record which rate the /// figure it publishes was measured at without asking the engine again. sample_rate_hz: u32, } +/// A latency figure `activate` carried across an activation on the *prediction* that this +/// activation's replay will reproduce it — issue #93's mechanism, and issue #145's finding 8 about +/// what happens when the prediction is wrong. +/// +/// See [`NamirAudioProcessor::publish_latency`] for how this is discharged. +struct CarriedLatency { + /// The figure `activate` published and `notify_latency_changed` announced: what the host is + /// currently compensating for, and what the engine's own reading is measured against once the + /// replay has had its turn. + announced: u32, + /// `SharedInner::worker_instance_epoch` as `activate` read it, immediately before dispatching + /// the replay. While it is unchanged the replay has not finished touching the engine, so the + /// engine's reading is a transient rather than an answer. + epoch: u32, + /// Frames still to be processed before the prediction is judged, or `None` while the epoch has + /// not moved. See [`CARRY_SETTLE_MS`]. + settle_frames: Option, +} + +/// How much audio must pass after the replay has finished with the engine before an unconfirmed +/// carried figure is judged against the engine's own reading. +/// +/// **Not a guess at how long a replay takes** — that is unbounded (a file read, a parse, a worker +/// thread the OS may schedule whenever it likes) and is what `SharedInner::worker_instance_epoch` +/// answers instead. This covers only the strictly bounded remainder: the replay's resource command +/// is already in the SPSC ring by the time the epoch moves, so what is left is one `process()` call +/// to drain it plus D-8.1 step 3's handover crossfade (`HANDOVER_CROSSFADE_MS`, 20 ms) before the +/// new slot becomes active and the chain's reported latency moves. Half a second is that with more +/// than an order of magnitude of margin, and it is half a second of *processed audio*, so it is +/// half a second of wall clock in any host that is actually running. +const CARRY_SETTLE_MS: u32 = 500; + impl<'a> NamirAudioProcessor<'a> { /// Runs `audio`'s frames `[start, end)` through the engine — see [`process_port_pair`] for the /// per-port channel plumbing. Called once per segment of [`Self::process`]'s event split, so a @@ -141,24 +198,84 @@ impl<'a> NamirAudioProcessor<'a> { /// `process()`'s own automation loop and `crate::params_ext`'s `PluginAudioProcessorParams:: /// flush` (called when active but `process()` was not, per `clack_extensions::params`'s own /// doc comment) share, kept in one place so the two paths cannot silently drift apart. + /// + /// **Non-finite values are refused here, not passed on** (issue #145's finding 6). A host is + /// free to hand us any `f64` it likes and both callers narrow it with `as f32`, so a `NaN` or + /// an infinity is reachable from outside. `namir_engine`'s `Chain::apply` rejects one too -- + /// it has to, being reachable from the ring as well -- but the engine's guard cannot protect + /// the *mirror*: without this check the bad value would still be stored, shown in the editor + /// and written back into the instance's state on the next save, which outlives the session + /// the bad event arrived in. Refused rather than clamped, for the reason D-16.3 gives and + /// `Chain::apply` repeats: there is no sensible clamp for "NaN dB", so the last valid value + /// stays in force. Silent, per FR-ERR-030 -- this is the audio thread. pub(crate) fn apply_direct_and_mirror(&mut self, id: ParamId, value: f32) { + if !value.is_finite() { + return; + } self.engine.apply_param_direct(ParamChange { id, value }); self.shared.inner.params.set_by_id(id.0, value); } /// Publishes this block's latency reading and, if it changed, wakes the main thread — see - /// this module's doc comment for the full FR-CLAP-040 sequence. - fn publish_latency(&mut self) { + /// this module's doc comment for the full FR-CLAP-040 sequence, and [`CarriedLatency`] for the + /// one case in which "changed" is not the same question as "differs from last block". + /// + /// `frames` is this block's own frame count, which only the settle countdown reads. + fn publish_latency(&mut self, frames: u32) { let latency = self.engine.chain().latency_samples(); - if latency == self.last_seen_latency { - // Nothing moved. In particular this is the whole of a block during an activation's - // replay, where the engine still reports 0 and `SharedInner::latency_samples` is - // deliberately holding the figure the replay will converge on -- so this must compare - // against the engine's own last reading, never against the published one, or it would - // republish the transient zero and re-open issue #93's loop from the other side. + if latency != self.last_seen_latency { + // The engine has spoken, which settles any outstanding prediction along with it. + self.last_seen_latency = latency; + self.carried = None; + self.announce_latency(latency); return; } - self.last_seen_latency = latency; + + // The engine's reading has not moved. Ordinarily that is the whole story -- and it must + // stay the whole story while a replay is in flight, where the engine still reports 0 and + // `SharedInner::latency_samples` is deliberately holding the figure that replay is + // expected to converge on. Republishing the transient zero there re-opens issue #93's loop + // from the other side, which is why this compares against the engine's own last reading + // rather than against the published one. + // + // What that comparison alone cannot see is a replay that has *finished* and converged + // somewhere else -- most sharply, back on the fresh engine's own zero, where "differs from + // last block" is false forever and the host is left compensating for a delay the chain + // does not have (issue #145's finding 8). So a carried figure is a prediction with an + // outstanding verdict, and this is where it is discharged: once the replay has finished + // with the engine (`SharedInner::worker_instance_epoch`) and the bounded remainder of the + // handover has had time to land (`CARRY_SETTLE_MS`), the engine's reading is compared + // against what the host was actually *told*, and a disagreement is published like any + // other change. + let Some(carried) = self.carried.as_mut() else { + return; + }; + match carried.settle_frames { + None => { + if self.shared.inner.worker_instance_epoch() != carried.epoch { + carried.settle_frames = Some(settle_frames(self.sample_rate_hz)); + } + } + Some(remaining) => { + let remaining = remaining.saturating_sub(frames); + carried.settle_frames = Some(remaining); + if remaining == 0 { + let announced = carried.announced; + self.carried = None; + if latency != announced { + self.announce_latency(latency); + } + } + } + } + } + + /// Publishes `latency` as this instance's reported figure and wakes the main thread to act on + /// it — the tail both of [`Self::publish_latency`]'s paths share. + /// + /// Wait-free: two relaxed atomic stores and `request_callback`, which + /// `clack_extensions::latency` documents as thread-safe and which returns without waiting. + fn announce_latency(&mut self, latency: u32) { self.shared .inner .publish_latency(latency, self.sample_rate_hz); @@ -229,6 +346,9 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> .carried_latency(sample_rate_hz) .unwrap_or(engine_latency); shared.inner.publish_latency(reported, sample_rate_hz); + // Read *before* the replay is dispatched below, or the very job whose completion this is + // waiting for could finish between the read and the dispatch and go unnoticed. + let epoch = shared.inner.worker_instance_epoch(); // Permitted here unconditionally per `clack_extensions::latency::HostLatency::changed`'s // own doc comment ("allowed to change only during the activate callback") — see this // module's doc comment for the full sequence. It is also what records `reported` as the @@ -248,6 +368,13 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> host, priority_elevated: false, last_seen_latency: engine_latency, + // Only when the two actually disagree is there a prediction outstanding: a carried + // figure that already matches the fresh engine's reading predicts nothing. + carried: (reported != engine_latency).then_some(CarriedLatency { + announced: reported, + epoch, + settle_frames: None, + }), sample_rate_hz, }) } @@ -308,14 +435,18 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> self.process_segment(&mut audio, cursor, frames); } - self.publish_latency(); + self.publish_latency(frames); // FR-PARAM-030's other direction (issue #94): a knob the user turned in *this* plugin's // editor is reported back to the host as automation, wrapped in a gesture, so the host can // record it and keep its own generic UI in step. See `crate::params_ext`'s // `emit_gui_param_changes` for why this is allocation-free and why host-originated changes // are never echoed back through it. - crate::params_ext::emit_gui_param_changes(&self.shared.inner.params, events.output); + crate::params_ext::emit_gui_param_changes( + &self.shared.inner.params, + &self.shared.inner.gestures, + events.output, + ); Ok(ProcessStatus::Continue) } @@ -335,6 +466,13 @@ impl<'a> PluginAudioProcessor<'a, NamirShared<'a>, NamirMainThread<'a>> } } +/// [`CARRY_SETTLE_MS`] as a frame count at `sample_rate_hz`, saturating rather than wrapping on a +/// rate no sane host presents. At least one frame, so the countdown always terminates. +fn settle_frames(sample_rate_hz: u32) -> u32 { + let frames = u64::from(sample_rate_hz) * u64::from(CARRY_SETTLE_MS) / 1_000; + u32::try_from(frames).unwrap_or(u32::MAX).max(1) +} + /// Builds the up-to-two channel mutable slices a `StageIo` needs from one port pair's frames /// `[start, end)`, and runs the engine over them. Declared free (not a method) so its /// generic-lifetime signature stays simple; see this module's doc comment section on channel diff --git a/crates/namir-clap/src/main_thread.rs b/crates/namir-clap/src/main_thread.rs index 76e7020..6c32cc0 100644 --- a/crates/namir-clap/src/main_thread.rs +++ b/crates/namir-clap/src/main_thread.rs @@ -90,7 +90,12 @@ impl<'a> NamirMainThread<'a> { } fn request_param_flush_if_pending(&mut self) { - if !self.shared.inner.params.has_gui_pending() { + // A gesture this instance opened and could not close counts as pending work too (issue + // #145): the change itself may have reached the host perfectly well, and the only thing + // still owed is the `ParamGestureEnd` that `crate::params_ext`'s + // `emit_gui_param_changes` will push at the head of its next call. Without this an + // inactive plugin with nothing left to report would never ask for that call. + if !self.shared.inner.params.has_gui_pending() && !self.shared.inner.gestures.has_open() { return; } if let Some(params) = self.host_params { diff --git a/crates/namir-clap/src/params_ext.rs b/crates/namir-clap/src/params_ext.rs index 530463e..a3a3cd0 100644 --- a/crates/namir-clap/src/params_ext.rs +++ b/crates/namir-clap/src/params_ext.rs @@ -12,6 +12,7 @@ //! was nothing here to flag. use std::ffi::CStr; +use std::sync::atomic::{AtomicU64, Ordering}; use clack_extensions::params::{ ParamDisplayWriter, ParamInfo, ParamInfoFlags, ParamInfoWriter, PluginAudioProcessorParams, @@ -115,6 +116,55 @@ fn apply_flush_events(input: &InputEvents, mut apply: impl FnMut(EngineParamId, } } +/// Whether a [`ParamGestureBeginEvent`] this crate emitted is still owed its matching +/// [`ParamGestureEndEvent`], per `REGISTRY` index — the state [`emit_gui_param_changes`] needs +/// between two calls, and the whole of it. +/// +/// **Why this exists (issue #145's review, below the cut).** The three pushes that make up one +/// gesture are three separate `OutputEvents::try_push` calls, and a host is entitled to refuse any +/// of them: `try_push`'s own documentation says so ("usually a sign that the implementer ran out of +/// buffer space"). A refusal partway through leaves the *begin* already delivered, and there is no +/// un-push — so the only way to keep the host's view well-formed is to remember what is still open +/// and close it on the next call, before anything else is emitted. A host that tracks gesture +/// nesting (which is what the begin/end pair is *for*) otherwise keeps recording automation for a +/// knob the user let go of, until the next accident happens to balance the books. +/// +/// One `u64`, one bit per `REGISTRY` entry, exactly like [`crate::param_mirror::ParamMirror`]'s own +/// pending set — so this holds at most 64 parameters, which is the same ceiling that type already +/// documents and asserts. +/// +/// Lives in [`crate::shared::SharedInner`] rather than in either `flush` implementation because the +/// two audio-thread entry points (`crate::audio`'s `process` and this module's +/// `PluginAudioProcessorParams::flush`) and the main-thread one all emit through the same function, +/// and a gesture opened by one of them must be closed by whichever runs next. +#[derive(Default)] +pub(crate) struct GestureState { + open: AtomicU64, +} + +impl GestureState { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Takes the set of parameters whose gesture is still open, leaving it empty — the caller then + /// owns closing them, and stores back whatever it could not close ([`Self::store_open`]). + fn take_open(&self) -> u64 { + self.open.swap(0, Ordering::Relaxed) + } + + fn store_open(&self, open: u64) { + self.open.store(open, Ordering::Relaxed); + } + + /// Whether any gesture is still waiting for its end event — read by + /// [`crate::main_thread::NamirMainThread::request_param_flush_if_pending`], so an inactive + /// plugin still gets a call in which to close one. + pub(crate) fn has_open(&self) -> bool { + self.open.load(Ordering::Relaxed) != 0 + } +} + /// Reports every parameter the user moved in **this plugin's own editor** to the host, as a /// gesture-wrapped automation point — issue #94, and `clack_extensions::params`' own "Turning a /// knob on the Plugin interface" scenario ("send an automation event and don't forget to wrap the @@ -132,20 +182,57 @@ fn apply_flush_events(input: &InputEvents, mut apply: impl FnMut(EngineParamId, /// unterminated drag. Widening `UiIntent` to carry drag boundaries is a `namir-ui` change, not a /// `namir-clap` one. /// +/// # A refused push must not leave a gesture hanging (issue #145) +/// +/// The three pushes were `&&`-chained, which reads as all-or-nothing and is not: `&&` short-circuits +/// *after* the begin has already been handed to the host, so a buffer that filled up between the +/// begin and the end left an unmatched `ParamGestureBegin` out there — and the retry path then +/// emitted a *second* begin on the next block, because the change was correctly put back in the +/// pending set. Two begins, one end. +/// +/// There is no un-push, and `OutputEvents` exposes no remaining capacity to check first, so the +/// emission is made whole across calls rather than within one: +/// +/// 1. any gesture a previous call left open is closed first, before anything new is emitted; +/// 2. a begin is only pushed once nothing is still open, since a host that is refusing events is +/// refusing the end that would balance a new begin too; +/// 3. once a begin *is* delivered, this owes an end for that parameter — so the end is attempted +/// even when the value push was refused, and recorded as still-open if it too is refused. +/// +/// The value and the gesture are tracked separately on purpose: a value that did not reach the host +/// goes back into the mirror's pending set and is reported again next call (never lost, never +/// duplicated), while the *gesture* is a debt to the host that is settled where it was incurred. +/// /// # Real-time safety /// /// Called from `process()` (the audio thread) as well as from both `flush` implementations. -/// Allocation-free and bounded: one `swap`, at most 64 iterations (one per `REGISTRY` entry, and -/// only for entries actually marked), three stack-built `#[repr(C)]` events each, and no branch -/// that can loop. `OutputEvents::try_push` calls the host's own callback, which the host is -/// required to keep real-time safe for exactly this reason. -/// -/// A `try_push` the host refuses (a full buffer) puts the change back in the pending set rather -/// than dropping it, so it is reported on the next block instead of silently lost. +/// Allocation-free and bounded: two `swap`s, at most 64 iterations each (one per `REGISTRY` entry, +/// and only for entries actually marked), at most three stack-built `#[repr(C)]` events per entry, +/// and no branch that can loop. `OutputEvents::try_push` calls the host's own callback, which the +/// host is required to keep real-time safe for exactly this reason. pub(crate) fn emit_gui_param_changes( mirror: &crate::param_mirror::ParamMirror, + gestures: &GestureState, out: &mut OutputEvents, ) { + // 1. Settle what a previous call could not: a begin is out there with no end after it, and + // nothing new may be emitted in front of it. + let mut unclosed = gestures.take_open(); + let mut still_open = 0u64; + while unclosed != 0 { + let index = unclosed.trailing_zeros() as usize; + let bit = 1u64 << index; + unclosed &= !bit; + let Some(descriptor) = REGISTRY.get(index) else { + continue; + }; + let id = ClapId::new(descriptor.id.0); + if out.try_push(ParamGestureEndEvent::new(0, id)).is_err() { + still_open |= bit; + } + } + + // 2. This call's own changes. let mut pending = mirror.take_gui_pending(); let mut undelivered = 0u64; while pending != 0 { @@ -156,25 +243,40 @@ pub(crate) fn emit_gui_param_changes( let (Some(descriptor), Some(value)) = (REGISTRY.get(index), mirror.value_at(index)) else { continue; }; + if still_open != 0 { + // The host is refusing events -- it just refused an end. Opening another gesture now + // would only add a second one that cannot be closed either. + undelivered |= bit; + continue; + } let id = ClapId::new(descriptor.id.0); - let delivered = out.try_push(ParamGestureBeginEvent::new(0, id)).is_ok() - && out - .try_push(ParamValueEvent::new( - 0, - id, - Pckn::new(Match::All, Match::All, Match::All, Match::All), - value as f64, - Cookie::empty(), - )) - .is_ok() - && out.try_push(ParamGestureEndEvent::new(0, id)).is_ok(); - if !delivered { + if out.try_push(ParamGestureBeginEvent::new(0, id)).is_err() { + // Nothing was delivered for this parameter, so nothing is owed: report it next call. + undelivered |= bit; + continue; + } + // From here the host has the begin, and is owed the end whatever else happens. + let value_delivered = out + .try_push(ParamValueEvent::new( + 0, + id, + Pckn::new(Match::All, Match::All, Match::All, Match::All), + value as f64, + Cookie::empty(), + )) + .is_ok(); + if out.try_push(ParamGestureEndEvent::new(0, id)).is_err() { + still_open |= bit; + } + if !value_delivered { undelivered |= bit; } } + if undelivered != 0 { mirror.restore_gui_pending(undelivered); } + gestures.store_open(still_open); } impl<'a> PluginMainThreadParams for NamirMainThread<'a> { @@ -240,8 +342,9 @@ impl<'a> PluginMainThreadParams for NamirMainThread<'a> { }); // The outbound half (issue #94). This is the *only* channel a GUI-originated change has // while the plugin is inactive, which is why `crate::main_thread`'s - // `request_param_flush_if_pending` exists to ask for this call at all. - emit_gui_param_changes(mirror, output_parameter_changes); + // `request_param_flush_if_pending` exists to ask for this call at all -- including when + // there is no change left to report and only a gesture to close (issue #145). + emit_gui_param_changes(mirror, &shared.inner.gestures, output_parameter_changes); } } @@ -264,7 +367,11 @@ impl<'a> PluginAudioProcessorParams for NamirAudioProcessor<'a> { apply_flush_events(input_parameter_changes, |id, value| { self.apply_direct_and_mirror(id, value) }); - emit_gui_param_changes(&shared.inner.params, output_parameter_changes); + emit_gui_param_changes( + &shared.inner.params, + &shared.inner.gestures, + output_parameter_changes, + ); } } @@ -363,7 +470,7 @@ mod tests { mirror.set_by_key_from_gui(descriptor.key, 4.5); let mut buffer = EventBuffer::with_capacity(8); - emit_gui_param_changes(&mirror, &mut buffer.as_output()); + emit_gui_param_changes(&mirror, &GestureState::new(), &mut buffer.as_output()); let events: Vec<&clack_plugin::events::UnknownEvent> = buffer.iter().collect(); assert_eq!( @@ -391,7 +498,7 @@ mod tests { // Reported once: a second drain with nothing new emits nothing at all. let mut again = EventBuffer::with_capacity(8); - emit_gui_param_changes(&mirror, &mut again.as_output()); + emit_gui_param_changes(&mirror, &GestureState::new(), &mut again.as_output()); assert!( again.is_empty(), "a change already reported must not be reported again every block" @@ -423,7 +530,7 @@ mod tests { mirror.set_by_key_from_gui(namir_params::stages::trim::GAIN_DB.key, 4.5); let mut buffer = EventBuffer::with_capacity(8); - emit_gui_param_changes(&mirror, &mut buffer.as_output()); + emit_gui_param_changes(&mirror, &GestureState::new(), &mut buffer.as_output()); let events: Vec<&clack_plugin::events::UnknownEvent> = buffer.iter().collect(); assert_eq!(events.len(), 3, "begin + value + end"); @@ -462,10 +569,197 @@ mod tests { mirror.set_by_id(namir_params::stages::trim::GAIN_DB.id.0, 9.0); let mut buffer = EventBuffer::with_capacity(8); - emit_gui_param_changes(&mirror, &mut buffer.as_output()); + emit_gui_param_changes(&mirror, &GestureState::new(), &mut buffer.as_output()); assert!( buffer.is_empty(), "echoing the host's own automation back at it is a feedback loop, not a report" ); } } + +#[cfg(test)] +mod gesture_tests { + use super::*; + use clack_plugin::events::UnknownEvent; + use clack_plugin::events::io::{EventBuffer, OutputEventBuffer, TryPushError}; + + /// An `OutputEvents` backing buffer that accepts `cap` events and refuses everything after + /// them. + struct CappedOutput { + buffer: EventBuffer, + cap: u32, + } + + impl CappedOutput { + fn new(cap: u32) -> Self { + Self { + buffer: EventBuffer::with_capacity(8), + cap, + } + } + } + + impl OutputEventBuffer for CappedOutput { + fn try_push(&mut self, event: &UnknownEvent) -> Result<(), TryPushError> { + if self.buffer.len() >= self.cap { + return Err(TryPushError::new()); + } + self.buffer.try_push(event) + } + } + + /// `(kind, param_id)` for every event in `buffer`, where kind is `1` for a gesture begin, `-1` + /// for a gesture end and `0` for a value. + fn shape(buffer: &EventBuffer) -> Vec<(i32, Option)> { + buffer + .iter() + .map(|e| { + if let Some(b) = e.as_event::() { + (1, b.param_id()) + } else if let Some(end) = e.as_event::() { + (-1, end.param_id()) + } else if let Some(v) = e.as_event::() { + (0, v.param_id()) + } else { + panic!("unexpected event kind") + } + }) + .collect() + } + + /// **Issue #145's below-the-cut finding.** A host that refuses a push partway through a + /// gesture must not be left holding an unmatched [`ParamGestureBeginEvent`]. + /// + /// Driven at every interesting capacity, because the three pushes fail in three materially + /// different places: `0` refuses the begin (nothing is owed), `1` refuses the value and the end + /// (the begin is out there alone), `2` refuses only the end, and `3` refuses nothing. In every + /// case the two blocks together must read as well-formed gestures — never two begins in a row, + /// never an end with nothing open, and nothing left open at the finish — and the value the user + /// dialled must reach the host exactly once, neither lost nor duplicated. + /// + /// Before the fix, capacities 1 and 2 produced `[Begin, (Value,) Begin, Value, End]`: the + /// `&&`-chain short-circuited after the begin had already been handed over, and the retry path + /// — correct in itself, since the change must not be dropped — opened a second gesture on the + /// next block. + #[test] + fn a_refused_push_never_leaves_the_host_with_an_unmatched_gesture_begin() { + for cap in 0..=3u32 { + let mirror = crate::param_mirror::ParamMirror::new(); + let gestures = GestureState::new(); + let descriptor = &namir_params::stages::trim::GAIN_DB; + let expected_id = Some(ClapId::new(descriptor.id.0)); + mirror.set_by_key_from_gui(descriptor.key, 4.5); + + // One block into a host buffer with room for `cap` events... + let mut capped = CappedOutput::new(cap); + emit_gui_param_changes( + &mirror, + &gestures, + &mut clack_plugin::events::io::OutputEvents::from_buffer(&mut capped), + ); + + // ...and the next one into a host that refuses nothing. + let mut second = EventBuffer::with_capacity(8); + emit_gui_param_changes(&mirror, &gestures, &mut second.as_output()); + + let mut stream = shape(&capped.buffer); + stream.extend(shape(&second)); + + let mut depth = 0i32; + for (kind, id) in &stream { + assert_eq!( + *id, expected_id, + "cap {cap}: an event on the wrong parameter" + ); + depth += kind; + assert!( + (0..=1).contains(&depth), + "cap {cap}: the host sees a malformed gesture stream {stream:?} (depth \ + {depth}) -- a second ParamGestureBegin arrived before the first was closed, \ + so a host tracking gesture nesting keeps writing automation after the user \ + let go" + ); + } + assert_eq!( + depth, 0, + "cap {cap}: every gesture the host was told about must eventually be closed: \ + {stream:?}" + ); + assert_eq!( + stream.iter().filter(|(kind, _)| *kind == 0).count(), + 1, + "cap {cap}: the value the user dialled must reach the host exactly once: \ + {stream:?}" + ); + assert!( + !gestures.has_open(), + "cap {cap}: a gesture is still recorded as open after a block that refused nothing" + ); + } + } + + /// The one case the retry path cannot settle on its own: the second block refuses the closing + /// end too, so the debt is still recorded and `has_open` is what tells + /// `crate::main_thread`'s `request_param_flush_if_pending` to ask the host for another call. + #[test] + fn a_gesture_that_could_not_be_closed_is_still_owed_after_the_retry() { + let mirror = crate::param_mirror::ParamMirror::new(); + let gestures = GestureState::new(); + mirror.set_by_key_from_gui(namir_params::stages::trim::GAIN_DB.key, 4.5); + + let mut first = CappedOutput::new(2); // begin + value, no room for the end + emit_gui_param_changes( + &mirror, + &gestures, + &mut clack_plugin::events::io::OutputEvents::from_buffer(&mut first), + ); + assert!(gestures.has_open()); + + let mut second = CappedOutput::new(0); // refuses the end as well + emit_gui_param_changes( + &mirror, + &gestures, + &mut clack_plugin::events::io::OutputEvents::from_buffer(&mut second), + ); + assert!( + gestures.has_open(), + "the end is still owed, and nothing but a later call can deliver it" + ); + + let mut third = EventBuffer::with_capacity(8); + emit_gui_param_changes(&mirror, &gestures, &mut third.as_output()); + assert_eq!( + shape(&third), + vec![( + -1, + Some(ClapId::new(namir_params::stages::trim::GAIN_DB.id.0)) + )], + "the first call that can take an event closes the gesture, and emits nothing else" + ); + assert!(!gestures.has_open()); + } + + /// A begin that was itself refused owes nothing: the change goes back into the mirror's + /// pending set and is reported whole next time, with no phantom end in front of it. + #[test] + fn a_refused_begin_leaves_no_debt_and_loses_no_change() { + let mirror = crate::param_mirror::ParamMirror::new(); + let gestures = GestureState::new(); + let descriptor = &namir_params::stages::trim::GAIN_DB; + mirror.set_by_key_from_gui(descriptor.key, 4.5); + + let mut refused = CappedOutput::new(0); + emit_gui_param_changes( + &mirror, + &gestures, + &mut clack_plugin::events::io::OutputEvents::from_buffer(&mut refused), + ); + assert!(refused.buffer.is_empty()); + assert!(!gestures.has_open()); + + let mut next = EventBuffer::with_capacity(8); + emit_gui_param_changes(&mirror, &gestures, &mut next.as_output()); + let id = Some(ClapId::new(descriptor.id.0)); + assert_eq!(shape(&next), vec![(1, id), (0, id), (-1, id)]); + } +} diff --git a/crates/namir-clap/src/shared.rs b/crates/namir-clap/src/shared.rs index efc4244..6645cc1 100644 --- a/crates/namir-clap/src/shared.rs +++ b/crates/namir-clap/src/shared.rs @@ -82,11 +82,15 @@ use namir_worker::pool::ThreadPool; use namir_worker::{Instance, ResourceCache}; use crate::param_mirror::ParamMirror; +use crate::params_ext::GestureState; /// Everything this instance needs that has no reason to be tied to the plugin's `'a` lifetime. /// See this module's doc comment. pub(crate) struct SharedInner { pub(crate) params: ParamMirror, + /// Which parameters have a `ParamGestureBegin` outstanding with the host — see + /// [`crate::params_ext::GestureState`], which is the whole of the explanation. + pub(crate) gestures: GestureState, pub(crate) cache: Arc, pub(crate) pool: ThreadPool, pub(crate) instance: Mutex>, @@ -114,6 +118,10 @@ pub(crate) struct SharedInner { /// The sample rate `latency_samples` was measured at, or 0 if it has never been measured. /// [`Self::carried_latency`] is the only reader; see its doc comment. pub(crate) latency_basis_rate: AtomicU32, + /// Completed worker-side interactions with this instance's live engine — see + /// [`SharedInner::worker_instance_epoch`], which is the only reader and carries the whole + /// explanation. + worker_instance_epoch: AtomicU32, /// Set by the audio thread when `latency_samples` changed since it was last reported to the /// host; cleared once `on_main_thread` has acted on it. See `crate::audio`'s module doc /// comment for the full FR-CLAP-040 sequencing. @@ -194,6 +202,7 @@ impl SharedInner { fn with_library(library: Option) -> Self { Self { params: ParamMirror::new(), + gestures: GestureState::new(), cache: ResourceCache::shared(), pool: ThreadPool::new(), instance: Mutex::new(None), @@ -207,6 +216,7 @@ impl SharedInner { latency_samples: AtomicU32::new(0), latency_announced: AtomicU32::new(0), latency_basis_rate: AtomicU32::new(0), + worker_instance_epoch: AtomicU32::new(0), latency_dirty: AtomicBool::new(false), thread_priority_kind: AtomicU8::new(THREAD_PRIORITY_UNREPORTED), thread_priority_os_error: AtomicI64::new(0), @@ -343,6 +353,26 @@ impl SharedInner { lock(&self.notices).clone() } + /// A handle a pool job can block on until this instance's library index has actually been + /// read — [`namir_worker::library::LibraryService::loader`], taken under the lock but *used* + /// outside it. + /// + /// **Why any job needs this at all (#145).** M14 made the index load deferred, so + /// [`Self::library_snapshot`] hands back an empty `Index` for the first fraction of a second + /// of an instance's life. `LibraryService::start_scan` already blocks for the load inside its + /// own pool job for exactly that reason; `crate::worker_jobs`' `spawn_recall` and + /// `library_target` did not, and a host's `set_state` and every `activate` arrive within + /// milliseconds of `SharedInner::new` — so a preset's reference was resolved against nothing + /// and FR-STATE-070's hash candidate could never fire. `namir-app` has always called + /// `ensure_loaded()` before building its resolver (`crate::app`), so this was a shell-parity + /// divergence (FR-CFG-020) as well as a defect. + /// + /// `None` only when this instance has no library service at all — the same condition + /// [`Self::library_roots`] returns an empty list for. + pub(crate) fn library_loader(&self) -> Option { + lock(&self.library).as_ref().map(|service| service.loader()) + } + /// The library as the GUI sees it this frame — and the point at which the deferred load's /// warnings are reported (M14, see [`SharedInner::new`]). /// @@ -460,6 +490,14 @@ impl SharedInner { /// When either fails the caller adopts the engine's own reading and the ordinary /// change-detection path does the rest, at the cost of the one restart it was always going to /// cost. + /// + /// **Both conditions hold at the moment of the activation, and neither is a guarantee about + /// the replay's outcome** (issue #145's finding 8). The reference may name a file that has + /// since been deleted, or one whose content is now a session-rate model that adds no latency at + /// all; the replay then converges on zero and this figure is simply wrong. That is not + /// detectable here — it is only knowable once the replay has run — so the correction lives + /// where the replay's outcome can be observed: `crate::audio`'s `publish_latency`, which + /// carries this figure as a claim and retracts it against [`Self::worker_instance_epoch`]. pub(crate) fn carried_latency(&self, sample_rate_hz: u32) -> Option { let replay_pending = self.nam_ref().is_some() || self.ir_ref().is_some(); let basis = self.latency_basis_rate.load(Ordering::Relaxed); @@ -554,8 +592,50 @@ impl SharedInner { lock(&self.instance) } + /// Runs `f` against the live `Instance`, or answers `None` when there is none yet (not an + /// error — just "not activated"; see `crate::params_ext`'s main-thread `flush`). + /// + /// Every call that actually reached an `Instance` bumps [`Self::worker_instance_epoch`] on the + /// way out, which is how the audio thread learns that an off-thread interaction with *its* + /// engine has finished. See that method's doc comment. pub(crate) fn with_instance(&self, f: impl FnOnce(&mut Instance) -> R) -> Option { - self.lock_instance().as_mut().map(f) + let outcome = self.lock_instance().as_mut().map(f); + if outcome.is_some() { + // After the guard above has been dropped, so the epoch a reader observes is never + // ahead of the lock being free. + self.worker_instance_epoch.fetch_add(1, Ordering::Release); + } + outcome + } + + /// How many worker-side interactions with this instance's live engine have *completed*. + /// + /// **What this is for (issue #145's finding 8).** `crate::audio`'s `activate` may carry the + /// previous activation's latency figure across an activation ([`Self::carried_latency`]) on the + /// prediction that the replay it dispatches will converge on that figure again. The prediction + /// can be wrong — the model may have been deleted or replaced while the plugin was inactive — + /// and when it is, *nothing about the engine moves*: it reports zero before the replay and zero + /// after it, so a change detector comparing against the engine's own reading has nothing to + /// detect and the host is never told the figure it holds is stale. + /// + /// Telling the two apart needs one fact the audio thread cannot get from the engine: whether + /// the replay has finished. This counter is that fact. `crate::worker_jobs::spawn_recall`'s + /// last act is a [`Self::with_instance`] call, so an epoch different from the one `activate` + /// recorded means the replay has had its turn at the engine and whatever the engine reports now + /// is the outcome, not a transient. + /// + /// **Deliberately coarse, and stated as such.** It counts *every* completed `with_instance` + /// call, not only a replay's — a GUI-driven load or a main-thread parameter flush that reached + /// a live engine bumps it too. That over-approximates in the safe direction: the worst a + /// spurious bump can do is start `crate::audio`'s settle countdown early, and the countdown is + /// what absorbs the lag between a command being submitted and the handover crossfade finishing. + /// A missed bump would be the dangerous direction, and there is none — the counter only ever + /// moves forwards. + /// + /// Read from the audio thread as one relaxed atomic load per block (and only while a carried + /// figure is unconfirmed), which is wait-free and allocation-free. + pub(crate) fn worker_instance_epoch(&self) -> u32 { + self.worker_instance_epoch.load(Ordering::Acquire) } /// Installs a freshly built `Instance`, replacing whatever was there — every `activate()` @@ -936,4 +1016,43 @@ mod tests { evidence about the new configuration" ); } + + /// The counter `crate::audio` uses to know a replay has had its turn at the engine (issue + /// #145's finding 8): it moves for a call that reached a live `Instance`, and not for one that + /// found none. + #[test] + fn the_worker_instance_epoch_moves_only_when_a_job_actually_reached_the_engine() { + let inner = SharedInner::new(); + let start = inner.worker_instance_epoch(); + + assert_eq!( + inner.with_instance(|_| ()), + None, + "there is no engine before the first activate()" + ); + assert_eq!( + inner.worker_instance_epoch(), + start, + "a job that found no engine has told the audio thread nothing -- there is no audio \ + thread yet" + ); + + let ctx = namir_engine::PrepareContext::new( + namir_core::SampleRate::new(48_000).expect("48 kHz is a valid sample rate"), + 64, + namir_core::ChannelConfig::Stereo, + ) + .expect("the prepare context must build"); + let (_engine, endpoint) = + namir_engine::build_default_engine(&ctx).expect("the engine must build"); + inner.install_instance(Instance::new(namir_worker::EngineConfig { ctx }, endpoint)); + + assert_eq!(inner.with_instance(|_| 7), Some(7)); + assert_eq!( + inner.worker_instance_epoch(), + start + 1, + "a completed interaction with the live engine is exactly what the carried-latency \ + claim in `crate::audio` waits for" + ); + } } diff --git a/crates/namir-clap/src/worker_jobs.rs b/crates/namir-clap/src/worker_jobs.rs index 5864589..cb4cec0 100644 --- a/crates/namir-clap/src/worker_jobs.rs +++ b/crates/namir-clap/src/worker_jobs.rs @@ -35,13 +35,19 @@ pub(crate) fn spawn_load_library_entry(shared: Arc, path: PathBuf) } }; - let bytes = match std::fs::read(&path) { + // **`read_file_bounded`, not `std::fs::read` (#145).** This job reads the bytes itself + // because it needs their `ContentHash` for `record_reference` below (FR-STATE-060/-070), + // which `LoadSource::File` never hands back -- but `LoadSource::File` was also the only + // route through NFR-SEC-020's ceiling and through the `is_file()` check issue #107 added, + // so taking the bytes this way silently dropped both. The bound belongs to the read, not + // to the `LoadSource`; `namir_worker::read_file_bounded` is `pub` for this caller. + let bytes = match namir_worker::read_file_bounded(&path) { Ok(b) => b, + // Whole, with its own catalogue id: `read_file_bounded` already distinguishes + // unreadable from too-large from not-a-regular-file, and collapsing the three back + // into `FILE_UNREADABLE` would undo that (issue #39's rule at this boundary). Err(e) => { - shared.push_notice( - namir_worker::error_codes::FILE_UNREADABLE, - format!("{}: {e}", path.display()), - ); + shared.push_notice(e.code, e.detail); return; } }; @@ -194,13 +200,16 @@ pub(crate) fn spawn_recall_preset(shared: Arc, path: PathBuf) { let inner = Arc::clone(&shared); shared.pool.spawn(move || { let shared = inner; - let bytes = match std::fs::read(&path) { + // Bounded, like every other read either shell makes off a user-chosen path (#145). A + // `.namirpreset` path is exactly as untrusted as a library entry: `namir_state::Document:: + // parse` does enforce `MAX_DOCUMENT_BYTES`, but only once the whole file is already in + // memory -- the allocation NFR-SEC-020 exists to refuse -- and it says nothing at all + // about a path that is not a regular file, where a bare `std::fs::read` blocks this pool + // thread for as long as no writer appears. + let bytes = match namir_worker::read_file_bounded(&path) { Ok(bytes) => bytes, Err(e) => { - shared.push_notice( - namir_worker::error_codes::FILE_UNREADABLE, - format!("{}: {e}", path.display()), - ); + shared.push_notice(e.code, e.detail); return; } }; @@ -237,6 +246,20 @@ pub(crate) fn spawn_recall(shared: Arc) { if state.nam.is_none() && state.ir.is_none() { return; // Nothing to replay; the common case for a brand-new instance. } + // **The index has to be *there* before a resolver is built over it (#145).** M14 deferred + // the load, so `library_snapshot()` is an empty `Index` for the first fraction of a second + // of an instance's life -- and both callers of this function, a host `state` load and + // every `activate`, land inside that window. A resolver over an empty index cannot try + // FR-STATE-070's `library_relative` or hash candidates at all, so a preset whose `.nam` + // was renamed or moved *inside* the library reported `state.reference.not_found` on + // project load, and `namir_ui::push_deduplicated` then made the notice stick. This is the + // same block `LibraryService::start_scan` performs inside its own pool job, for the same + // reason and with the same cost: once per process, on a thread that is allowed to block. + // `namir-app` has always done it (`crate::app`), which made this an FR-CFG-020 divergence + // as well. + if let Some(loader) = shared.library_loader() { + loader.ensure_loaded(); + } let index = shared.library_snapshot().index; // **Issue #96: the real roots, off the `LibraryService` this instance already holds.** // This was a hardcoded `Vec::new()`, so `LibraryResolver::resolve_library_relative` could @@ -269,7 +292,17 @@ pub(crate) fn spawn_recall(shared: Arc) { }); } +/// Which stage the library entry at `path` belongs to, per the index's own recorded +/// [`namir_library::ItemKind`], or `None` if the library does not know this path. +/// +/// **Blocks for the deferred index load first (#145)** — see `spawn_recall` above for the full +/// argument. Without it a double-click in the first moments of an instance's life consulted an +/// empty index and was reported back to the user as "not a recognised library entry". Called only +/// from `spawn_load_library_entry`'s pool job, which is allowed to block. fn library_target(shared: &SharedInner, path: &Path) -> Option { + if let Some(loader) = shared.library_loader() { + loader.ensure_loaded(); + } let index = shared.library_snapshot().index; let entry = index.get(path)?; Some(match entry.kind { @@ -351,4 +384,153 @@ mod tests { let _ = std::fs::remove_dir_all(&config); } + + /// A saved index at the location [`namir_worker::library::LibraryService::open_at`] uses, + /// holding `count` entries, the last of which is `path`. + /// + /// **`count` is FR-LIB-020's own stated scale, and it is load-bearing rather than decorative.** + /// M14 made the index load deferred: `LibraryService::open` returns before the file has been + /// read, and `snapshot()` hands back an empty `Index` until a loader thread lands. A test that + /// wrote a three-entry index would race that thread and pass whether or not the code under + /// test blocks for the load. At 10 000 entries the parse is ~161 ms on the reference machine + /// (`namir_worker::library`'s own `Stamp` doc comment), against the microseconds between + /// `SharedInner::new_at` returning and the assertion below — so "the index was still loading" + /// is established by a five-order-of-magnitude margin rather than by hope. + fn saved_index_of(config: &Path, count: usize, path: &Path, hash: ContentHash) { + let mut index = namir_library::Index::empty(); + for i in 0..count.saturating_sub(1) { + index.upsert(namir_library::LibraryEntry { + path: config.join("Library").join(format!("filler-{i}.nam")), + kind: namir_library::ItemKind::Nam, + size: 2, + mtime: namir_library::FileTime::now(), + hash: Some(ContentHash::of(format!("filler-{i}").as_bytes())), + metadata: namir_library::ItemMetadata::None, + origin: namir_library::Origin::Local, + }); + } + index.upsert(namir_library::LibraryEntry { + path: path.to_path_buf(), + kind: namir_library::ItemKind::Nam, + size: 2, + mtime: namir_library::FileTime::now(), + hash: Some(hash), + metadata: namir_library::ItemMetadata::None, + origin: namir_library::Origin::Local, + }); + let (store, _, _) = namir_library::IndexStore::open(config.join("library-index.json")); + store.save_atomic(&index).unwrap(); + } + + /// FR-LIB-020's scale, and the reason this figure is here rather than inline — see + /// [`saved_index_of`]. + const INDEXED_ENTRIES: usize = 10_000; + + /// A sparse file one byte past NFR-SEC-020's ceiling — `set_len`, not 256 MiB of writes, since + /// the bound is checked against the file's length. + fn oversized_file(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let file = std::fs::File::create(path).unwrap(); + file.set_len(namir_worker::MAX_FILE_BYTES as u64 + 1) + .unwrap(); + } + + /// Waits for this instance to raise a notice, and returns the first one's catalogue id. Every + /// job in this module runs on the pool, so a test has to wait rather than assume. + fn wait_for_notice(shared: &SharedInner) -> String { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if let Some(notice) = shared.notices().first() { + return notice.code.id.to_string(); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("this instance raised no notice within the deadline"); + } + + /// **Issue #145, finding 1.** `library_target` consults the library index from a pool job, and + /// M14 made that index arrive asynchronously — so it has to block for the load the way + /// `LibraryService::start_scan`'s own job does, rather than reading whatever `snapshot()` + /// happens to hold. Without that, a double-click on a library entry in the first fraction of a + /// second of an instance's life resolves against an empty index and is reported back as "not a + /// recognised library entry". + #[test] + fn a_library_entry_resolves_while_the_deferred_index_load_is_still_in_flight() { + let config = temp_config_dir("deferred_target"); + let path = config.join("Library").join("jcm800.nam"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"{}").unwrap(); + saved_index_of(&config, INDEXED_ENTRIES, &path, ContentHash::of(b"{}")); + + let shared = SharedInner::new_at(&config); + let target = library_target(&shared, &path); + + assert_eq!( + target, + Some(Target::Nam), + "the index has this entry; a job that reads the not-yet-loaded snapshot instead \ + reports the user's own library file as unrecognised" + ); + + shared.shutdown_workers(); + let _ = std::fs::remove_dir_all(&config); + } + + /// **Issue #145, finding 2**, the library-entry half. This job reads the file itself — it needs + /// the bytes' `ContentHash` for FR-STATE-060/-070's `FileRef`, which `LoadSource::File` never + /// hands back — and that read has to be `namir_worker::read_file_bounded`. With a bare + /// `std::fs::read` an oversized file is pulled whole into memory and only then refused by a + /// parser, which is NFR-SEC-020's ceiling not being applied at all. + #[test] + fn an_oversized_library_entry_is_refused_before_it_is_read() { + let config = temp_config_dir("oversized_entry"); + let path = config.join("Library").join("huge.nam"); + oversized_file(&path); + saved_index_of(&config, 1, &path, ContentHash::of(b"")); + + let shared = Arc::new(SharedInner::new_at(&config)); + spawn_load_library_entry(Arc::clone(&shared), path); + + assert_eq!( + wait_for_notice(&shared), + namir_worker::error_codes::FILE_TOO_LARGE.id, + "an oversized library entry must be refused by NFR-SEC-020's ceiling, not read whole \ + into memory and then refused by a parser" + ); + // The pre-fix behaviour raised no notice at all: with no live `Instance` the job took its + // "nothing to load into yet" branch and recorded the 256 MiB file as this instance's model + // reference, so the next save would have written a preset naming it. + assert!( + shared.nam_ref().is_none(), + "a refused file must not be recorded as this instance's model reference" + ); + + shared.shutdown_workers(); + let _ = std::fs::remove_dir_all(&config); + } + + /// **Issue #145, finding 2**, the preset half: a `.namirpreset` path the user chose is exactly + /// as untrusted as a library entry. `namir_state::Document::parse` does enforce + /// `MAX_DOCUMENT_BYTES`, but only once the whole file is already in memory — the allocation + /// NFR-SEC-020 exists to refuse — and it says nothing at all about a path that is not a + /// regular file. + #[test] + fn an_oversized_preset_is_refused_before_it_is_read() { + let config = temp_config_dir("oversized_preset"); + let path = config.join("huge.namirpreset"); + oversized_file(&path); + + let shared = Arc::new(SharedInner::new_at(&config)); + spawn_recall_preset(Arc::clone(&shared), path); + + assert_eq!( + wait_for_notice(&shared), + namir_worker::error_codes::FILE_TOO_LARGE.id, + "an oversized preset must be refused before the read, not after `Document::parse` has \ + already been handed 256 MiB" + ); + + shared.shutdown_workers(); + let _ = std::fs::remove_dir_all(&config); + } } diff --git a/crates/namir-clap/tests/clap_host_latency.rs b/crates/namir-clap/tests/clap_host_latency.rs index 9bc07d4..2616f16 100644 --- a/crates/namir-clap/tests/clap_host_latency.rs +++ b/crates/namir-clap/tests/clap_host_latency.rs @@ -76,7 +76,7 @@ mod support; #[cfg(feature = "host-ext-tests")] mod host_ext { - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use clack_extensions::latency::PluginLatency; @@ -180,15 +180,45 @@ mod host_ext { } } - /// The bytes a host hands to `clap_plugin_state.load`. Built through the real - /// `State`/`Document` writers, so the base64 encoding is the format's own rather than this - /// file's (`namir-clap` has no `base64` dependency, and should not gain one for a test). - fn state_document_bytes(model: &[u8]) -> Vec { + /// The same reference as [`embedded_nam_reference`], but resolvable only through + /// FR-STATE-070's *second* candidate — an absolute path on disk, with nothing embedded to fall + /// back to. Used by the one test that needs the model to be able to *stop* resolving (issue + /// #145's finding 8): an embedded copy travels with the document and can never go missing. + fn external_nam_reference(model: &[u8], path: &Path) -> FileRef { + FileRef { + hash: ContentHash::of(model), + library_relative: None, + absolute: Some(path.to_string_lossy().into_owned()), + display_name: "fr-clap-040-external-44k1.nam".to_string(), + embedded: None, + } + } + + /// The bytes a host hands to `clap_plugin_state.load`, for a document naming `nam` and nothing + /// else. Built through the real `State`/`Document` writers, so the base64 encoding is the + /// format's own rather than this file's (`namir-clap` has no `base64` dependency, and should + /// not gain one for a test). + fn state_document_for(nam: FileRef) -> Vec { let mut state = State::defaults(); - state.nam = Some(embedded_nam_reference(model)); + state.nam = Some(nam); state.write_onto(&Document::empty()).to_pretty_bytes() } + fn state_document_bytes(model: &[u8]) -> Vec { + state_document_for(embedded_nam_reference(model)) + } + + /// A directory of this test binary's own, named for `label` and for the process, so two tests + /// (or two concurrent runs) never share one. Nothing here is under any library root — the + /// reference below is resolved by absolute path. + fn temp_dir(label: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("namir-clap-latency-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("the temporary directory must be creatable"); + dir + } + /// A resolver that finds nothing — every external candidate misses, exactly as it does inside /// the plugin (whose own resolver is a real `LibraryResolver` over an index that has never seen /// this synthetic model). @@ -426,6 +456,158 @@ mod host_ext { drop(instance); // `clap_plugin.destroy` } + /// **Issue #145's finding 8: a carried figure that the replay no longer produces.** + /// + /// `SharedInner::carried_latency` (issue #93) lets an activation keep reporting the figure the + /// host already has, on the reasoning that the replay this activation dispatches will converge + /// on it again. That reasoning is a *prediction*, and it can be wrong: the model the figure was + /// measured against can be gone by the time the replay looks for it. Nothing then moves the + /// engine's own reading — it is `0` before the replay and `0` after it — so the audio thread's + /// change detection, which compares against that reading and nothing else, stays silent, and + /// the host goes on compensating for a delay the chain does not have, for the rest of the + /// session. + /// + /// The reproduction is the reviewer's own scenario, with the model on disk rather than embedded + /// in the document (an embedded copy cannot go missing, which is exactly the property that + /// makes it useless here): converge on the 44.1 kHz model's latency, honour the restart the + /// plugin asks for, delete the file while the plugin is inactive, and reactivate. The reference + /// is untouched, so `carried_latency`'s two conditions are both still met and the stale figure + /// is carried onto a chain that will never produce it. + /// + /// Asserted the way `a_rate_mismatched_model_asks_for_one_restart_and_then_settles` asserts its + /// own bound: the correction must arrive, and it must also *terminate* — the restart it asks + /// for must be the last one. + #[test] + fn a_carried_latency_figure_is_corrected_when_the_replay_can_no_longer_produce_it() { + /// Blocks processed after the correction has landed, to give a plugin that is still + /// churning a chance to ask for another restart before this test concludes it has settled. + const SETTLE_BLOCKS: usize = 64; + + let model = model_json_bytes(); + let expected = engine_latency_for(&model); + assert!( + expected > 0, + "a {MODEL_RATE_HZ} Hz model in a {DEFAULT_SAMPLE_RATE} Hz engine must engage D-9.2's \ + resampler; with zero there is no carried figure for this test to correct" + ); + + let dir = temp_dir("carried"); + let path = dir.join("fr-clap-040-external-44k1.nam"); + std::fs::write(&path, &model).expect("the model file must be writable"); + let document = state_document_for(external_nam_reference(&model, &path)); + + let (_entry, mut instance) = instantiate_default(); + let latency = require_plugin_extension::(&mut instance); + let state = require_plugin_extension::(&mut instance); + + let mut processor = activate_default(&mut instance) + .start_processing() + .expect("processing must start"); + let mut bufs = StereoBuffers::default_size(); + let tone = sine_1k(bufs.max_frames(), DEFAULT_SAMPLE_RATE, AMPLITUDE); + bufs.fill_input(|_channel, frame| tone[frame]); + + // The same warm-up the test above runs, and for the same reason: the first `process()` of + // an instance may request a callback for D-13.2's thread-priority outcome. + for _ in 0..4 { + audio_section(|| bufs.process_block(&mut processor, BLOCK)) + .expect("a warm-up block must process"); + } + instance.access_shared_handler(|shared| shared.reset_request_counts()); + + // -- The model loads from its file, and the host is told what it costs ---------------- + let mut reader = document.as_slice(); + state + .load(&mut main_thread_handle(&mut instance), &mut reader) + .expect("the host-driven state load must succeed"); + process_until(&mut bufs, &mut processor, LIMB_TIMEOUT, "load", || { + instance.access_shared_handler(|shared| shared.callback_requests()) > 0 + }); + assert_eq!( + latency.get(&mut main_thread_handle(&mut instance)), + expected, + "the model must load through its absolute-path candidate -- with nothing embedded, a \ + miss here means the reference never resolved and the rest of this test is vacuous" + ); + instance.call_on_main_thread_callback(); + assert_eq!( + instance.access_shared_handler(|shared| shared.restart_requests()), + 1, + "the first latency change must produce exactly one restart request" + ); + + // -- The host honours the restart. The model disappears while it is inactive ----------- + let stopped = processor.stop_processing(); + instance.deactivate(stopped); + std::fs::remove_file(&path).expect("the model file must be removable"); + instance.access_shared_handler(|shared| shared.reset_request_counts()); + + let mut processor = activate_default(&mut instance) + .start_processing() + .expect("processing must restart"); + assert_eq!( + latency.get(&mut main_thread_handle(&mut instance)), + expected, + "issue #93: the activation carries the figure the host already has rather than the \ + fresh engine's transient zero. That is the starting point of this test, not its bug" + ); + + // -- The replay finds nothing, so the carried figure is wrong and must be retracted ----- + process_until( + &mut bufs, + &mut processor, + LIMB_TIMEOUT, + "correction", + || instance.access_shared_handler(|shared| shared.callback_requests()) > 0, + ); + instance.call_on_main_thread_callback(); + assert_eq!( + latency.get(&mut main_thread_handle(&mut instance)), + 0, + "the model the carried figure was measured against is gone and the chain resamples \ + nothing, so the plugin must correct the figure downwards -- otherwise the host \ + compensates {expected} samples for a passthrough chain for the rest of the session" + ); + assert_eq!( + instance.access_shared_handler(|shared| shared.restart_requests()), + 1, + "a latency the host has been told is wrong is exactly what a restart request is for" + ); + + // -- ...and honouring *that* restart asks for no further one --------------------------- + let stopped = processor.stop_processing(); + instance.deactivate(stopped); + instance.access_shared_handler(|shared| shared.reset_request_counts()); + let mut processor = activate_default(&mut instance) + .start_processing() + .expect("processing must restart a second time"); + assert_eq!( + latency.get(&mut main_thread_handle(&mut instance)), + 0, + "the corrected figure is the one the next activation carries" + ); + for _ in 0..SETTLE_BLOCKS { + audio_section(|| bufs.process_block(&mut processor, BLOCK)) + .expect("a settled block must process"); + if instance.access_shared_handler(|shared| shared.callback_requests()) > 0 { + instance.access_shared_handler(|shared| shared.reset_request_counts()); + instance.call_on_main_thread_callback(); + } + assert_eq!( + instance.access_shared_handler(|shared| shared.restart_requests()), + 0, + "the correction must settle, exactly as issue #93's own fix must: a plugin that \ + keeps asking to be restarted for a figure that is already right is the same loop \ + from the other side" + ); + } + + let stopped = processor.stop_processing(); + instance.deactivate(stopped); + drop(instance); // `clap_plugin.destroy` + let _ = std::fs::remove_dir_all(&dir); + } + /// Processes blocks until `done` returns `true`, returning how many it took. /// /// Panics with `label` once `timeout` elapses — the conditions this file waits on are all diff --git a/crates/namir-clap/tests/clap_host_sample_rates.rs b/crates/namir-clap/tests/clap_host_sample_rates.rs index f5bec4b..cac3e96 100644 --- a/crates/namir-clap/tests/clap_host_sample_rates.rs +++ b/crates/namir-clap/tests/clap_host_sample_rates.rs @@ -322,7 +322,7 @@ mod loaded { use clack_extensions::latency::PluginLatency; use clack_extensions::state::PluginState; - use clack_host::prelude::PluginInstance; + use clack_host::prelude::{PluginInstance, StartedPluginAudioProcessor}; use namir_core::ContentHash; use namir_fixtures::nam::{WaveNetShape, generate}; use namir_state::{Document, EmbeddedRef, FileRef, State}; @@ -376,6 +376,34 @@ mod loaded { /// Slept between landing polls, so the worker actually gets to run on a small box. const LANDING_POLL: Duration = Duration::from_millis(10); + /// Audio time a run of steady blocks must span before the chain counts as settled, in + /// milliseconds. **Rate-independent by construction, and that is the point.** A crossfade + /// changes the level monotonically across its own 20 ms, so at 192 kHz it spans some fifteen + /// 256-frame blocks and consecutive blocks inside it differ by only a few percent -- a + /// block-against-previous-block test would call that steady. A run required to span three + /// crossfades' worth of audio cannot sit inside one, whatever the rate, because it necessarily + /// contains the whole excursion. + const SETTLE_SPAN_MS: f64 = 60.0; + + /// Ceiling on the settle gate, in blocks. Reaching it means the output never stopped moving, + /// which the panic says. + const SETTLE_LIMIT: usize = 512; + + /// How far the loudest and quietest block in a candidate run may differ, as a fraction, and + /// still count as steady. + /// + /// **The gate reads each block's peak, not its RMS, and that choice is what makes this number + /// meaningful.** A block is not a whole number of 1 kHz cycles at any of these rates, and at + /// the top of [`RATES`] it is barely more than one -- 256 frames at 191 100 Hz is 1.34 cycles + /// -- so a *settled* tone's per-block RMS still swings between 0.1453 and 0.1606, i.e. **10.5%**, + /// purely from where the window happens to cut the waveform. That is a quarter of the +3 dB + /// (41%) excursion this gate exists to catch, which leaves no honest threshold between them. + /// Peak has no such term: every block at every rate here spans at least one full period of the + /// chain's output, which is periodic at the probe frequency however hard the model distorts it, + /// so a settled peak repeats to within the sampling grid's own ~0.01%. 5% is far above that and + /// far below 41%. + const SETTLE_TOLERANCE: f64 = 0.05; + /// The rate set. Both endpoints, the six standard rates, and two values off every grid. const RATES: [f64; 8] = [ 44_100.0, 45_100.0, 48_000.0, 88_200.0, 96_000.0, 176_400.0, 191_100.0, 192_000.0, @@ -420,6 +448,68 @@ mod loaded { latency: u32, } + /// Pumps the probe tone until the chain's own output stops moving, so the measurement window + /// that follows contains no handover. + /// + /// **Why the latency poll above is not enough (issue #145's finding 7).** Every activation + /// after the first dispatches *two* `spawn_recall` jobs -- `crate::audio`'s activate-time + /// replay and `state_ext`'s own at the end of `load` -- and the landing poll breaks on the + /// first nonzero latency, which only proves that *one* of them finished. The second installs + /// the same model at the same latency, so no latency reading can tell it apart from the first; + /// the only thing that changes when it lands is the audio. On a slow runner it landed inside + /// the warm-up or the measurement window and FR-NAM-070's equal-power crossfade averaged into + /// the reading: -1.76 dB when it straddled the window's start (the macOS CI failure, RMS + /// 0.12507376), and up to +3 dB when it sat wholly inside, since the two sides of that fade + /// are the *same* model. No engine change can bring either inside [`RMS_TOLERANCE_DB`] -- + /// equal-power is what FR-NAM-070 mandates -- so the gate is what has to get stricter. + /// + /// Blocks are separated by a [`LANDING_POLL`] so the worker pool actually gets scheduled + /// between them on a small box; the crossfade itself only advances as blocks are processed. + fn settle( + processor: &mut StartedPluginAudioProcessor, + bufs: &mut StereoBuffers, + rate: f64, + ) { + let span_blocks = ((SETTLE_SPAN_MS / 1000.0 * rate) / f64::from(BLOCK)).ceil() as usize; + let mut run: Vec = Vec::with_capacity(span_blocks + 1); + + for index in 0..SETTLE_LIMIT { + for channel in 0..CHANNELS { + fill_sine( + &mut bufs.input_mut(channel)[..BLOCK as usize], + SINE_FREQ_HZ, + rate, + AMPLITUDE, + index as u64 * u64::from(BLOCK), + ); + } + audio_section(|| bufs.process_block(processor, BLOCK)) + .unwrap_or_else(|e| panic!("a settling block at {rate} Hz must process: {e}")); + + let block_peak = f64::from(peak(&bufs.output(0)[..BLOCK as usize])); + + run.push(block_peak); + let low = run.iter().copied().fold(f64::INFINITY, f64::min); + let high = run.iter().copied().fold(0.0_f64, f64::max); + if low <= 0.0 || high - low > low * SETTLE_TOLERANCE { + // This block does not belong to the run the earlier ones were forming. Restart + // from it rather than from nothing -- it is itself a candidate first block. + run.clear(); + run.push(block_peak); + } + if run.len() >= span_blocks { + return; + } + std::thread::sleep(LANDING_POLL); + } + + panic!( + "at {rate} Hz the chain never held a steady level for {SETTLE_SPAN_MS} ms within \ + {SETTLE_LIMIT} blocks -- a handover is still running, so any measurement taken now \ + would average a crossfade rather than the settled chain" + ); + } + /// Activates `instance` at `rate`, loads a model declared at a rate that is *not* `rate`, waits /// for the handover, then measures a 1 kHz tone through the loaded chain. /// @@ -469,6 +559,8 @@ mod loaded { SlotResampler was never built and this rate proves nothing" ); + settle(&mut processor, bufs, rate); + let warmup_frames = (WARMUP_MS / 1000.0 * rate).ceil() as u64; let measure_frames = (MEASURE_MS / 1000.0 * rate).ceil() as u64; let total_frames = warmup_frames + measure_frames; diff --git a/crates/namir-worker/src/lib.rs b/crates/namir-worker/src/lib.rs index 8589e04..46db638 100644 --- a/crates/namir-worker/src/lib.rs +++ b/crates/namir-worker/src/lib.rs @@ -118,8 +118,21 @@ pub enum LoadSource { File(std::path::PathBuf), } -/// NFR-SEC-020's ceiling applied to one path, for every read this crate performs off disk — -/// [`LoadSource::File`] and [`recall`]'s candidate reads alike (issue #107). +/// NFR-SEC-020's ceiling applied to one path — **the one route either product shell may read a +/// user-named file through**, and every read this crate performs off disk: [`LoadSource::File`] +/// and [`recall`]'s candidate reads alike (issue #107). +/// +/// **`pub`, not `pub(crate)`, since #145.** `LoadSource::File` used to be the only door onto this +/// function, so a shell that needed the bytes *themselves* — to take their [`namir_core:: +/// ContentHash`] for FR-STATE-060/-070's `FileRef`, or to parse a `.namirpreset` document, neither +/// of which a `LoadSource` produces — had no way to ask for a bounded read and fell back to a bare +/// `std::fs::read`. That is not a smaller version of this check, it is none of it: a 4 GB `.wav` +/// under a library root was read whole into memory instead of being refused with +/// [`error_codes::FILE_TOO_LARGE`], and a named pipe or character device at that path blocked the +/// reading thread for as long as no writer appeared — on `namir-app`'s single worker thread, that +/// is every later `SaveState`/`ListPresets`/`RescanLibrary` queued behind it forever. Keeping the +/// check reachable only through `LoadSource::File` would have covered one of the four sites; the +/// other three do not load a resource at all. /// /// **One shape for both, not two.** `namir-library`'s `StdFs::read_file` reached this same shape /// through its own issue #70, and the argument is not crate-specific: the file *type* is checked @@ -133,7 +146,7 @@ pub enum LoadSource { /// early rejection, so a 4 GB WAV is refused without being read up to the ceiling first, and a /// capacity hint, so an ordinary load makes one allocation instead of growing through a dozen. /// Being wrong about either costs a reallocation; neither can let a byte past the bound. -pub(crate) fn read_file_bounded(path: &std::path::Path) -> Result, WorkerError> { +pub fn read_file_bounded(path: &std::path::Path) -> Result, WorkerError> { let display = path.display().to_string(); let meta = std::fs::metadata(path) .map_err(|e| WorkerError::new(error_codes::FILE_UNREADABLE, format!("{display}: {e}")))?; diff --git a/crates/namir-worker/src/library.rs b/crates/namir-worker/src/library.rs index 8cc1763..eec2c21 100644 --- a/crates/namir-worker/src/library.rs +++ b/crates/namir-worker/src/library.rs @@ -85,6 +85,25 @@ impl ScanHandle { } } +/// A `'static` handle onto one index file's deferred load — see [`LibraryService::loader`]. +/// +/// Deliberately carries nothing else: it is not a second way to read the index, only a way to wait +/// for the one the service already owns. +#[derive(Clone)] +pub struct IndexLoader { + shared: Arc, +} + +impl IndexLoader { + /// [`LibraryService::ensure_loaded`], from a caller that holds no `LibraryService`. + /// + /// **Blocks**, for as long as reading and parsing the index file takes. Never call it on an + /// audio thread or on a plugin's instantiation path; a worker-pool job is what it is for. + pub fn ensure_loaded(&self) { + self.shared.ensure_loaded(); + } +} + /// How one [`LibraryService::start_scan`] run ended. #[derive(Debug, Clone)] pub struct ScanOutcome { @@ -281,6 +300,22 @@ impl LibraryService { ) } + /// A cheap, `'static` handle onto this service's deferred load, for a caller that must block + /// for the index but may not hold the lock its own shell keeps the [`LibraryService`] behind. + /// + /// `namir-clap` is that caller (#145): its `SharedInner` holds the service in a `Mutex` the + /// GUI thread takes every frame for [`Self::snapshot`], so calling [`Self::ensure_loaded`] + /// through that lock would stall the editor for the whole parse — ~161 ms at FR-LIB-020's + /// 10 000 entries, which is precisely the frame stall M14 took off the instantiation path. + /// Taking this handle costs one `Arc` clone under that lock; the block then happens outside + /// it. Clone it freely: every handle for one index path names the same load, so a second + /// caller waits for the first rather than parsing again. + pub fn loader(&self) -> IndexLoader { + IndexLoader { + shared: Arc::clone(&self.shared), + } + } + /// Blocks until this process has the index file's contents, parsing it here if the loader /// thread has not got to it (or could not be spawned). /// From 58bd85a8c3fd80f7cfaf9112c54e908ed1fe4c58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 19:09:10 +0000 Subject: [PATCH 44/44] Wait for the recalled IR, not just the recalled parameter (Windows CI) `recalling_a_preset_puts_the_recalled_resource_name_on_screen` gated on the parameter and then asserted the IR name. The parameter is applied straight from the decoded document; the IR is a resource whose reload is a separate worker job, so the gate raced the assertion. Green on Linux, lost the race on Windows CI: left Some("fender.wav"), right Some("marshall.wav") -- the file the recall was meant to displace. Gate on both halves. `snapshot_until` returns the last snapshot after 4 s rather than panicking, so a recall that genuinely never restored the IR still fails the assertion, with its notices. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHVuZbYNsdWUfemA2tLP5N --- crates/namir-app/src/host.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/namir-app/src/host.rs b/crates/namir-app/src/host.rs index 0f24002..8ea726a 100644 --- a/crates/namir-app/src/host.rs +++ b/crates/namir-app/src/host.rs @@ -1899,7 +1899,17 @@ mod tests { assert_eq!(snapshot.loaded_ir_name.as_deref(), Some("fender.wav")); host.dispatch(UiIntent::RecallPreset { path }); - let snapshot = snapshot_until(&mut host, |s| s.params.get(key) == Some(-18.0)); + // Gate on *both* halves of the recall. The parameter is applied straight from the decoded + // document, but the IR is a resource: reloading it is a separate worker job that finishes + // later, so waiting on the parameter alone races the thing this test is actually about. + // Observed failing on Windows CI, where the slower filesystem lost that race and the + // snapshot still showed `fender.wav` -- the file the recall was meant to displace. + // `snapshot_until` gives up after 4 s and returns the last snapshot rather than panicking, + // so a recall that genuinely never restored the IR still fails on the second assertion + // below, carrying its notices. + let snapshot = snapshot_until(&mut host, |s| { + s.params.get(key) == Some(-18.0) && s.loaded_ir_name.as_deref() == Some("marshall.wav") + }); assert_eq!( snapshot.params.get(key), Some(-18.0),