Skip to main content

forge_workspace/
bench.rs

1//! Benching a model on this machine's own material: which models a run can
2//! load, the config each one runs under, and the state and results the
3//! page reads.
4//!
5//! The corpus and the runner are `forge-dictate`'s; this module is the
6//! workspace's half - it knows the roles, the installed set and the store.
7
8use std::path::Path;
9use std::sync::Arc;
10use std::sync::atomic::Ordering;
11
12use forge_dictate::ModelSpec;
13
14use crate::dictate::SetRecording;
15use crate::{DispatchError, Workspace};
16
17/// Which slot a bench target runs in, in the names the wire uses.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BenchRole {
21    Transcribing,
22    Cleanup,
23}
24
25/// One model a bench can load.
26#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub struct BenchTarget {
28    pub file: String,
29    pub role: BenchRole,
30    /// Whether `forge.toml` pins the role this target would take - the
31    /// page offers no activation for one, and a bench still runs.
32    pub pinned: bool,
33}
34
35/// Where the last bench got to, as the page draws it.
36#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
37#[serde(tag = "state", rename_all = "snake_case")]
38pub enum BenchState {
39    #[default]
40    Idle,
41    /// `clip` of `clips` is counting clips done, and `so_far` is the share
42    /// of the compared ones that agreed word-for-word so far, when any
43    /// clip carried something to compare.
44    Running {
45        target: BenchTarget,
46        tier: forge_dictate::bench::Tier,
47        clip: usize,
48        clips: usize,
49        so_far: Option<f64>,
50    },
51    /// The run stopped with a reason. Nothing was saved.
52    Failed { target: BenchTarget, reason: String },
53}
54
55/// What one finished bench measured, kept under what it was about.
56#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
57pub struct BenchResult {
58    pub target: BenchTarget,
59    pub tier: forge_dictate::bench::Tier,
60    pub metrics: forge_dictate::bench::Metrics,
61    /// RFC 3339.
62    pub at: String,
63    /// The corpus it ran over, so two numbers are only compared when these
64    /// agree.
65    pub corpus: forge_dictate::bench::CorpusId,
66}
67
68/// Where the speaking bench got to, as the page draws it.
69#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
70#[serde(tag = "state", rename_all = "snake_case")]
71pub enum SpeakingBenchState {
72    #[default]
73    Idle,
74    /// `clip` of `clips` transcripts have been spoken, heard back and
75    /// scored.
76    Running { clip: usize, clips: usize },
77    /// The run stopped with a reason. Nothing was saved.
78    Failed { reason: String },
79}
80
81/// What one speaking bench measured, kept under the model and corpus it ran.
82#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
83pub struct SpeakingBenchResult {
84    /// The speaking model's file, as the page names it.
85    pub file: String,
86    pub metrics: forge_tts::bench::TtsBenchResult,
87    /// RFC 3339.
88    pub at: String,
89    /// The corpus it ran over, on the same terms as the words bench's.
90    pub corpus: forge_dictate::bench::CorpusId,
91}
92
93/// What the live roles run on this machine, for a bench that runs outside
94/// forge.
95#[cfg(any(test, feature = "testing"))]
96#[derive(Debug)]
97pub struct LiveRoles {
98    /// The model each role runs, in role order, with where the choice came
99    /// from.
100    pub models: Vec<(crate::dictate::DictateRole, crate::install::ActiveModel)>,
101    /// The engine configuration those roles describe, as the boot builds it.
102    pub config: forge_dictate::Config,
103    /// Where the weights live: the configured directory, else the platform
104    /// cache directory.
105    pub models_dir: std::path::PathBuf,
106}
107
108/// A copy of a store another process holds, removed when it goes.
109#[cfg(any(test, feature = "testing"))]
110struct StoreCopy(std::path::PathBuf);
111
112#[cfg(any(test, feature = "testing"))]
113impl StoreCopy {
114    fn take(live: &Path) -> Result<Self, String> {
115        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
116        let path = std::env::temp_dir().join(format!(
117            "forge-store-{}-{}.redb",
118            std::process::id(),
119            NEXT.fetch_add(1, Ordering::Relaxed)
120        ));
121        std::fs::copy(live, &path).map_err(|error| {
122            format!(
123                "the store at {} could not be read ({error}); has forge run with this config dir \
124                 yet?",
125                live.display()
126            )
127        })?;
128        Ok(Self(path))
129    }
130}
131
132#[cfg(any(test, feature = "testing"))]
133impl Drop for StoreCopy {
134    fn drop(&mut self) {
135        let _ = std::fs::remove_file(&self.0);
136    }
137}
138
139/// The models the live roles resolve to, read by a process that is not
140/// forge.
141///
142/// redb takes an exclusive lock, so a running forge holds the live store; the
143/// rows are read off a copy. On macOS the copy is an APFS clone, so it is a
144/// point-in-time snapshot; a plain byte copy racing a commit can land on the
145/// previous one, so these answers are the store as of the copy rather than
146/// what a running engine holds mid-activation. A role whose config key names
147/// a variant this machine has not installed is an error rather than a feed
148/// lookup: the caller is a bench, and a bench fetches nothing.
149#[cfg(any(test, feature = "testing"))]
150pub fn resolved_live_roles(config_dir: &Path, app_support: &Path) -> Result<LiveRoles, String> {
151    let loaded = crate::config::load_from_dir(config_dir)
152        .map_err(|error| format!("forge.toml could not be read: {error}"))?;
153    let settings = &loaded.dictate;
154    if !settings.enabled {
155        return Err("[dictate] enabled = false: dictation runs no role on this machine, so the \
156                    bench has nothing to measure"
157            .to_owned());
158    }
159    let Some(models_dir) = settings.models_dir() else {
160        return Err("no models directory resolves: [dictate] models_dir is unset and there is no \
161                    platform cache directory"
162            .to_owned());
163    };
164
165    let live = crate::store::path_for(app_support, config_dir);
166    let copy = StoreCopy::take(&live)?;
167    let db = crate::store::Db::open(&copy.0).map_err(|error| {
168        format!(
169            "the store at {} could not be read through a copy at {}: {error}",
170            live.display(),
171            copy.0.display()
172        )
173    })?;
174    let installed = crate::store::dictate_models::installed(&db).map_err(|error| {
175        format!("the installed models could not be read from {}: {error}", live.display())
176    })?;
177
178    let mut models = Vec::new();
179    for (role, pin) in settings.model_specs() {
180        let configured = match role {
181            crate::dictate::DictateRole::Transcribing => settings.transcribe_model.as_deref(),
182            crate::dictate::DictateRole::Normalization => settings.cleanup_model.as_deref(),
183            // The bench walks the dictate roles; speaking has its own run.
184            crate::dictate::DictateRole::Speaking => None,
185        };
186        // The pick is read only when the key does not decide, as the boot
187        // does: a row that cannot matter must not fail the bench.
188        let pick = if configured.is_none() {
189            crate::store::dictate_models::active(&db, role).map_err(|error| {
190                format!(
191                    "the {} row could not be read from {}: {error}",
192                    role.label(),
193                    live.display()
194                )
195            })?
196        } else {
197            None
198        };
199        match crate::install::resolve_role(role, pin, configured, &installed, pick) {
200            crate::install::RoleResolution::Chosen(model) => models.push((role, model)),
201            crate::install::RoleResolution::NeedsFeed { key, variant } => {
202                return Err(format!(
203                    "[dictate] {key} names {variant}, which this machine has not installed; the \
204                     bench measures what a live role runs and never fetches"
205                ));
206            }
207        }
208    }
209
210    let config = crate::dictate::preflight_config(settings, &models);
211    Ok(LiveRoles { models, config, models_dir })
212}
213
214/// How many saved results the page reads.
215const RESULTS_SHOWN: usize = 50;
216
217/// One recording of the read-aloud passage, as the page lists it: what the
218/// row draws and what the bench scores, one row per clip.
219#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220pub struct ReadAloudRecording {
221    /// The take's own directory name, which is what the page deletes by.
222    pub id: String,
223    pub duration_ms: u64,
224    /// The wav's own byte length, which is what the recording costs on disk.
225    pub bytes: u64,
226    /// The wav's sha256, lowercase hex: the recording's identity, and what
227    /// two recordings of the same reading share.
228    pub sha256: String,
229    /// RFC 3339, off the stamp the recording is named by.
230    pub at: String,
231}
232
233/// The read-aloud set as the page reads it: the recordings this machine has,
234/// whether one is being recorded right now, the passage they are read from,
235/// the terms a run scores them on, and the last recording's failure when
236/// there is one - a write that failed after the stop has no dispatch left to
237/// answer, so the read carries it.
238#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct ReadAloudState {
240    /// Oldest first, so a page that appends draws a list that grows down.
241    pub recordings: Vec<ReadAloudRecording>,
242    pub recording: bool,
243    pub error: Option<String>,
244    pub passage: String,
245    /// The words of the passage a run scores term accuracy on - the figure
246    /// only this corpus can produce.
247    pub terms: Vec<String>,
248}
249
250/// The set directory's recordings, oldest first, read off the take
251/// directories themselves: the name carries the stamp, `meta.json` the
252/// length the recorder wrote, and the wav its size and its own sha. A
253/// directory without its meta is one the store was interrupted writing, and
254/// is skipped rather than listed as a clip the bench would then skip too.
255fn read_aloud_recordings(dir: &Path) -> Vec<ReadAloudRecording> {
256    let Ok(entries) = std::fs::read_dir(dir) else {
257        return Vec::new();
258    };
259    let mut names: Vec<String> = entries
260        .flatten()
261        .map(|entry| entry.file_name().to_string_lossy().into_owned())
262        .filter(|name| {
263            name.strip_prefix("take-").is_some_and(|rest| {
264                !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit())
265            })
266        })
267        .collect();
268    names.sort();
269
270    names
271        .into_iter()
272        .filter_map(|id| {
273            let millis = id.strip_prefix("take-")?.parse::<i64>().ok()?;
274            let take = dir.join(&id);
275            let meta = std::fs::read_to_string(take.join("meta.json")).ok()?;
276            let meta: serde_json::Value = serde_json::from_str(&meta).ok()?;
277            let wav = take.join("output.wav");
278            let at = time::OffsetDateTime::from_unix_timestamp(millis.checked_div(1000)?)
279                .ok()?
280                .format(&time::format_description::well_known::Rfc3339)
281                .ok()?;
282            Some(ReadAloudRecording {
283                id,
284                duration_ms: meta.get("duration_ms")?.as_u64()?,
285                bytes: std::fs::metadata(&wav).ok()?.len(),
286                sha256: meta.get("sha256")?.as_str()?.to_owned(),
287                at,
288            })
289        })
290        .collect()
291}
292
293/// The config one target runs under: the base with the target's file in
294/// its role's slot.
295///
296/// The spec carries no URL and no digest - a bench loads what is already
297/// on disk and never fetches - and the size is the file's own, so the
298/// engine's check is against the bytes that are there.
299pub fn config_for(
300    base: &forge_dictate::Config,
301    models_dir: &Path,
302    target: &BenchTarget,
303) -> Result<forge_dictate::Config, forge_dictate::Error> {
304    let path = models_dir.join(&target.file);
305    let size = std::fs::metadata(&path)
306        .map_err(|source| forge_dictate::Error::Io { path: path.clone(), source })?
307        .len();
308    let spec = ModelSpec {
309        file: target.file.clone(),
310        url: String::new(),
311        size,
312        sha256: None,
313        facts: forge_dictate::ModelFacts::default(),
314    };
315
316    let mut cfg = base.clone();
317    // **A bench run is not the user's dictation.** Its clips would otherwise
318    // land in the per-take diagnostics store - filling the shelf the bench
319    // itself scores against, so every run grew its own corpus.
320    cfg.diagnostics_dir = None;
321    match target.role {
322        BenchRole::Transcribing => cfg.asr_model = spec,
323        BenchRole::Cleanup => cfg.normalizer = Some(spec),
324    }
325    Ok(cfg)
326}
327
328impl Workspace {
329    /// The bench's state, for the page's read.
330    pub fn dictate_bench(&self) -> BenchState {
331        self.dictate_bench.lock().clone()
332    }
333
334    /// The directory the read-aloud set lives under, beside forge's other
335    /// machine-local stores; `None` when no app-support dir resolves. A
336    /// test points it at its own dir rather than the real one.
337    fn read_aloud_dir(&self) -> Option<std::path::PathBuf> {
338        #[cfg(any(test, feature = "testing"))]
339        if let Some(dir) = self.test_read_aloud_dir.lock().clone() {
340            return Some(dir);
341        }
342        forge_sdk::app_support_dir().ok().map(|dir| dir.join("dictate-read-aloud"))
343    }
344
345    /// The read-aloud set, as the page draws it: the recordings this machine
346    /// has, whether one is being recorded right now, the passage they are
347    /// read from, and the last write's failure when there was one.
348    pub fn read_aloud_state(&self) -> ReadAloudState {
349        let dir = self.read_aloud_dir();
350        let passage = forge_dictate::bench::READ_ALOUD_PASSAGE.to_owned();
351        ReadAloudState {
352            recordings: dir.as_deref().map(read_aloud_recordings).unwrap_or_default(),
353            recording: self.dictate_runtime.lock().set_recording.is_some(),
354            error: self.read_aloud_error.lock().clone(),
355            terms: forge_dictate::bench::terms(&passage),
356            passage,
357        }
358    }
359
360    /// Drop one recording from the read-aloud set, by the id the page read.
361    ///
362    /// The id is a directory name this server wrote, and it is checked
363    /// against that shape before anything is removed: a page-supplied path
364    /// must never name a directory outside the set.
365    pub(crate) fn delete_read_aloud(&self, id: &str) -> Result<(), DispatchError> {
366        let Some(dir) = self.read_aloud_dir() else {
367            return Err(DispatchError::ReadAloudUnavailable {
368                reason: "no app-support directory resolves".to_owned(),
369            });
370        };
371        let keep_pattern =
372            |rest: &str| !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit());
373        if !id.strip_prefix("take-").is_some_and(keep_pattern) {
374            return Err(DispatchError::ReadAloudUnavailable {
375                reason: format!("{id} is not a recording this set holds"),
376            });
377        }
378        let path = dir.join(id);
379        if let Err(error) = std::fs::remove_dir_all(&path)
380            && error.kind() != std::io::ErrorKind::NotFound
381        {
382            tracing::warn!(%error, id, "read-aloud: the recording was not deleted");
383            return Err(DispatchError::ReadAloudUnavailable { reason: error.to_string() });
384        }
385        Ok(())
386    }
387
388    /// Begin recording the read-aloud set: the page's own microphone feeds
389    /// the frames, and no engine and no transcript are involved - the
390    /// passage's words are known, so recording transcribes nothing.
391    ///
392    /// One capture at a time, like every other: refused while a take is
393    /// live, and while a recording is already running - the refusal names
394    /// whatever holds it.
395    pub(crate) fn start_read_aloud(&self, initiator: Option<u64>) -> Result<(), DispatchError> {
396        if !self.config_arc().dictate.enabled {
397            return Err(DispatchError::DictateOff);
398        }
399        let mut runtime = self.dictate_runtime.lock();
400        if let Some(holder) = runtime.live_holder() {
401            return Err(DispatchError::TakeLive { holder });
402        }
403        *self.read_aloud_error.lock() = None;
404        runtime.set_recording = Some(SetRecording { initiator, samples: Vec::new() });
405        Ok(())
406    }
407
408    /// Feed a client's frames into the running recording, when that
409    /// connection is the one that started it.
410    ///
411    /// The audio is capped at the same span a take holds, so a recording
412    /// nobody stops cannot grow without bound; the frames past the cap are
413    /// dropped rather than silently stored as a shorter reading.
414    pub fn read_aloud_push(&self, samples: &[f32], initiator: Option<u64>) -> bool {
415        let mut runtime = self.dictate_runtime.lock();
416        let Some(recording) = runtime.set_recording.as_mut() else {
417            return false;
418        };
419        if recording.initiator != initiator {
420            return false;
421        }
422        let cap = usize::try_from(self.config_arc().dictate.max_capture_minutes)
423            .unwrap_or(usize::MAX)
424            .saturating_mul(60)
425            .saturating_mul(usize::try_from(forge_dictate::SAMPLE_RATE).unwrap_or(16_000));
426        let room = cap.saturating_sub(recording.samples.len());
427        let take = room.min(samples.len());
428        recording.samples.extend_from_slice(&samples[..take]);
429        take > 0
430    }
431
432    /// Stop the recording: `keep` writes it as the set, and a stop that does
433    /// not keeps nothing. The write runs off the runtime thread, and its
434    /// failure lands in the read rather than in this answer - the dispatch
435    /// has already gone by the time the bytes hit the disk.
436    pub(crate) fn finish_read_aloud(
437        self: &Arc<Self>,
438        keep: bool,
439        initiator: Option<u64>,
440    ) -> Result<(), DispatchError> {
441        let samples = {
442            let mut runtime = self.dictate_runtime.lock();
443            let started_by_this_connection = runtime
444                .set_recording
445                .as_ref()
446                .is_some_and(|recording| recording.initiator == initiator);
447            if !started_by_this_connection {
448                return Err(DispatchError::ReadAloudNotRecording);
449            }
450            let Some(recording) = runtime.set_recording.take() else {
451                return Err(DispatchError::ReadAloudNotRecording);
452            };
453            recording.samples
454        };
455        if !keep {
456            return Ok(());
457        }
458        let Some(dir) = self.read_aloud_dir() else {
459            *self.read_aloud_error.lock() = Some("no app-support directory resolves".to_owned());
460            return Ok(());
461        };
462        if samples.is_empty() {
463            *self.read_aloud_error.lock() = Some("nothing was captured".to_owned());
464            return Ok(());
465        }
466
467        let this = Arc::clone(self);
468        tokio::task::spawn_blocking(move || {
469            let outcome = forge_dictate::bench::store_read_aloud(&dir, &samples);
470            match outcome {
471                Ok(()) => {}
472                Err(error) => {
473                    tracing::warn!(
474                        event_name = "read_aloud_write_failed",
475                        %error,
476                        dir = %dir.display(),
477                        "the read-aloud set was not written"
478                    );
479                    *this.read_aloud_error.lock() = Some(error.to_string());
480                }
481            }
482            this.push_models();
483        });
484        Ok(())
485    }
486
487    /// Every saved result, newest first, capped at what the page draws.
488    pub fn bench_results(&self) -> Vec<BenchResult> {
489        let db = self.db.lock();
490        db.as_ref()
491            .map(|db| {
492                let mut rows = crate::store::bench_results::results(db).unwrap_or_default();
493                rows.truncate(RESULTS_SHOWN);
494                rows
495            })
496            .unwrap_or_default()
497    }
498
499    /// Run one model over one tier's corpus on this machine's own material.
500    ///
501    /// Refused while a take is live (the microphone is the machine's, and a
502    /// bench would hold a second engine through it) and while another bench
503    /// runs - one at a time, like a download. The outcome rides
504    /// [`SessionUpdate::DictateModelsChanged`](crate::SessionUpdate::DictateModelsChanged),
505    /// the way every other dictate action's does.
506    pub(crate) fn start_bench(
507        self: &Arc<Self>,
508        target: BenchTarget,
509        tier: forge_dictate::bench::Tier,
510    ) -> Result<(), DispatchError> {
511        if !self.config_arc().dictate.enabled {
512            return Err(DispatchError::DictateOff);
513        }
514        self.claim_bench()?;
515        if let Some(holder) = self.dictate_runtime.lock().live_holder() {
516            self.release_bench();
517            return Err(DispatchError::TakeLive { holder });
518        }
519        *self.dictate_bench.lock() =
520            BenchState::Running { target: target.clone(), tier, clip: 0, clips: 0, so_far: None };
521        self.dictate_bench_cancel.store(false, Ordering::Relaxed);
522
523        let this = Arc::clone(self);
524        tokio::spawn(async move {
525            let runner = Arc::clone(&this);
526            runner.run_bench(target, tier).await;
527            this.push_models();
528        });
529        Ok(())
530    }
531
532    /// Drop one saved result: the row the page asked to delete, by its key.
533    pub(crate) fn delete_bench_result(
534        &self,
535        target: &BenchTarget,
536        tier: forge_dictate::bench::Tier,
537        corpus: &str,
538    ) -> Result<(), DispatchError> {
539        let db = self.db.lock();
540        if let Some(db) = db.as_ref() {
541            let key =
542                crate::store::bench_results::key_parts(target.role, &target.file, tier, corpus);
543            if let Err(error) = crate::store::bench_results::remove(db, &key) {
544                tracing::warn!(
545                    event_name = "dictate_bench_delete_failed",
546                    %error,
547                    "the saved result was not deleted"
548                );
549            }
550        }
551        Ok(())
552    }
553
554    /// Stop the run in flight. What it measured so far is discarded rather
555    /// than saved: a partial corpus is not a result.
556    pub(crate) fn stop_bench(&self) -> Result<(), DispatchError> {
557        if !matches!(*self.dictate_bench.lock(), BenchState::Running { .. }) {
558            return Err(DispatchError::BenchNotRunning);
559        }
560        self.dictate_bench_cancel.store(true, Ordering::Relaxed);
561        Ok(())
562    }
563
564    /// Whether a bench holds the machine, which is what a swap waits on.
565    /// Both benches count: each loads its own engines, and a swap under
566    /// either would pull a model out from under a run.
567    pub(crate) fn bench_running(&self) -> bool {
568        self.bench_claim.load(Ordering::SeqCst)
569            || matches!(*self.dictate_bench.lock(), BenchState::Running { .. })
570            || matches!(*self.speaking_bench.lock(), SpeakingBenchState::Running { .. })
571    }
572
573    /// Take the one bench slot, or answer that another run holds it. The
574    /// claim is a single atomic, so two presses cannot both pass the check
575    /// and load two engines.
576    fn claim_bench(&self) -> Result<(), DispatchError> {
577        if self.bench_running() {
578            return Err(DispatchError::BenchRunning);
579        }
580        self.bench_claim
581            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
582            .map(|_| ())
583            .map_err(|_| DispatchError::BenchRunning)
584    }
585
586    fn release_bench(&self) {
587        self.bench_claim.store(false, Ordering::SeqCst);
588    }
589
590    /// The speaking bench's state, for the page's read.
591    pub fn speaking_bench(&self) -> SpeakingBenchState {
592        self.speaking_bench.lock().clone()
593    }
594
595    /// Every saved speaking result, newest first, capped like the words
596    /// bench's list.
597    pub fn speaking_bench_results(&self) -> Vec<SpeakingBenchResult> {
598        let db = self.db.lock();
599        db.as_ref()
600            .map(|db| {
601                let mut rows = crate::store::speaking_results::results(db).unwrap_or_default();
602                rows.truncate(RESULTS_SHOWN);
603                rows
604            })
605            .unwrap_or_default()
606    }
607
608    /// Score the speaking loop on the public corpus: synthesize each
609    /// transcript, hear it back through the settled recognizer, and record
610    /// the round trip. Refused while a take is live or another bench runs -
611    /// one at a time, like the words bench.
612    ///
613    /// The run gets a thread of its own with a current-thread runtime: it
614    /// awaits the engine's chunks and blocks on recognition, so it must not
615    /// hold a worker from the server's runtime for minutes.
616    pub(crate) fn start_speaking_bench(self: &Arc<Self>) -> Result<(), DispatchError> {
617        let config = self.config_arc();
618        if !config.tts.enabled {
619            return Err(DispatchError::VoiceOff { reason: "[tts] enabled is false".to_owned() });
620        }
621        if !config.dictate.enabled {
622            return Err(DispatchError::VoiceOff {
623                reason: "[dictate] enabled is false, so the recognizer half cannot run".to_owned(),
624            });
625        }
626        self.claim_bench()?;
627        if let Some(holder) = self.dictate_runtime.lock().live_holder() {
628            self.release_bench();
629            return Err(DispatchError::TakeLive { holder });
630        }
631        *self.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: 0 };
632        self.speaking_bench_cancel.store(false, Ordering::Relaxed);
633
634        let this = Arc::clone(self);
635        let spawned = std::thread::Builder::new().name("forge-tts-bench".into()).spawn(move || {
636            let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
637                Ok(runtime) => runtime,
638                Err(error) => {
639                    this.settle_speaking_bench(Err(format!(
640                        "the bench runtime did not build: {error}"
641                    )));
642                    this.push_models();
643                    return;
644                }
645            };
646            runtime.block_on(Arc::clone(&this).run_speaking_bench());
647            this.push_models();
648        });
649        if let Err(error) = spawned {
650            self.release_bench();
651            *self.speaking_bench.lock() = SpeakingBenchState::Failed {
652                reason: format!("the bench thread did not start: {error}"),
653            };
654        }
655        Ok(())
656    }
657
658    /// Stop the speaking run in flight. What it measured so far is
659    /// discarded rather than saved: a partial corpus is not a result.
660    pub(crate) fn stop_speaking_bench(&self) -> Result<(), DispatchError> {
661        if !matches!(*self.speaking_bench.lock(), SpeakingBenchState::Running { .. }) {
662            return Err(DispatchError::BenchNotRunning);
663        }
664        self.speaking_bench_cancel.store(true, Ordering::Relaxed);
665        Ok(())
666    }
667
668    /// Gather the public corpus, load both engines, and run the loop.
669    async fn run_speaking_bench(self: Arc<Self>) {
670        let settings = self.config_arc().dictate.clone();
671        let active = self.active_models();
672        let cfg = crate::dictate::preflight_config(&settings, &active);
673        let Some(models_dir) = settings.models_dir() else {
674            return self.settle_speaking_bench(Err("no models directory is configured".to_owned()));
675        };
676        let takes_dir = cfg.diagnostics_dir.clone().unwrap_or_else(|| models_dir.clone());
677        let read_aloud_dir = self.read_aloud_dir().unwrap_or_else(|| takes_dir.clone());
678        // The bundled public slice lives beside the models, installed on
679        // first use, as the asset both directions read.
680        let public_dir = models_dir.join("public-corpus");
681        if let Err(error) = forge_dictate::bench::install_public_corpus(&models_dir) {
682            return self.settle_speaking_bench(Err(format!(
683                "the bundled public corpus could not be installed: {error}"
684            )));
685        }
686
687        let corpus = match forge_dictate::bench::corpus(
688            forge_dictate::bench::Tier::Public,
689            &takes_dir,
690            &read_aloud_dir,
691            &public_dir,
692        ) {
693            Ok(corpus) => corpus,
694            Err(error) => return self.settle_speaking_bench(Err(error.to_string())),
695        };
696        let clips: Vec<forge_tts::bench::BenchClip> = corpus
697            .clips
698            .iter()
699            .filter_map(|clip| clip.truth.clone())
700            .map(|transcript| forge_tts::bench::BenchClip { transcript })
701            .collect();
702        if clips.is_empty() {
703            return self.settle_speaking_bench(Err(
704                "nothing to bench: the bundled public corpus holds no clips".to_owned(),
705            ));
706        }
707        let total = clips.len();
708        *self.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: total };
709
710        let Some(files) = self.speaking_files() else {
711            return self.settle_speaking_bench(Err(
712                "the speaking model is not installed; install it from the models page".to_owned(),
713            ));
714        };
715        let engine = match forge_tts::KokoroTts::load(&files) {
716            Ok(engine) => engine,
717            Err(error) => return self.settle_speaking_bench(Err(error.to_string())),
718        };
719        let recognizer = match forge_dictate::Engine::new(cfg) {
720            Ok(engine) => engine,
721            Err(error) => return self.settle_speaking_bench(Err(error.to_string())),
722        };
723        if let Err(error) = recognizer.wait_ready() {
724            return self.settle_speaking_bench(Err(error.to_string()));
725        }
726
727        // A recognizer failure is the bench's failure, not a clip the model
728        // said nothing on: the first one is kept and the run settles with
729        // it, so a broken pair never reads as a perfect score.
730        let heard_error: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
731        let transcribe = |pcm: &[f32], rate: u32| {
732            let record = |said: String| {
733                if let Ok(mut slot) = heard_error.lock()
734                    && slot.is_none()
735                {
736                    *slot = Some(said);
737                }
738                String::new()
739            };
740            let resampled =
741                match forge_dictate::resample_mono(pcm, rate, forge_dictate::SAMPLE_RATE) {
742                    Ok(resampled) => resampled,
743                    Err(error) => return record(error.to_string()),
744                };
745            match recognizer
746                .transcribe(forge_dictate::Samples::mono(resampled))
747                .and_then(forge_dictate::Ticket::recv)
748            {
749                Ok(forge_dictate::Outcome::Transcript(transcript)) => transcript.text,
750                Ok(other) => record(format!("a clip produced no transcript: {other:?}")),
751                Err(error) => record(error.to_string()),
752            }
753        };
754
755        let this = Arc::clone(&self);
756        let outcome = forge_tts::bench::run(&engine, &clips, transcribe, |clip| {
757            *this.speaking_bench.lock() = SpeakingBenchState::Running { clip, clips: total };
758            this.push_models();
759            if this.speaking_bench_cancel.load(Ordering::Relaxed) {
760                std::ops::ControlFlow::Break(())
761            } else {
762                std::ops::ControlFlow::Continue(())
763            }
764        })
765        .await;
766        if let Some(said) = heard_error.lock().ok().and_then(|slot| slot.clone()) {
767            return self.settle_speaking_bench(Err(said));
768        }
769        match outcome {
770            Ok(metrics) => {
771                let file = files.model.file_name().map_or_else(
772                    || files.model.display().to_string(),
773                    |name| name.to_string_lossy().into_owned(),
774                );
775                self.settle_speaking_bench(Ok(SpeakingBenchResult {
776                    file,
777                    metrics,
778                    at: crate::install::rfc3339_now(),
779                    corpus: corpus.id,
780                }));
781            }
782            Err(error) => self.settle_speaking_bench(Err(error.to_string())),
783        }
784    }
785
786    /// A speaking run that ended: a result is saved, a stop is idle, and
787    /// any other ending is a failure with its own reason.
788    fn settle_speaking_bench(&self, outcome: Result<SpeakingBenchResult, String>) {
789        self.release_bench();
790        match outcome {
791            Ok(result) => {
792                self.save_speaking_result(&result);
793                *self.speaking_bench.lock() = SpeakingBenchState::Idle;
794            }
795            Err(reason) => {
796                if self.speaking_bench_cancel.load(Ordering::Relaxed) {
797                    *self.speaking_bench.lock() = SpeakingBenchState::Idle;
798                } else {
799                    *self.speaking_bench.lock() = SpeakingBenchState::Failed { reason };
800                }
801            }
802        }
803    }
804
805    fn save_speaking_result(&self, result: &SpeakingBenchResult) {
806        let db = self.db.lock();
807        if let Some(db) = db.as_ref()
808            && let Err(error) = crate::store::speaking_results::save(db, result)
809        {
810            tracing::warn!(
811                event_name = "speaking_bench_save_failed",
812                %error,
813                file = %result.file,
814                "the speaking bench's result was not saved; the numbers are gone with the run"
815            );
816        }
817    }
818
819    /// Gather the corpus, build the target's config, and run it.
820    async fn run_bench(self: Arc<Self>, target: BenchTarget, tier: forge_dictate::bench::Tier) {
821        let settings = self.config_arc().dictate.clone();
822        let active = self.active_models();
823        let cfg = crate::dictate::preflight_config(&settings, &active);
824        let Some(models_dir) = settings.models_dir() else {
825            return self.fail_bench(target, "no models directory is configured".to_owned());
826        };
827        let takes_dir = cfg.diagnostics_dir.clone();
828        let read_aloud_dir = self.read_aloud_dir();
829        // The bundled public slice lives beside the models, installed on
830        // first use: one directory both directions read.
831        let public_dir = models_dir.join("public-corpus");
832        // The closure takes its own copy: the caller keeps the target for
833        // the failure paths below.
834        let ran = target.clone();
835
836        let this = Arc::clone(&self);
837        let outcome = tokio::task::spawn_blocking(move || {
838            let Some(takes_dir) = takes_dir else {
839                return Err("no diagnostics directory is configured".to_owned());
840            };
841            let read_aloud_dir = read_aloud_dir.unwrap_or_else(|| takes_dir.clone());
842            if tier == forge_dictate::bench::Tier::Public
843                && let Err(error) = forge_dictate::bench::install_public_corpus(&models_dir)
844            {
845                return Err(format!("the bundled public corpus could not be installed: {error}"));
846            }
847            let corpus =
848                forge_dictate::bench::corpus(tier, &takes_dir, &read_aloud_dir, &public_dir)
849                    .map_err(|error| error.to_string())?;
850            if corpus.clips.is_empty() {
851                return Err(match tier {
852                    forge_dictate::bench::Tier::Consensus => {
853                        "nothing to bench: no takes have been saved here yet - dictate a take, \
854                         or record the read-aloud passage and score that"
855                            .to_owned()
856                    }
857                    forge_dictate::bench::Tier::ReadAloud => {
858                        "nothing to bench: the read-aloud passage has not been recorded yet"
859                            .to_owned()
860                    }
861                    forge_dictate::bench::Tier::Public => {
862                        "nothing to bench: the bundled public corpus holds no clips".to_owned()
863                    }
864                });
865            }
866            let run_cfg = config_for(&cfg, &models_dir, &ran).map_err(|e| e.to_string())?;
867
868            let mut compared = 0_usize;
869            let mut agreed = 0_usize;
870            let clips = corpus.clips.len();
871            let outcome = forge_dictate::bench::run(&run_cfg, &corpus, |clip| {
872                if clip.matched.is_some() {
873                    compared += 1;
874                    if clip.matched == Some(true) {
875                        agreed += 1;
876                    }
877                }
878                *this.dictate_bench.lock() = BenchState::Running {
879                    target: ran.clone(),
880                    tier,
881                    clip: clip.index + 1,
882                    clips,
883                    so_far: (compared > 0).then(|| forge_dictate::bench::ratio(agreed, compared)),
884                };
885                this.push_models();
886                if this.dictate_bench_cancel.load(Ordering::Relaxed) {
887                    std::ops::ControlFlow::Break(())
888                } else {
889                    std::ops::ControlFlow::Continue(())
890                }
891            });
892            Ok::<_, String>((outcome, corpus.id))
893        })
894        .await;
895
896        match outcome {
897            Ok(Ok((Ok(metrics), corpus))) => {
898                let result = BenchResult {
899                    target: target.clone(),
900                    tier,
901                    metrics,
902                    at: crate::install::rfc3339_now(),
903                    corpus,
904                };
905                self.settle_bench_success(&result);
906            }
907            Ok(Ok((Err(error), _))) => self.settle_bench(&target, error.to_string()),
908            Ok(Err(reason)) => self.settle_bench(&target, reason),
909            Err(join) => self.settle_bench(&target, join.to_string()),
910        }
911    }
912
913    /// A run that produced a result: save it, release the one bench slot,
914    /// and go idle. The release is here rather than only on the failure
915    /// paths, or one successful run would hold the slot for the process's
916    /// life.
917    fn settle_bench_success(&self, result: &BenchResult) {
918        self.save_bench_result(result);
919        self.release_bench();
920        *self.dictate_bench.lock() = BenchState::Idle;
921    }
922
923    /// A run that ended without a result: idle when it was stopped on
924    /// request - a partial corpus is not a result, and nothing is saved -
925    /// and failed with its own reason otherwise.
926    fn settle_bench(&self, target: &BenchTarget, reason: String) {
927        self.release_bench();
928        if self.dictate_bench_cancel.load(Ordering::Relaxed) {
929            *self.dictate_bench.lock() = BenchState::Idle;
930        } else {
931            self.fail_bench(target.clone(), reason);
932        }
933    }
934
935    fn fail_bench(&self, target: BenchTarget, reason: String) {
936        self.release_bench();
937        *self.dictate_bench.lock() = BenchState::Failed { target, reason };
938    }
939
940    fn save_bench_result(&self, result: &BenchResult) {
941        let db = self.db.lock();
942        if let Some(db) = db.as_ref()
943            && let Err(error) = crate::store::bench_results::save(db, result)
944        {
945            tracing::warn!(
946                event_name = "dictate_bench_save_failed",
947                %error,
948                file = %result.target.file,
949                "the bench's result was not saved; the numbers are gone with the run"
950            );
951        }
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use crate::SeatUpdate;
959
960    /// A file present on disk, so the target rules are about the rules.
961    fn present(dir: &Path, file: &str) {
962        std::fs::write(dir.join(file), b"weights").unwrap();
963    }
964
965    fn target(file: &str, role: BenchRole) -> BenchTarget {
966        BenchTarget { file: file.to_owned(), role, pinned: false }
967    }
968
969    fn metrics(clips: usize) -> forge_dictate::bench::Metrics {
970        forge_dictate::bench::Metrics {
971            clips,
972            audio_seconds: 12.5,
973            wall_seconds: 1.25,
974            xrt_wall: 10.0,
975            term_accuracy: Some(0.75),
976            wer: Some(0.05),
977            matched: Some((2, 3)),
978            stages_ms: forge_dictate::bench::StageTotals::default(),
979        }
980    }
981
982    fn corpus_id(sha: &str) -> forge_dictate::bench::CorpusId {
983        forge_dictate::bench::CorpusId { clips: 3, audio_seconds: 12, sha256: sha.to_owned() }
984    }
985
986    /// The config a target runs under: the base with the target's file in
987    /// its slot, no URL and no digest - the bench fetches nothing - and the
988    /// size the file's own.
989    #[test]
990    fn a_targets_config_swaps_its_own_slot_and_leaves_the_other_alone() {
991        let dir = tempfile::tempdir().unwrap();
992        present(dir.path(), "candidate.gguf");
993        let base = forge_dictate::Config::default();
994
995        let for_it = config_for(
996            &base,
997            dir.path(),
998            &BenchTarget {
999                file: "candidate.gguf".to_owned(),
1000                role: BenchRole::Transcribing,
1001                pinned: false,
1002            },
1003        )
1004        .expect("the file is on disk");
1005
1006        assert_eq!(for_it.asr_model.file, "candidate.gguf");
1007        assert!(for_it.asr_model.url.is_empty(), "a bench never fetches");
1008        assert!(for_it.asr_model.sha256.is_none(), "no digest is published for one");
1009        assert_eq!(for_it.asr_model.size, 7, "the size is the bytes on disk");
1010        assert!(
1011            for_it.diagnostics_dir.is_none(),
1012            "a bench run must not write takes into the store it scores against"
1013        );
1014        assert_eq!(
1015            for_it.normalizer.as_ref().map(|spec| spec.file.clone()),
1016            base.normalizer.as_ref().map(|spec| spec.file.clone()),
1017            "the other role keeps its model"
1018        );
1019
1020        let for_cleanup = config_for(
1021            &base,
1022            dir.path(),
1023            &BenchTarget {
1024                file: "candidate.gguf".to_owned(),
1025                role: BenchRole::Cleanup,
1026                pinned: false,
1027            },
1028        )
1029        .expect("the file is on disk");
1030        assert_eq!(
1031            for_cleanup.normalizer.as_ref().map(|spec| spec.file.as_str()),
1032            Some("candidate.gguf")
1033        );
1034        assert_eq!(for_cleanup.asr_model.file, base.asr_model.file);
1035    }
1036
1037    /// A target whose file vanished between the list and the run fails
1038    /// naming the path rather than loading something else.
1039    #[test]
1040    fn a_target_whose_file_is_gone_fails_by_name() {
1041        let dir = tempfile::tempdir().unwrap();
1042
1043        let err = config_for(
1044            &forge_dictate::Config::default(),
1045            dir.path(),
1046            &target("gone.gguf", BenchRole::Transcribing),
1047        )
1048        .expect_err("no file, no config");
1049
1050        assert!(err.to_string().contains("gone.gguf"), "got: {err}");
1051    }
1052
1053    /// Both benches load engines, so neither starts while the other runs.
1054    #[tokio::test]
1055    async fn the_two_benches_refuse_each_other_by_name() {
1056        let (ws, _updates, _config, _models) = voice_stub();
1057        *ws.dictate_bench.lock() = BenchState::Running {
1058            target: target("asr.gguf", BenchRole::Transcribing),
1059            tier: forge_dictate::bench::Tier::Consensus,
1060            clip: 0,
1061            clips: 0,
1062            so_far: None,
1063        };
1064        let err = ws.start_speaking_bench().expect_err("one bench at a time");
1065        assert!(matches!(err, crate::DispatchError::BenchRunning), "got: {err:?}");
1066
1067        *ws.dictate_bench.lock() = BenchState::Idle;
1068        *ws.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: 1 };
1069        let err = ws
1070            .dispatch(crate::Command::DictateBench {
1071                target: target("asr.gguf", BenchRole::Transcribing),
1072                tier: forge_dictate::bench::Tier::Consensus,
1073            })
1074            .expect_err("the speaking bench holds the machine");
1075        assert!(matches!(err, crate::DispatchError::BenchRunning), "got: {err:?}");
1076    }
1077
1078    /// A stop with nothing running is its own refusal.
1079    #[test]
1080    fn a_speaking_stop_with_nothing_running_is_refused() {
1081        let (ws, _updates, _config, _models) = voice_stub();
1082        let err = ws.dispatch(crate::Command::VoiceBenchStop).expect_err("nothing to stop");
1083        assert!(matches!(err, crate::DispatchError::BenchNotRunning), "got: {err:?}");
1084    }
1085
1086    /// A stopped speaking run settles to idle and saves nothing: a partial
1087    /// corpus is not a result.
1088    #[test]
1089    fn a_stopped_speaking_run_settles_to_idle_and_saves_nothing() {
1090        let (ws, _updates, _config, _models) = voice_stub();
1091        let dir = tempfile::tempdir().unwrap();
1092        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1093        *ws.speaking_bench.lock() = SpeakingBenchState::Running { clip: 3, clips: 12 };
1094        ws.speaking_bench_cancel.store(true, Ordering::Relaxed);
1095
1096        ws.settle_speaking_bench(Err("stopped".to_owned()));
1097
1098        assert!(matches!(*ws.speaking_bench.lock(), SpeakingBenchState::Idle));
1099        assert!(ws.speaking_bench_results().is_empty(), "a stop saves nothing");
1100    }
1101
1102    /// A run that failed without a stop settles to failed with its reason.
1103    #[test]
1104    fn a_failed_speaking_run_settles_to_failed() {
1105        let (ws, _updates, _config, _models) = voice_stub();
1106        *ws.speaking_bench.lock() = SpeakingBenchState::Running { clip: 0, clips: 1 };
1107
1108        ws.settle_speaking_bench(Err("the model is missing".to_owned()));
1109
1110        assert!(
1111            matches!(&*ws.speaking_bench.lock(), SpeakingBenchState::Failed { reason } if reason.contains("missing")),
1112            "got: {:?}",
1113            *ws.speaking_bench.lock(),
1114        );
1115    }
1116
1117    /// **Speech switched off refuses by name**: there is no speaking model
1118    /// for a run to load.
1119    #[test]
1120    fn a_speaking_bench_with_speech_off_is_refused_by_name() {
1121        let (ws, _updates) = Workspace::testing_stub();
1122        let err = ws.start_speaking_bench().expect_err("no [tts] section to run");
1123        assert!(matches!(err, crate::DispatchError::VoiceOff { .. }), "got: {err:?}");
1124    }
1125
1126    /// **The first speaking run installs the bundled slice, then says what
1127    /// it is actually missing.** A machine with no corpus needs nothing
1128    /// fetched; the run reaches its model and fails naming that instead.
1129    #[tokio::test]
1130    async fn a_speaking_bench_installs_the_bundled_corpus_before_scoring() {
1131        let (ws, mut updates, _config, models) = voice_stub();
1132        ws.dispatch(crate::Command::VoiceBenchStart).expect("the run starts and fails on its own");
1133
1134        let state = tokio::time::timeout(std::time::Duration::from_secs(15), async {
1135            loop {
1136                match updates.recv().await {
1137                    Some(frame) => {
1138                        if let crate::SessionUpdate::DictateModelsChanged { models } =
1139                            std::sync::Arc::unwrap_or_clone(frame.update)
1140                            && matches!(models.speaking_bench, SpeakingBenchState::Failed { .. })
1141                        {
1142                            break models.speaking_bench;
1143                        }
1144                    }
1145                    None => panic!("the subscription must stay attached"),
1146                }
1147            }
1148        })
1149        .await
1150        .expect("the failure must land inside fifteen seconds");
1151        let SpeakingBenchState::Failed { reason } = state else {
1152            panic!("a run with no speaking model must land failed");
1153        };
1154        assert!(!reason.contains("public corpus"), "the bundled corpus is not a blocker: {reason}");
1155        assert!(reason.contains("speaking model"), "the missing model is named: {reason}");
1156
1157        let installed = forge_dictate::bench::public_corpus(&models.path().join("public-corpus"))
1158            .expect("the bundled slice installed");
1159        assert_eq!(installed.len(), 32, "the whole slice landed beside the models");
1160    }
1161
1162    /// **A words-bench run over the public tier installs the slice too** -
1163    /// the same first use, so both directions score the corpus the binary
1164    /// carries rather than something a machine had to find.
1165    #[tokio::test]
1166    async fn a_public_words_bench_installs_the_bundled_corpus_before_scoring() {
1167        let (ws, mut updates, _config, models) = voice_stub();
1168        ws.dispatch(crate::Command::DictateBench {
1169            target: target("asr.gguf", BenchRole::Transcribing),
1170            tier: forge_dictate::bench::Tier::Public,
1171        })
1172        .expect("the run starts and fails on its own");
1173
1174        tokio::time::timeout(std::time::Duration::from_secs(15), async {
1175            loop {
1176                match updates.recv().await {
1177                    Some(frame) => {
1178                        if let crate::SessionUpdate::DictateModelsChanged { models } =
1179                            std::sync::Arc::unwrap_or_clone(frame.update)
1180                            && matches!(models.bench, BenchState::Failed { .. })
1181                        {
1182                            break;
1183                        }
1184                    }
1185                    None => panic!("the subscription must stay attached"),
1186                }
1187            }
1188        })
1189        .await
1190        .expect("the failure must land inside fifteen seconds");
1191
1192        let installed = forge_dictate::bench::public_corpus(&models.path().join("public-corpus"))
1193            .expect("the bundled slice installed");
1194        assert_eq!(installed.len(), 32, "the whole slice landed beside the models");
1195    }
1196
1197    /// A workspace with both directions pointed at temp dirs, so a speaking
1198    /// run reaches the corpus before it would load a model.
1199    fn voice_stub() -> (
1200        Arc<Workspace>,
1201        tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
1202        tempfile::TempDir,
1203        tempfile::TempDir,
1204    ) {
1205        let config_dir = tempfile::tempdir().unwrap();
1206        let models = tempfile::tempdir().unwrap();
1207        let mut config = crate::config::LoadedConfig::empty_for_test();
1208        config.dictate.enabled = true;
1209        config.dictate.models_dir = Some(models.path().to_string_lossy().into_owned());
1210        config.tts.enabled = true;
1211        config.tts.models_dir = Some(models.path().to_string_lossy().into_owned());
1212        let (ws, updates) =
1213            Workspace::testing_stub_with_config(config_dir.path().to_path_buf(), config)
1214                .expect("the stub builds");
1215        (ws, updates, config_dir, models)
1216    }
1217
1218    /// **A bench refuses by name while a take is live** - the microphone is
1219    /// the machine's, and a bench would hold a second engine through it.
1220    #[test]
1221    fn a_bench_refuses_by_name_while_a_take_is_live() {
1222        let (ws, _updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1223        let (stop, _stop_rx) = tokio::sync::mpsc::channel(1);
1224        ws.dictate_runtime.lock().recordings.insert(
1225            crate::SessionSlot::new("Busytools", "forge", "worker"),
1226            crate::dictate::LiveRecording { stop, sink: None, initiator: None },
1227        );
1228
1229        let err = ws
1230            .dispatch(crate::Command::DictateBench {
1231                target: target("asr.gguf", BenchRole::Transcribing),
1232                tier: forge_dictate::bench::Tier::Consensus,
1233            })
1234            .expect_err("a live take refuses the run");
1235
1236        assert!(
1237            matches!(&err, crate::DispatchError::TakeLive { holder } if holder == "Busytools/forge/worker"),
1238            "the refusal names the seat whose take is live, got: {err:?}"
1239        );
1240        assert_eq!(ws.dictate_bench(), BenchState::Idle, "and nothing started");
1241    }
1242
1243    /// A stop with nothing running is its own refusal, and a run that was
1244    /// stopped settles to idle with nothing saved - **a partial corpus is
1245    /// not a result.**
1246    #[test]
1247    fn a_stopped_run_settles_to_idle_and_saves_nothing() {
1248        let (ws, _updates) = Workspace::testing_stub();
1249        let dir = tempfile::tempdir().unwrap();
1250        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1251
1252        let err = ws.dispatch(crate::Command::DictateBenchStop).expect_err("nothing to stop");
1253        assert!(matches!(err, crate::DispatchError::BenchNotRunning), "got: {err:?}");
1254
1255        let target = target("asr.gguf", BenchRole::Transcribing);
1256        *ws.dictate_bench.lock() = BenchState::Running {
1257            target: target.clone(),
1258            tier: forge_dictate::bench::Tier::Consensus,
1259            clip: 3,
1260            clips: 12,
1261            so_far: Some(0.5),
1262        };
1263        ws.dispatch(crate::Command::DictateBenchStop).expect("a running bench stops");
1264
1265        ws.settle_bench(&target, forge_dictate::Error::Cancelled.to_string());
1266
1267        assert_eq!(ws.dictate_bench(), BenchState::Idle, "a stop is not a failure");
1268        assert!(ws.bench_results().is_empty(), "nothing measured part-way is kept");
1269    }
1270
1271    /// A successful run releases the one bench slot. A leak here would
1272    /// refuse every later bench and every install for the process's life.
1273    #[test]
1274    fn a_successful_bench_releases_the_claim() {
1275        let (ws, _updates) = Workspace::testing_stub();
1276        let dir = tempfile::tempdir().unwrap();
1277        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1278        ws.claim_bench().expect("the claim is free");
1279
1280        ws.settle_bench_success(&BenchResult {
1281            target: target("asr.gguf", BenchRole::Transcribing),
1282            tier: forge_dictate::bench::Tier::Consensus,
1283            metrics: metrics(3),
1284            at: "2026-11-02T00:00:00Z".to_owned(),
1285            corpus: corpus_id("abc"),
1286        });
1287
1288        assert!(!ws.bench_running(), "the claim is released");
1289        assert_eq!(ws.dictate_bench(), BenchState::Idle);
1290    }
1291
1292    /// A settled speaking run releases the one bench slot too.
1293    #[test]
1294    fn a_settled_speaking_run_releases_the_claim() {
1295        let (ws, _updates, _config, _models) = voice_stub();
1296        ws.claim_bench().expect("the claim is free");
1297
1298        ws.settle_speaking_bench(Err("stopped".to_owned()));
1299
1300        assert!(!ws.bench_running(), "the claim is released");
1301    }
1302
1303    /// A failed run releases the slot, so a second bench can start.
1304    #[test]
1305    fn a_failed_bench_leaves_the_slot_free() {
1306        let (ws, _updates, _config, _models) = voice_stub();
1307        ws.claim_bench().expect("the claim is free");
1308
1309        ws.fail_bench(
1310            target("asr.gguf", BenchRole::Transcribing),
1311            "the model would not load".to_owned(),
1312        );
1313
1314        ws.claim_bench().expect("the claim is free again");
1315    }
1316
1317    /// A stopped run releases the slot, so a second bench can start.
1318    #[test]
1319    fn a_stopped_bench_leaves_the_slot_free() {
1320        let (ws, _updates, _config, _models) = voice_stub();
1321        ws.claim_bench().expect("the claim is free");
1322        ws.dictate_bench_cancel.store(true, Ordering::Relaxed);
1323
1324        ws.settle_bench(&target("asr.gguf", BenchRole::Transcribing), "stopped".to_owned());
1325
1326        ws.claim_bench().expect("the claim is free again");
1327    }
1328
1329    /// A run that ends any other way lands failed, with its own reason.
1330    #[test]
1331    fn a_run_that_ends_without_a_result_lands_failed() {
1332        let (ws, _updates) = Workspace::testing_stub();
1333        let target = target("asr.gguf", BenchRole::Transcribing);
1334
1335        ws.settle_bench(&target, "the model would not load".to_owned());
1336
1337        let BenchState::Failed { target: failed, reason } = ws.dictate_bench() else {
1338            panic!("a failure must land as one, got {:?}", ws.dictate_bench());
1339        };
1340        assert_eq!(failed.file, "asr.gguf");
1341        assert_eq!(reason, "the model would not load");
1342    }
1343
1344    /// A result round-trips through the store newest first, and a re-run
1345    /// over the same corpus replaces its row rather than stacking one.
1346    #[test]
1347    fn a_result_round_trips_through_the_store_newest_first() {
1348        let (ws, _updates) = Workspace::testing_stub();
1349        let dir = tempfile::tempdir().unwrap();
1350        ws.install_db_for_test(crate::store::Db::open(&dir.path().join("db.redb")).unwrap());
1351        let target = target("asr.gguf", BenchRole::Transcribing);
1352
1353        let older = BenchResult {
1354            target: target.clone(),
1355            tier: forge_dictate::bench::Tier::Consensus,
1356            metrics: metrics(15),
1357            at: "2026-10-06T10:00:00Z".to_owned(),
1358            corpus: corpus_id("aaaa"),
1359        };
1360        let mut newer = older.clone();
1361        newer.at = "2026-10-06T11:00:00Z".to_owned();
1362        newer.corpus = corpus_id("bbbb");
1363        ws.save_bench_result(&older);
1364        ws.save_bench_result(&newer);
1365
1366        let rows = ws.bench_results();
1367
1368        assert_eq!(rows.len(), 2, "two corpora are two results");
1369        assert_eq!(rows[0].at, "2026-10-06T11:00:00Z", "newest first");
1370        assert_eq!(rows[0].metrics.clips, 15, "the metrics cross whole");
1371        assert_eq!(rows[0].metrics.term_accuracy, Some(0.75));
1372
1373        // The same corpus again: the row is replaced, not duplicated.
1374        let mut rerun = older.clone();
1375        rerun.at = "2026-10-06T12:00:00Z".to_owned();
1376        ws.save_bench_result(&rerun);
1377        let rows = ws.bench_results();
1378        assert_eq!(rows.len(), 2, "a re-run replaces its own row");
1379        assert_eq!(
1380            rows.iter().filter(|row| row.corpus.sha256 == "aaaa").count(),
1381            1,
1382            "one row per (role, file, tier, corpus)"
1383        );
1384    }
1385
1386    /// The last models frame queued so far, draining what came before it.
1387    fn drained_models(
1388        updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
1389    ) -> Option<crate::catalogue::DictateModelsSnapshot> {
1390        let mut found = None;
1391        while let Ok(frame) = updates.try_recv() {
1392            if let crate::SessionUpdate::DictateModelsChanged { models } =
1393                std::sync::Arc::unwrap_or_clone(frame.update)
1394            {
1395                found = Some(models);
1396            }
1397        }
1398        found
1399    }
1400
1401    /// **Starting and stopping a recording both push the read.** The card is
1402    /// drawn from a frame like every other fact on the page, so a command
1403    /// that moves the recording without a frame leaves the press looking
1404    /// dead.
1405    #[test]
1406    fn starting_and_stopping_a_recording_push_the_models_read() {
1407        let (ws, mut updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1408        let dir = tempfile::tempdir().unwrap();
1409        *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1410
1411        ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) })
1412            .expect("nothing else is capturing");
1413        let models = drained_models(&mut updates).expect("starting pushes the read");
1414        assert!(models.read_aloud.recording, "the frame carries the recording");
1415
1416        ws.dispatch(crate::Command::DictateReadAloudStop { keep: false, initiator: Some(1) })
1417            .expect("the recording is this connection's");
1418        let models = drained_models(&mut updates).expect("stopping pushes the read");
1419        assert!(!models.read_aloud.recording, "the frame carries it stopped");
1420    }
1421
1422    /// The next frame that carries a written set, inside a test's patience:
1423    /// the write runs off the runtime thread, so the stop's own frame lands
1424    /// before it and this is the one that says the bytes are on disk.
1425    async fn await_recorded(
1426        updates: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
1427    ) -> crate::catalogue::DictateModelsSnapshot {
1428        tokio::time::timeout(std::time::Duration::from_secs(15), async {
1429            loop {
1430                match updates
1431                    .recv()
1432                    .await
1433                    .map(|frame| std::sync::Arc::unwrap_or_clone(frame.update))
1434                {
1435                    Some(crate::SessionUpdate::DictateModelsChanged { models }) => {
1436                        if !models.read_aloud.recordings.is_empty() {
1437                            break models;
1438                        }
1439                    }
1440                    Some(_) => {}
1441                    None => panic!("the subscription must stay attached"),
1442                }
1443            }
1444        })
1445        .await
1446        .expect("the set must land inside fifteen seconds")
1447    }
1448
1449    /// **A kept recording becomes the set.** The frames the page fed land as
1450    /// the passage's own take - and only the recording connection's frames
1451    /// do: another connection's are not the recording's to feed. A stop that
1452    /// does not keep writes nothing.
1453    #[tokio::test]
1454    async fn a_kept_recording_becomes_the_set_and_a_dropped_one_writes_nothing() {
1455        let (ws, mut updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1456        let dir = tempfile::tempdir().unwrap();
1457        *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1458
1459        ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1460        assert!(ws.read_aloud_push(&vec![0.25_f32; 1_600], Some(1)), "its own frames land");
1461        assert!(!ws.read_aloud_push(&[0.0_f32; 8], Some(2)), "another connection's do not");
1462        ws.dispatch(crate::Command::DictateReadAloudStop { keep: true, initiator: Some(1) })
1463            .unwrap();
1464
1465        let models = await_recorded(&mut updates).await;
1466        assert_eq!(models.read_aloud.recordings.len(), 1, "the read says the set is here");
1467        let clips = forge_dictate::bench::read_aloud(dir.path()).expect("the set reads");
1468        assert_eq!(clips.len(), 1, "a passage and a take are there");
1469        assert_eq!(clips[0].audio.len(), 1_600, "the frames the page fed");
1470
1471        // A second recording, dropped rather than kept: nothing replaces the
1472        // set that stands.
1473        ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1474        assert!(ws.read_aloud_push(&vec![0.5_f32; 800], Some(1)));
1475        ws.dispatch(crate::Command::DictateReadAloudStop { keep: false, initiator: Some(1) })
1476            .unwrap();
1477        let clips = forge_dictate::bench::read_aloud(dir.path()).unwrap();
1478        assert_eq!(clips.len(), 1, "a dropped recording leaves the set alone");
1479        assert_eq!(clips[0].audio.len(), 1_600);
1480    }
1481
1482    /// **A recording is deleted by the id the page read, and nothing else
1483    /// is.** The id is a directory name this server wrote, and a page-supplied
1484    /// path must never name a directory outside the set.
1485    #[tokio::test]
1486    async fn a_recording_is_deleted_by_its_id_and_a_foreign_one_is_refused() {
1487        let (ws, mut updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1488        let dir = tempfile::tempdir().unwrap();
1489        *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1490
1491        ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1492        assert!(ws.read_aloud_push(&vec![0.25_f32; 1_600], Some(1)));
1493        ws.dispatch(crate::Command::DictateReadAloudStop { keep: true, initiator: Some(1) })
1494            .unwrap();
1495        let models = await_recorded(&mut updates).await;
1496        let id = models.read_aloud.recordings[0].id.clone();
1497        assert_eq!(models.read_aloud.recordings[0].duration_ms, 100, "a tenth of a second");
1498
1499        // A path that is not one of this set's own directory names is refused
1500        // rather than removed.
1501        let outside = dir.path().join("neighbour");
1502        std::fs::create_dir_all(&outside).unwrap();
1503        for id in ["../neighbour", "neighbour", "take-x", "take-"] {
1504            let err = ws.dispatch(crate::Command::DictateReadAloudDelete { id: id.to_owned() });
1505            assert!(err.is_err(), "accepted {id:?}");
1506        }
1507        assert!(outside.is_dir(), "a foreign directory must survive");
1508
1509        ws.dispatch(crate::Command::DictateReadAloudDelete { id: id.clone() }).unwrap();
1510        let models = drained_models(&mut updates).expect("the delete pushes the read");
1511        assert!(models.read_aloud.recordings.is_empty(), "the recording left the set");
1512        assert!(!dir.path().join(&id).exists(), "and its directory went with it");
1513    }
1514
1515    /// One capture at a time: a take that is live refuses a recording by
1516    /// name, and a recording counts as live for the guards that wait on
1517    /// captures.
1518    #[test]
1519    fn a_recording_and_a_take_refuse_each_other() {
1520        let (ws, _updates, _models) = crate::catalogue::tests_catalogue_view::enabled_stub();
1521        let dir = tempfile::tempdir().unwrap();
1522        *ws.test_read_aloud_dir.lock() = Some(dir.path().to_path_buf());
1523
1524        ws.dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) }).unwrap();
1525        assert_eq!(
1526            ws.dictate_runtime.lock().live_holder().as_deref(),
1527            Some("the read-aloud recording"),
1528            "a recording is a live capture"
1529        );
1530        let err = ws
1531            .dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) })
1532            .expect_err("one recording at a time");
1533        assert!(
1534            matches!(err, crate::DispatchError::TakeLive { ref holder } if holder == "the read-aloud recording"),
1535            "got: {err:?}"
1536        );
1537
1538        let (stop, _stop_rx) = tokio::sync::mpsc::channel(1);
1539        ws.dictate_runtime.lock().recordings.insert(
1540            crate::SessionSlot::new("Busytools", "forge", "worker"),
1541            crate::dictate::LiveRecording { stop, sink: None, initiator: None },
1542        );
1543        ws.dispatch(crate::Command::DictateReadAloudStop { keep: false, initiator: Some(1) })
1544            .unwrap();
1545        let err = ws
1546            .dispatch(crate::Command::DictateReadAloudStart { initiator: Some(1) })
1547            .expect_err("a take holds the capture");
1548        assert!(
1549            matches!(&err, crate::DispatchError::TakeLive { holder } if holder == "Busytools/forge/worker"),
1550            "the refusal names the seat whose take is live, got: {err:?}"
1551        );
1552    }
1553}