Isolate speech transport failures and keep experimental media usable

This commit is contained in:
karl committed 2026-10-13 21:09:43 +02:00
1 parent f15ee07ed0
commit 296a39a6c2
7 files changed
+147 -3

No files matched your search

+1
View File
@@ -484,6 +484,7 @@ async function refreshSemanticStatus() {
? "Speech models ready · Spoken passages are waiting to be indexed."
: "Local speech passages ready. Recognized words and timings can contain errors."
: "Local speech passages are preparing. Filename and existing content search remain available.";
speechStatus.textContent = `Experimental speech search · ${speechStatus.textContent}`;
}
const retry = element<HTMLButtonElement>("retry-model");
retry.hidden =
+61
View File
@@ -24,6 +24,10 @@ pub trait SpeechProvider: Send {
fn admission_us(&self) -> u64 {
0
}
/// Starts a new source without retrying a failed window from the previous source.
fn begin_source(&mut self) -> Result<()> {
Ok(())
}
fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result<SpeechTranscription>;
}
@@ -31,6 +35,9 @@ impl SpeechProvider for searchi_speech::SpeechWorker {
fn fingerprint(&self) -> &str {
searchi_speech::SpeechWorker::fingerprint(self)
}
fn begin_source(&mut self) -> Result<()> {
searchi_speech::SpeechWorker::begin_source(self)
}
fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result<SpeechTranscription> {
searchi_speech::SpeechWorker::transcribe(self, pcm, sample_rate)
}
@@ -58,6 +65,13 @@ impl SpeechProvider for BudgetedSpeechProvider {
fn admission_us(&self) -> u64 {
self.admission_us
}
fn begin_source(&mut self) -> Result<()> {
let start = std::time::Instant::now();
let permit = self.budget.acquire(InferenceRole::Background, 1);
self.admission_us = start.elapsed().as_micros().min(u64::MAX as u128) as u64;
let _permit = permit?;
self.inner.begin_source()
}
fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result<SpeechTranscription> {
let start = std::time::Instant::now();
let permit = self.budget.acquire(InferenceRole::Background, 1);
@@ -275,6 +289,10 @@ impl HybridSearch {
let filename = path.file_name().unwrap_or_default().to_string_lossy();
let mut ordinal = 0usize;
if metadata.has_audio {
self.speech
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Speech provider unavailable"))?
.begin_source()?;
for window in speech_windows(metadata.audio_start_ms, metadata.audio_end_ms)? {
let pcm = decode_audio_aligned(tools, path, window, limits)?;
let provider = self
@@ -372,6 +390,49 @@ impl HybridSearch {
mod tests {
use super::*;
#[test]
fn new_source_recovers_provider_without_retrying_failed_window() -> Result<()> {
struct SourceProvider {
sources: usize,
calls: usize,
failed: bool,
}
impl SpeechProvider for SourceProvider {
fn fingerprint(&self) -> &str {
"lifecycle-test"
}
fn begin_source(&mut self) -> Result<()> {
self.sources += 1;
self.failed = false;
Ok(())
}
fn transcribe(&mut self, _: &[f32], _: u32) -> Result<SpeechTranscription> {
self.calls += 1;
if self.sources == 1 || self.failed {
self.failed = true;
anyhow::bail!("first source transport failure");
}
Ok(SpeechTranscription {
passages: Vec::new(),
metrics: SpeechMetrics::default(),
})
}
}
let mut provider = SourceProvider {
sources: 0,
calls: 0,
failed: false,
};
provider.begin_source()?;
let first_failure = provider.transcribe(&[0.], 16000).unwrap_err().to_string();
assert_eq!(provider.calls, 1, "failed PCM is never retried");
provider.begin_source()?;
provider.transcribe(&[0.], 16000)?;
assert_eq!(provider.calls, 2);
assert_eq!(provider.sources, 2);
assert_eq!(first_failure, "first source transport failure");
Ok(())
}
#[test]
fn point_spans_are_legal_and_invalid_native_evidence_is_rejected() -> Result<()> {
let mut result = SpeechTranscription {
passages: vec![SpeechPassage {
+25 -2
View File
@@ -52,6 +52,7 @@ pub struct SpeechWorker {
base_path: PathBuf,
vad_path: PathBuf,
device: Device,
reset_on_next_source: bool,
}
fn verified_file(path: &Path, bytes: u64, expected: &str) -> Result<String> {
@@ -114,11 +115,31 @@ impl SpeechWorker {
base_path: base.to_owned(),
vad_path: vad.to_owned(),
device,
reset_on_next_source: false,
})
}
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
/// Recover a failed transport only at admission of a different source.
/// Never retries the previous source or malformed native output.
pub fn begin_source(&mut self) -> Result<()> {
if !std::mem::take(&mut self.reset_on_next_source) {
return Ok(());
}
let replacement = Self::load(
&self.worker_path,
&self.base_path,
&self.vad_path,
self.device,
)?;
ensure!(
replacement.fingerprint == self.fingerprint,
"Speech assets changed before source recovery"
);
*self = replacement;
Ok(())
}
pub fn transcribe(&mut self, pcm: &[f32], sample_rate: u32) -> Result<SpeechTranscription> {
ensure!(
sample_rate == SAMPLE_RATE && !pcm.is_empty() && pcm.len() <= MAX_SAMPLES,
@@ -126,7 +147,7 @@ impl SpeechWorker {
);
ensure!(pcm.iter().all(|v| v.is_finite()), "Nonfinite speech input");
let fallback_started = Instant::now();
match self.transcribe_once(pcm) {
let result = (|| match self.transcribe_once(pcm) {
Ok(value) => Ok(value),
Err(error) if self.device == Device::Auto && process::recoverable(&error) => {
// Retry only initialization/compute/transport failures. Reverify
@@ -148,7 +169,9 @@ impl SpeechWorker {
Ok(result)
}
Err(error) => Err(error),
}
})();
self.reset_on_next_source = result.as_ref().err().is_some_and(process::failed_transport);
result
}
fn transcribe_once(&mut self, pcm: &[f32]) -> Result<SpeechTranscription> {
let started = Instant::now();
+8
View File
@@ -60,6 +60,9 @@ pub(crate) fn recoverable(error: &anyhow::Error) -> bool {
.downcast_ref::<NativeFailure>()
.is_some_and(NativeFailure::recoverable)
}
pub(crate) fn failed_transport(error: &anyhow::Error) -> bool {
error.is::<TransportFailure>()
}
fn read_response<R: Read>(reader: &mut R) -> Result<Value> {
let mut prefix = [0; 4];
@@ -259,6 +262,7 @@ mod tests {
.exchange(vec![1], Duration::from_millis(50))
.unwrap_err();
assert!(recoverable(&error));
assert!(failed_transport(&error));
assert!(process.child.try_wait().unwrap().is_some());
assert!(process.failed);
let mut command = Command::new(&python);
@@ -268,6 +272,7 @@ mod tests {
.exchange(vec![1], Duration::from_secs(1))
.unwrap_err();
assert!(!recoverable(&error));
assert!(!failed_transport(&error));
assert!(process.child.try_wait().unwrap().is_some());
assert!(process.failed);
}
@@ -279,9 +284,12 @@ mod tests {
anyhow::Error::new(ProtocolFailure),
] {
assert!(!recoverable(&error));
assert!(!failed_transport(&error));
}
assert!(recoverable(&NativeFailure::Initialization.into()));
assert!(recoverable(&NativeFailure::Compute.into()));
assert!(!failed_transport(&NativeFailure::Initialization.into()));
assert!(!failed_transport(&NativeFailure::Compute.into()));
assert!(!recoverable(
&read_response(&mut &[2, 0, 0, 0, 0xff, 0xff][..]).unwrap_err()
));
+22 -1
View File
@@ -88,7 +88,7 @@ Raw log `.searchi/qa/service-speech-google-cpu-v3.log`: 1292 bytes, SHA
The first normal auto compilation reached Vulkan setup but failed on Windows
nested MSBuild path length. Its log is retained, SHA
`59f1820d6ac6adac6f525d8084c442c1ed261753eb34fcce916dc157041f777c`.
An unchanged build in a shorter owned output directory is underway; no CPU-only
An unchanged build in a shorter owned output directory was started; no CPU-only
distribution was substituted.
The shorter-path normal auto build subsequently completed. Worker SHA
@@ -122,3 +122,24 @@ The complete report is 174868 bytes, SHA
It uses the internal v3 CPU artifact and does not establish normal auto runtime
quality parity or GPU performance. Details and the retained comparison are in
[the evaluator](../tools/speech-eval/native-validation/README.md).
## Longer-source failure and release recovery
The normal-runtime timing regression retained all five original sources and
all eight queries, but the speech session returned a transport failure on the
first source and later files inherited the unavailable session. Raw Google
indexing completed with 79 chunks and no media failures. Recognized timing
scores remain ineligible; no historical 6/8 score is claimed for this run.
An isolated native reproduction using the **exact failed PCM hash**
`03ef72d2a4cb926b7d31fbaef757a6cb954a55d6d6961085ea6992728c8d9e37`
completed all four actual VAD intervals, valid recognition responses and exit
zero. The original transport failure is unexplained; no model or memory tuning
was introduced to hide it.
The release now restarts a verified session once at the next new source after
a terminal recoverable transport failure. The original failed source remains
reported; compatible donors return before restarting and raw search stays
usable. Malformed protocol/text and invalid token points never arm this reset.
Five focused service tests and native transport/lifecycle checks passed with
strict Clippy. Media and spoken passages remain visibly experimental.
@@ -152,3 +152,28 @@ Seven Rust tests, strict all-target Clippy and five timing-adapter tests passed.
A retry must use a fresh output directory after the corrected evaluator is
frozen and the CPU inference slot is granted again; the failed attempt remains
preserved and has no quality score.
The corrected frozen attempt (commit `f15ee07`, session 39812) completed on CPU
at `.searchi/qa/speech-live-timing-v1-coverage`, but is **not timing-eligible**.
All five source checks passed; raw indexing produced 79 chunks (16 audio and
63 image calls), with no media failures, in 377691.6842 ms. The first speech
request for the video's initial 30-second window failed with
`Speech worker transport exited or timed out` after 2382.0169 ms. The four
remaining sources reported `Unavailable speech session or unbounded PCM`.
Five failed calls were recorded against 16 expected windows; zero recognized
speech chunks or citations were published. The public client discards stderr,
so these artifacts cannot identify the native failure cause. The request
deadline is 600 seconds, longer than the observed first-call failure.
All eight first files were `clip04`, but their actual origins were raw audio or
video, not recognized speech. The unchanged scorer therefore records
`comparison_eligible: false`, `strict_point_score: null` and
`correct_first_file: null`; no historical score carries over.
Immutable report SHA-256:
`7261223ba479ff1c319ded187fcd6e7cf37d351ec9d62437e26b63017d1bccc2`;
scored artifact SHA-256:
`361d5fbaf917363bfedd9c3ba2ea368d1714cf28f1bc34544a9ed324761eb4db`;
preflight SHA-256:
`97c603b3078831ce0fcf19f0ebb9e15160ee2363b488946c992e881d5509ce6b`.
This failed speech lifecycle requires investigation before any live-window
timing claim; no policy, labels or sources were changed after this result.
+5
View File
@@ -57,6 +57,11 @@ path; the failed log SHA-256 is
An unchanged-source build using a shorter owned output path completed. Prefer a
short build output directory on Windows; no CPU-only distribution was substituted.
If a worker transport dies, the failing source retains its error. `begin_source`
permits one verified same-fingerprint restart at the next source boundary, keeping
the current CPU/device choice. Malformed protocol, UTF-8 or alignment outputs do
not authorize recovery or repeated inference of the failing source.
The persistent worker receives binary requests: operation byte (1 live VAD,
2 contiguous recognition), little-endian u32 sample count and mono little-endian
f32 PCM, at most 480000 finite samples. Responses have a four-byte little-endian