Skip to main content

text_mirror/convert/
subprocess.rs

1//! Subprocess adapters: converters that run behind the sandboxed
2//! runner instead of in this process.
3//!
4//! Each adapter speaks the framed stdin/stdout protocol to a worker
5//! mode and renders the response to the outcome shape every converter
6//! emits. The PDF adapter is the production occupant: it carries the
7//! same outcome, warnings, and failure reasons as the in-process PDF
8//! path did, so its manifest records differ only in converter id. The
9//! image and audio converters decode in-jail and drive their pinned
10//! engines behind hash-verified inventories. The OCR and video
11//! adapters are the protocol half of their conversions: their engines
12//! are not pinned yet, so the registry routes their formats to
13//! `unsupported` with reason `engine-unpinned` until a rules bump pins
14//! an engine, and the adapters are exercised against fake engines
15//! under test.
16
17use serde::de::DeserializeOwned;
18
19use crate::segments::Segment;
20
21use super::{ConvertError, Converter, Outcome, normalize_text};
22
23/// Registry id of the subprocess PDF adapter.
24pub const PDF_SUBPROCESS_ID: &str = "pdf-subprocess";
25/// Version of the subprocess PDF adapter.
26pub const PDF_SUBPROCESS_VERSION: &str = "1.0.0";
27
28/// Manifest converter id for the direct-extraction recovery path: a
29/// PDF whose text was recovered after the markdown path declined the
30/// page as image-based. It names the actual extraction path so a
31/// recovered document is never recorded under the markdown path's id.
32pub const PDF_RECOVERED_ID: &str = "pdf-text-recovered";
33/// Version of the PDF recovery path. It shares the adapter version
34/// deliberately: both paths ship in the same adapter, and the checkpoint
35/// key compares converter version, so an unchanged recovered PDF skips
36/// on a later run only because this equals the adapter version.
37///
38/// INVARIANT: any future change to the recovery path's behavior MUST
39/// bump the shared adapter version [`PDF_SUBPROCESS_VERSION`]. Because
40/// this equals that version, a bump moves both paths together and
41/// reconverts prior artifacts of both. The equality is asserted in the
42/// tests below so a silent divergence cannot compile past review.
43pub const PDF_RECOVERED_VERSION: &str = PDF_SUBPROCESS_VERSION;
44
45/// The manifest converter id and version for a PDF outcome, chosen by
46/// whether the text came from the direct recovery extractor. The
47/// registered converter keeps its single id and version; only the
48/// per-outcome manifest record names the recovery path.
49#[cfg_attr(not(unix), allow(dead_code))]
50pub(crate) fn pdf_converter_identity(recovered: bool) -> (&'static str, &'static str) {
51    if recovered {
52        (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
53    } else {
54        (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
55    }
56}
57
58/// Registry id of the jailed records worker adapter.
59pub const RECORDS_SUBPROCESS_ID: &str = "records-worker";
60/// Version of the jailed records worker adapter.
61pub const RECORDS_SUBPROCESS_VERSION: &str = "1.0.0";
62
63/// Registry id of the in-jail pixel-OCR converter for raster images.
64pub const IMAGE_PIXEL_OCR_ID: &str = "image-pixel-ocr";
65/// Version of the in-jail pixel-OCR converter. 1.1.0 because the same
66/// converter gained the svg input route through the external provider
67/// and the child-specific segment source: additive behavior under the
68/// shared id, so every prior direct-raster record reconverts once.
69pub const IMAGE_PIXEL_OCR_VERSION: &str = "1.1.0";
70
71/// The segment source the direct raster path labels its spans with.
72pub const OCR_SEGMENT_SOURCE_DOCUMENT: &str = "document";
73
74/// The segment source the svg provider child labels its spans with, so
75/// a consumer can tell recognized text lifted from a vector source's
76/// rendering from the source's own markup.
77pub const OCR_SEGMENT_SOURCE_OCR: &str = "ocr";
78
79/// Registry id of the OCR adapter.
80pub const OCR_ADAPTER_ID: &str = "ocr-adapter";
81/// Version of the OCR adapter.
82pub const OCR_ADAPTER_VERSION: &str = "1.0.0";
83
84/// Registry id of the speech transcription adapter.
85pub const ASR_ADAPTER_ID: &str = "asr-adapter";
86/// Version of the speech transcription adapter. 2.0.0 because the
87/// pinned in-jail decode-and-transcribe path replaced the
88/// engine-unpinned protocol scaffold: a total behavior change under
89/// the shared id, so every prior record under it reconverts.
90pub const ASR_ADAPTER_VERSION: &str = "2.0.0";
91
92/// The pinned decode policy, serialized exactly as the engine child
93/// receives it: one line, single spaces, no trailing newline. The
94/// engine argv embeds these tokens verbatim between the input
95/// selection and the output selection, and the rules pin this
96/// serialization's BLAKE3 under the `asr-decode-policy` role, so the
97/// rules, the code, and the child invocation cannot drift apart.
98pub const ASR_DECODE_POLICY: &str = "-l en -t 1 -p 1 -bo 1 -bs 1 -nf -tp 0 -tpi 0 -nfa -mc 0";
99
100/// The pinned speech runtime roles. Generic labels only: the
101/// identities behind them are deployment data bound by hash.
102pub const ASR_ROLE_CLI: &str = "asr-cli";
103/// The transcription weights role.
104pub const ASR_ROLE_WEIGHTS: &str = "asr-weights";
105/// The serialized decode policy role. Not a file: it pins
106/// [`ASR_DECODE_POLICY`] itself.
107pub const ASR_ROLE_DECODE_POLICY: &str = "asr-decode-policy";
108/// The backend probe role: the pinned executable whose clean exit
109/// proves an accelerator-class device is enumerable by type.
110pub const ASR_ROLE_PROBE: &str = "asr-probe";
111
112/// The speech runtime roles that are files a deployment supplies and
113/// the cold worker re-hashes: everything except the decode policy.
114pub const ASR_FILE_ROLE_LABELS: [&str; 3] = [ASR_ROLE_CLI, ASR_ROLE_WEIGHTS, ASR_ROLE_PROBE];
115
116/// Registry id of the video adapter.
117pub const VIDEO_ADAPTER_ID: &str = "video-adapter";
118/// Version of the video adapter.
119pub const VIDEO_ADAPTER_VERSION: &str = "1.0.0";
120
121/// OCR spans below this confidence carry a warning.
122pub const OCR_CONFIDENCE_WARNING: f64 = 0.5;
123
124/// Renders whole seconds as `HH:MM:SS` for transcript timecodes.
125/// The input must be finite and non-negative.
126pub fn format_timecode(seconds: f64) -> Result<String, String> {
127    if !seconds.is_finite() || seconds < 0.0 {
128        return Err(format!("timestamp {seconds} is not a non-negative time"));
129    }
130    let whole = seconds as u64;
131    Ok(format!(
132        "{:02}:{:02}:{:02}",
133        whole / 3600,
134        (whole % 3600) / 60,
135        whole % 60
136    ))
137}
138
139fn single_line(text: &str) -> String {
140    text.replace(['\n', '\r'], " ").trim().to_string()
141}
142
143#[cfg(unix)]
144mod imp {
145    use std::sync::OnceLock;
146
147    use super::*;
148    use crate::convert::RecordsLimits;
149    use crate::manifest::ArtifactKind;
150    use crate::runner::protocol::bodies::{
151        OcrInput, OcrOk, OcrRequest, PdfOk, PdfRequest, RecordsOk, RecordsRequest, VideoOk,
152        VideoRequest,
153    };
154    use crate::runner::{Runner, RunnerError};
155
156    /// Maps a wire reason to the static reason vocabulary. Codes the
157    /// in-process converters already use pass through unchanged, so a
158    /// record's failure reason does not depend on which side of the
159    /// process boundary produced it.
160    fn static_code(code: &str) -> &'static str {
161        const KNOWN: &[&str] = &[
162            "pdf_no_text_layer",
163            "malformed",
164            "encrypted",
165            "resourceLimit",
166            "missingPart",
167            "unsupported",
168            "io",
169            "empty_output",
170            "invalid_utf8",
171            "converter_panic",
172            "unclaimed_format",
173            "resource_limit",
174            "record-limit-exceeded",
175            "records_read_error",
176            "not_sqlite",
177            // Image-OCR worker reasons. Each must survive the wire-to-
178            // static mapping so the manifest keeps the specific reason
179            // rather than collapsing it to adapter_error.
180            "image-ocr-decode-failed",
181            "image-ocr-area-exceeded",
182            "image-ocr-runtime-missing",
183            "image-ocr-runtime-mismatch",
184            "image-ocr-no-accelerator",
185            "ocr-protocol-error",
186            // Image-metadata worker reasons. Each must survive the wire-
187            // to-static mapping so the manifest keeps the specific reason
188            // rather than collapsing it to adapter_error.
189            "image-metadata-malformed",
190            "image-metadata-decompress-exceeded",
191            "image-metadata-xml-exceeded",
192            "image-metadata-limit-exceeded",
193            "image-metadata-unsupported",
194            // Svg provider reasons, in the ruled order. Each must
195            // survive the wire-to-static mapping so the manifest keeps
196            // the specific reason rather than collapsing it to
197            // adapter_error, and each names a generic role label only.
198            "rasterizer-missing",
199            "rasterizer-hash-drift",
200            "rasterizer-version-drift",
201            "raster-exec-failed",
202            "raster-geometry-mismatch",
203            "encoder-area-cap",
204            // Audio worker reasons. Each must survive the wire-to-
205            // static mapping so the manifest keeps the specific reason
206            // rather than collapsing it to adapter_error.
207            "asr-decode-failed",
208            "asr-codec-unsupported",
209            "asr-duration-exceeded",
210            "asr-decoded-too-large",
211            "asr-no-speech",
212            "asr-runtime-missing",
213            "asr-runtime-mismatch",
214            "asr-backend-unavailable",
215            "asr-protocol-error",
216        ];
217        KNOWN
218            .iter()
219            .find(|known| **known == code)
220            .copied()
221            .unwrap_or("adapter_error")
222    }
223
224    fn runner_failure(error: RunnerError) -> ConvertError {
225        ConvertError {
226            code: error.code,
227            message: error.message(),
228        }
229    }
230
231    /// Runs one adapter mode and returns the parsed success body.
232    fn call<Req: serde::Serialize, Ok: DeserializeOwned>(
233        runner: &Runner,
234        mode: &str,
235        request: &Req,
236        files: &[(&str, &[u8])],
237    ) -> Result<Ok, ConvertError> {
238        let payload = serde_json::to_value(request).map_err(|e| ConvertError {
239            code: "adapter_error",
240            message: format!("cannot encode the adapter request: {e}"),
241        })?;
242        let response = runner.run(mode, payload, files).map_err(runner_failure)?;
243        if let Some(error) = response.error {
244            return Err(ConvertError {
245                code: static_code(&error.code),
246                message: error.message,
247            });
248        }
249        let ok = response.ok.expect("validated response has a body");
250        serde_json::from_value(ok).map_err(|e| ConvertError {
251            code: "adapter_error",
252            message: format!("adapter success body did not parse: {e}"),
253        })
254    }
255
256    fn shared_runner() -> Result<&'static Runner, ConvertError> {
257        static RUNNER: OnceLock<Result<Runner, RunnerError>> = OnceLock::new();
258        match RUNNER.get_or_init(Runner::with_platform_defaults) {
259            Ok(runner) => Ok(runner),
260            Err(error) => Err(runner_failure(error.clone())),
261        }
262    }
263
264    /// The production PDF conversion behind the sandbox runner.
265    ///
266    /// The worker links the same anydoc path the in-process document
267    /// adapter used, so text, warnings such as `pdf_partial_text`, and
268    /// failure reasons such as `pdf_no_text_layer` are unchanged.
269    pub struct PdfSubprocess;
270
271    impl Converter for PdfSubprocess {
272        fn id(&self) -> &'static str {
273            PDF_SUBPROCESS_ID
274        }
275
276        fn version(&self) -> &'static str {
277            PDF_SUBPROCESS_VERSION
278        }
279
280        fn convert(
281            &self,
282            source: &[u8],
283            detected_format: &str,
284        ) -> std::result::Result<Outcome, ConvertError> {
285            // ai routes here too: a modern .ai is a PDF-compatible
286            // container, so the same worker path extracts its text
287            // layer with the pdf-family warnings and recovery route. A
288            // legacy PostScript-backed .ai has no PDF text layer and
289            // fails closed with a pdf-family reason.
290            if !matches!(detected_format, "pdf" | "ai") {
291                return Err(ConvertError {
292                    code: "unclaimed_format",
293                    message: format!(
294                        "the subprocess PDF adapter does not handle {detected_format}"
295                    ),
296                });
297            }
298            let body: PdfOk = call(
299                shared_runner()?,
300                "pdf",
301                &PdfRequest {
302                    input: "input.pdf".to_string(),
303                },
304                &[("input.pdf", source)],
305            )?;
306            let (converter_id, converter_version) = pdf_converter_identity(body.recovered);
307            Ok(Outcome {
308                artifact_kind: ArtifactKind::Text,
309                converter_id: converter_id.to_string(),
310                converter_version: converter_version.to_string(),
311                detected_format: detected_format.to_string(),
312                text: body.text,
313                warnings: body.warnings,
314                segments: body.segments,
315                media: None,
316            })
317        }
318    }
319
320    /// The records worker adapter: parquet, avro, and sqlite behind
321    /// the sandbox runner.
322    ///
323    /// The three readers pull large native parser trees with CVE
324    /// history, so they never run in this process. Each source stages
325    /// into the jail and the reader runs in the worker, where a panic
326    /// on hostile bytes crashes the child and records a failed outcome
327    /// without touching the pipeline. The ceilings come from the rules
328    /// and travel in the request, and a source over any ceiling fails
329    /// closed with reason `record-limit-exceeded`.
330    pub struct RecordsSubprocess {
331        limits: RecordsLimits,
332    }
333
334    impl RecordsSubprocess {
335        /// An adapter over the rules-supplied ceilings.
336        pub fn new(limits: RecordsLimits) -> RecordsSubprocess {
337            RecordsSubprocess { limits }
338        }
339    }
340
341    impl Converter for RecordsSubprocess {
342        fn id(&self) -> &'static str {
343            RECORDS_SUBPROCESS_ID
344        }
345
346        fn version(&self) -> &'static str {
347            RECORDS_SUBPROCESS_VERSION
348        }
349
350        fn convert(
351            &self,
352            source: &[u8],
353            detected_format: &str,
354        ) -> std::result::Result<Outcome, ConvertError> {
355            if !matches!(detected_format, "parquet" | "avro" | "sqlite") {
356                return Err(ConvertError {
357                    code: "unclaimed_format",
358                    message: format!("the records worker does not handle {detected_format}"),
359                });
360            }
361            let input = format!("input.{detected_format}");
362            let body: RecordsOk = call(
363                shared_runner()?,
364                "records",
365                &RecordsRequest {
366                    input: input.clone(),
367                    format: detected_format.to_string(),
368                    max_records: self.limits.max_records,
369                    max_tables: self.limits.max_tables,
370                    max_output_bytes: self.limits.max_output_bytes,
371                },
372                &[(&input, source)],
373            )?;
374            Ok(Outcome {
375                artifact_kind: ArtifactKind::Text,
376                converter_id: RECORDS_SUBPROCESS_ID.to_string(),
377                converter_version: RECORDS_SUBPROCESS_VERSION.to_string(),
378                detected_format: detected_format.to_string(),
379                text: body.text,
380                warnings: body.warnings,
381                segments: body.segments,
382                media: None,
383            })
384        }
385    }
386
387    /// The in-jail image-metadata converter: the derived-child leg that
388    /// lifts textual metadata out of a raster behind the same jail as the
389    /// records worker.
390    ///
391    /// The carrier and surface parsers pull decompression and expansion
392    /// surfaces a hostile image can flood, so they never run in this
393    /// process. Each source stages into the jail and the parsers run in
394    /// the worker, where a bomb or a flood times out or crashes the child
395    /// and records a failed child without touching the pipeline. The
396    /// ceilings come from the rules and travel in the request. The parent
397    /// holds no parser code: it renders the returned rows to the tabular
398    /// artifact and builds one hidden segment per row, so the output
399    /// contract and the hidden marking stay in the crate. Zero rows is
400    /// the not-applicable outcome, mapped to a distinct reason the
401    /// pipeline reads as write-nothing rather than a failure.
402    #[cfg(feature = "image-metadata")]
403    pub struct ImageMetadata {
404        limits: crate::convert::ImageMetadataLimits,
405    }
406
407    #[cfg(feature = "image-metadata")]
408    impl ImageMetadata {
409        /// An adapter over the rules-supplied metadata ceilings.
410        pub fn new(limits: crate::convert::ImageMetadataLimits) -> ImageMetadata {
411            ImageMetadata { limits }
412        }
413    }
414
415    #[cfg(feature = "image-metadata")]
416    impl Converter for ImageMetadata {
417        fn id(&self) -> &'static str {
418            crate::convert::image_metadata::IMAGE_METADATA_ID
419        }
420
421        fn version(&self) -> &'static str {
422            crate::convert::image_metadata::IMAGE_METADATA_VERSION
423        }
424
425        fn convert(
426            &self,
427            source: &[u8],
428            detected_format: &str,
429        ) -> std::result::Result<Outcome, ConvertError> {
430            use crate::runner::protocol::bodies::{ImageMetadataOk, ImageMetadataRequest};
431            if !crate::convert::image_metadata::image_metadata_applies(detected_format) {
432                return Err(ConvertError {
433                    code: "unclaimed_format",
434                    message: format!("the image-metadata worker does not read {detected_format}"),
435                });
436            }
437            let input = format!("input.{detected_format}");
438            let body: ImageMetadataOk = call(
439                shared_runner()?,
440                "image-metadata",
441                &ImageMetadataRequest {
442                    input: input.clone(),
443                    format: detected_format.to_string(),
444                    max_decompressed_bytes: self.limits.max_decompressed_bytes,
445                    max_xml_depth: self.limits.max_xml_depth,
446                    max_xml_events: self.limits.max_xml_events,
447                    max_boxes: self.limits.max_boxes,
448                    max_rows: self.limits.max_rows,
449                    max_output_bytes: self.limits.max_output_bytes,
450                },
451                &[(&input, source)],
452            )?;
453            // Zero rows is not-applicable: the image carries no textual
454            // metadata, so the parent writes nothing for this leg.
455            if body.rows.is_empty() {
456                return Err(ConvertError {
457                    code: crate::convert::image_metadata::IMAGE_METADATA_NOT_APPLICABLE,
458                    message: "the image carries no textual metadata".to_string(),
459                });
460            }
461            let (text, segments) = crate::convert::image_metadata::render_rows(&body.rows);
462            // A positive row count that renders to nothing is a fault, not
463            // a blank success, matching the other adapters' floor.
464            if text.is_empty() {
465                return Err(empty_output(source.len()));
466            }
467            Ok(Outcome {
468                artifact_kind: ArtifactKind::Text,
469                converter_id: crate::convert::image_metadata::IMAGE_METADATA_ID.to_string(),
470                converter_version: crate::convert::image_metadata::IMAGE_METADATA_VERSION
471                    .to_string(),
472                detected_format: detected_format.to_string(),
473                text,
474                warnings: Vec::new(),
475                segments,
476                media: None,
477            })
478        }
479    }
480
481    /// The in-jail pixel-OCR converter for raster images.
482    ///
483    /// png, jpeg, and webp stage into the jail by bare name and the
484    /// worker decodes them with the pure-Rust image crate, asserts the
485    /// encoder-input area cap, re-encodes a canonical raster, and hands
486    /// it to the pinned vision engine as a hash-verified jailed child.
487    /// The parent does no image parsing: it selects the worker mode,
488    /// stages the bytes, and maps the fenced recognition to the outcome
489    /// shape with [`ArtifactKind::Ocr`]. The engine is machine-class
490    /// scoped, so the recognized text carries no cross-platform
491    /// byte-equality claim; only the pure-Rust decode does.
492    #[cfg(feature = "image-ocr")]
493    pub struct ImagePixelOcr {
494        /// The worker mode this converter drives. Production always uses
495        /// the pinned-runtime `image-ocr` mode; the test-only fake
496        /// constructor selects the harness `image-ocr-fake` mode.
497        mode: &'static str,
498        /// The svg leg, present only when a deployment configured a
499        /// complete external provider. Absent, this converter handles
500        /// the direct raster formats and nothing else, and svg stays a
501        /// passthrough primary with no derived child at all.
502        #[cfg(feature = "svg-provider")]
503        svg: Option<SvgProviderLeg>,
504        /// The runtime files the deployment wired in, as wire entries
505        /// pairing each role's path with its rules-pinned hash. The
506        /// cold worker re-hashes each file itself before recognition.
507        inventory: Vec<crate::runner::protocol::bodies::InventoryEntry>,
508        /// This instance's own runner, built from its own limits at
509        /// construction. Security ceilings are per-registry rules data,
510        /// so each converter owns its runner rather than sharing a
511        /// process-global cache whose ceilings the first caller would
512        /// fix for every later one.
513        runner: Result<Runner, RunnerError>,
514    }
515
516    /// The svg leg's own state: the verified-at-exec role entries the
517    /// worker re-checks, and the runner over the provider jail class.
518    #[cfg(all(feature = "image-ocr", feature = "svg-provider"))]
519    struct SvgProviderLeg {
520        roles: Vec<crate::runner::protocol::bodies::SvgRoleEntry>,
521        runner: Result<Runner, RunnerError>,
522        /// Test-adapters-only seam. The named executable is replaced
523        /// after the parent presence preflight and immediately before
524        /// `Runner::run`, so the runner's spawn-time grant check sees
525        /// the change.
526        #[cfg(feature = "test-adapters")]
527        replace_after_preflight: Option<std::path::PathBuf>,
528    }
529
530    /// Ceiling on the provider response frame. The flattened raster
531    /// crosses the frame as hex, so the ceiling is sized for the
532    /// largest raster the area cap admits rather than the smaller image
533    /// response the recognition path returns.
534    #[cfg(all(feature = "image-ocr", feature = "svg-provider"))]
535    const PROVIDER_MAX_RESPONSE_BYTES: u32 = 64 * 1024 * 1024;
536
537    #[cfg(feature = "image-ocr")]
538    impl ImagePixelOcr {
539        /// An adapter over the rules-supplied image-OCR jail profile and
540        /// the deployment's runtime inventory. It drives the production
541        /// `image-ocr` worker mode, which fails closed with
542        /// `image-ocr-runtime-missing` until a deployment wires every
543        /// pinned runtime role.
544        pub fn new(
545            limits: crate::convert::ImageOcrLimits,
546            inventory: &crate::convert::RuntimeInventory,
547        ) -> ImagePixelOcr {
548            // A role travels only when the deployment supplied its path
549            // AND the rules pin its hash; anything less leaves the role
550            // unfilled and the worker fails closed on it. A path that is
551            // not valid UTF-8 cannot cross the wire and is treated as
552            // unsupplied for the same fail-closed outcome.
553            let entries: Vec<crate::runner::protocol::bodies::InventoryEntry> =
554                crate::convert::IMAGE_OCR_ROLE_LABELS
555                    .iter()
556                    .filter_map(|role| {
557                        let path = inventory.path(role)?.to_str()?.to_string();
558                        let expected = limits.inventory.get(*role)?.clone();
559                        Some(crate::runner::protocol::bodies::InventoryEntry {
560                            role: (*role).to_string(),
561                            path,
562                            expected_blake3: expected,
563                        })
564                    })
565                    .collect();
566            let mut exec_grants = Vec::new();
567            let mut read_grants = Vec::new();
568            for entry in &entries {
569                let path = std::path::PathBuf::from(&entry.path);
570                if entry.role == "engine-cli" {
571                    exec_grants.push(path);
572                } else {
573                    read_grants.push(path);
574                }
575            }
576            ImagePixelOcr {
577                mode: "image-ocr",
578                #[cfg(feature = "svg-provider")]
579                svg: None,
580                inventory: entries,
581                // The image worker runs the base jail class: its own
582                // engine grant buys it no allowance measured for a
583                // different engine.
584                runner: build_image_runner(image_runner_limits(&limits), exec_grants, read_grants),
585            }
586        }
587
588        /// The same converter with the svg leg wired: the deployment's
589        /// configured provider components travel to the provider worker
590        /// as role entries, and the runner runs that worker under the
591        /// provider jail class with the measured closures granted.
592        ///
593        /// The recognition half is unchanged: the flattened raster the
594        /// provider returns goes through the identical decode, area
595        /// guard, and engine path a direct raster takes.
596        #[cfg(feature = "svg-provider")]
597        pub fn with_svg_provider(
598            limits: crate::convert::ImageOcrLimits,
599            inventory: &crate::convert::RuntimeInventory,
600            provider: &crate::convert::provider::SvgProvider,
601        ) -> ImagePixelOcr {
602            use crate::runner::protocol::bodies::SvgRoleEntry;
603            // The expectations come from the rules and the path from
604            // the configuration; the wire entry pairs them, and the
605            // worker re-asserts the pair before every execution. A role
606            // the rules do not pin travels with no expectation at all,
607            // so the worker refuses it as missing.
608            let entry = |label: &str, role: &crate::convert::provider::ProviderRole| {
609                let pin = limits.svg_provider.get(label);
610                SvgRoleEntry {
611                    role: label.to_string(),
612                    path: role.path.display().to_string(),
613                    expected_blake3: pin.map(|pin| pin.blake3.clone()).unwrap_or_default(),
614                    version: pin.map(|pin| pin.version.clone()).unwrap_or_default(),
615                    closure_roots: role
616                        .closure_roots
617                        .iter()
618                        .map(|path| path.display().to_string())
619                        .collect(),
620                    closure_blake3: pin.and_then(|pin| pin.closure_blake3.clone()),
621                    jail_temp_env: role.jail_temp_env.clone(),
622                }
623            };
624            let roles = vec![
625                entry(
626                    crate::convert::provider::PROVIDER_ROLE_RASTER,
627                    &provider.raster,
628                ),
629                entry(
630                    crate::convert::provider::PROVIDER_ROLE_ENCODER,
631                    &provider.encoder,
632                ),
633            ];
634            // The literal grants are the two pinned executables. The
635            // closures they load helpers from are the jail's subpath
636            // grants, and the worker asserts an aggregate digest over
637            // each of them before every execution.
638            let exec_grants = vec![provider.raster.path.clone(), provider.encoder.path.clone()];
639            let mut closures = Vec::new();
640            for role in [&provider.raster, &provider.encoder] {
641                closures.extend(role.closure_roots.iter().cloned());
642            }
643            let mut provider_limits = image_runner_limits(&limits);
644            provider_limits.max_response_bytes = PROVIDER_MAX_RESPONSE_BYTES;
645            // The parameters go through the same validating constructor
646            // any caller must use, so the configuration layer and the
647            // render boundary enforce one grammar.
648            let runner = crate::runner::jail::ProviderJail::new(
649                closures,
650                provider.raster.jail_service_prefix.clone(),
651                provider.raster.jail_temp_env.clone(),
652            )
653            .and_then(|jail| {
654                Ok(Runner::with_provider_jail(
655                    crate::runner::jail::platform_backend()?,
656                    crate::runner::locate_worker()?,
657                    provider_limits,
658                    exec_grants,
659                    Vec::new(),
660                    jail,
661                ))
662            });
663            let mut converter = ImagePixelOcr::new(limits, inventory);
664            converter.svg = Some(SvgProviderLeg {
665                roles,
666                runner,
667                #[cfg(feature = "test-adapters")]
668                replace_after_preflight: None,
669            });
670            converter
671        }
672
673        /// The fake-engine converter with the svg leg wired: the
674        /// provider half is the real one, jail and all, and only the
675        /// recognition stage is the deterministic fake. It is how the
676        /// pipeline tests exercise the child lifecycle without a pinned
677        /// vision engine, and the registry never constructs it.
678        #[cfg(all(feature = "svg-provider", feature = "test-adapters"))]
679        pub fn new_fake_with_svg_provider(
680            limits: crate::convert::ImageOcrLimits,
681            provider: &crate::convert::provider::SvgProvider,
682        ) -> ImagePixelOcr {
683            let wired = ImagePixelOcr::with_svg_provider(
684                limits.clone(),
685                &crate::convert::RuntimeInventory::empty(),
686                provider,
687            );
688            let mut converter = ImagePixelOcr::new_fake(limits);
689            converter.svg = wired.svg;
690            converter
691        }
692
693        /// Test seam for the provider record-privacy boundary. It
694        /// replaces one already-preflighted executable with a directory
695        /// immediately before the runner starts its spawn-time checks.
696        /// The seam exists only with `test-adapters`.
697        #[cfg(all(feature = "svg-provider", feature = "test-adapters"))]
698        pub fn new_fake_with_svg_provider_spawn_recheck(
699            limits: crate::convert::ImageOcrLimits,
700            provider: &crate::convert::provider::SvgProvider,
701            replace_after_preflight: std::path::PathBuf,
702        ) -> ImagePixelOcr {
703            let mut converter = ImagePixelOcr::new_fake_with_svg_provider(limits, provider);
704            converter
705                .svg
706                .as_mut()
707                .expect("the provider leg is wired")
708                .replace_after_preflight = Some(replace_after_preflight);
709            converter
710        }
711
712        /// Runs the provider leg for one source and returns the
713        /// flattened raster's bytes. Every assertion runs inside the
714        /// jailed worker, immediately before each of the two external
715        /// executions.
716        #[cfg(feature = "svg-provider")]
717        fn rasterize(&self, source: &[u8]) -> std::result::Result<Vec<u8>, ConvertError> {
718            use crate::runner::protocol::bodies::{SvgRasterOk, SvgRasterRequest};
719            let leg = self.svg.as_ref().ok_or_else(|| ConvertError {
720                code: "unclaimed_format",
721                message: "no svg provider is configured for this converter".to_string(),
722            })?;
723            // Presence is checked here as well as in the worker. The
724            // worker's check is the authority, because it runs in the
725            // cold jail immediately before the execution; this one
726            // exists so an absent component is named as one rather
727            // than surfacing as the jail refusing to grant a path that
728            // is not there.
729            for entry in &leg.roles {
730                let present = std::fs::symlink_metadata(&entry.path)
731                    .is_ok_and(|metadata| metadata.file_type().is_file());
732                if !present {
733                    return Err(ConvertError {
734                        code: "rasterizer-missing",
735                        message: format!("provider component {} is not present", entry.role),
736                    });
737                }
738            }
739            #[cfg(feature = "test-adapters")]
740            if let Some(path) = &leg.replace_after_preflight {
741                std::fs::remove_file(path).map_err(|e| ConvertError {
742                    code: "adapter_spawn_error",
743                    message: format!(
744                        "the provider spawn-check test seam could not remove its executable: {:?}",
745                        e.kind()
746                    ),
747                })?;
748                std::fs::create_dir_all(path).map_err(|e| ConvertError {
749                    code: "adapter_spawn_error",
750                    message: format!(
751                        "the provider spawn-check test seam could not place its directory: {:?}",
752                        e.kind()
753                    ),
754                })?;
755            }
756            let runner = leg.runner.as_ref().map_err(|e| runner_failure(e.clone()))?;
757            let body: SvgRasterOk = call(
758                runner,
759                "svg-raster",
760                &SvgRasterRequest {
761                    input: crate::convert::svg_raster::JAIL_SOURCE.to_string(),
762                    roles: leg.roles.clone(),
763                },
764                &[(crate::convert::svg_raster::JAIL_SOURCE, source)],
765            )?;
766            crate::convert::svg_raster::hex_decode(&body.raster_png_hex).ok_or_else(|| {
767                ConvertError {
768                    code: "raster-exec-failed",
769                    message: "the provider returned a raster that does not decode".to_string(),
770                }
771            })
772        }
773
774        /// A test-only converter that drives the harness `image-ocr-fake`
775        /// worker mode instead of the pinned-runtime mode. It shares the
776        /// identical decode, area guards, and outcome mapping; only stage
777        /// 3 is the deterministic fake, which needs no runtime files. The
778        /// registry never constructs this, so a release build's image
779        /// converter always drives the production mode and never ships
780        /// fake recognition.
781        #[cfg(feature = "test-adapters")]
782        pub fn new_fake(limits: crate::convert::ImageOcrLimits) -> ImagePixelOcr {
783            ImagePixelOcr {
784                mode: "image-ocr-fake",
785                #[cfg(feature = "svg-provider")]
786                svg: None,
787                inventory: Vec::new(),
788                runner: build_image_runner(image_runner_limits(&limits), Vec::new(), Vec::new()),
789            }
790        }
791    }
792
793    /// Maps the rules-supplied image-OCR profile onto the runner limits.
794    #[cfg(feature = "image-ocr")]
795    fn image_runner_limits(limits: &crate::convert::ImageOcrLimits) -> crate::runner::Limits {
796        crate::runner::Limits {
797            wall_timeout: std::time::Duration::from_secs(limits.wall_timeout_secs),
798            max_response_bytes: limits.max_response_bytes,
799            max_stderr_bytes: limits.max_stderr_bytes,
800            address_space_bytes: Some(limits.address_space_bytes),
801            cpu_seconds: limits.cpu_seconds,
802            file_size_bytes: limits.file_size_bytes,
803            max_processes: limits.max_processes,
804            max_resident_bytes: None,
805        }
806    }
807
808    /// Builds one runner for the image-OCR jail profile. Called once per
809    /// converter instance, so the ceilings that travel are exactly this
810    /// registry's, never a leftover from an earlier caller.
811    #[cfg(feature = "image-ocr")]
812    fn build_image_runner(
813        limits: crate::runner::Limits,
814        exec_grants: Vec<std::path::PathBuf>,
815        read_grants: Vec<std::path::PathBuf>,
816    ) -> Result<Runner, RunnerError> {
817        Ok(Runner::with_grants(
818            crate::runner::jail::platform_backend()?,
819            crate::runner::locate_worker()?,
820            limits,
821            exec_grants,
822            read_grants,
823            crate::runner::jail::RuntimeProfile::Plain,
824        ))
825    }
826
827    #[cfg(feature = "image-ocr")]
828    impl Converter for ImagePixelOcr {
829        fn id(&self) -> &'static str {
830            IMAGE_PIXEL_OCR_ID
831        }
832
833        fn version(&self) -> &'static str {
834            IMAGE_PIXEL_OCR_VERSION
835        }
836
837        fn convert(
838            &self,
839            source: &[u8],
840            detected_format: &str,
841        ) -> std::result::Result<Outcome, ConvertError> {
842            // A vector source has no pixels of its own. It is claimed
843            // here only when a deployment configured the external
844            // provider; without one the format is not this converter's,
845            // and the pipeline never routes it here at all.
846            #[cfg(feature = "svg-provider")]
847            let is_svg = detected_format == "svg" && self.svg.is_some();
848            #[cfg(not(feature = "svg-provider"))]
849            let is_svg = false;
850            if !is_svg && !matches!(detected_format, "png" | "jpeg" | "webp") {
851                return Err(ConvertError {
852                    code: "unclaimed_format",
853                    message: format!(
854                        "the image-pixel-ocr converter does not handle {detected_format}"
855                    ),
856                });
857            }
858            // The provider leg runs first and on its own. Its raster is
859            // what the recognition path then reads, so an upstream
860            // failure returns here and the recognition worker never
861            // starts.
862            #[cfg(feature = "svg-provider")]
863            let rendered = if is_svg {
864                Some(self.rasterize(source)?)
865            } else {
866                None
867            };
868            #[cfg(feature = "svg-provider")]
869            let (input, staged): (String, &[u8]) = match &rendered {
870                Some(raster) => ("input.png".to_string(), raster.as_slice()),
871                None => (format!("input.{detected_format}"), source),
872            };
873            #[cfg(not(feature = "svg-provider"))]
874            let (input, staged): (String, &[u8]) = (format!("input.{detected_format}"), source);
875            let runner = self
876                .runner
877                .as_ref()
878                .map_err(|e| runner_failure(e.clone()))?;
879            let body: OcrOk = call(
880                runner,
881                self.mode,
882                &OcrRequest {
883                    input: input.clone(),
884                    kind: OcrInput::Image,
885                    inventory: self.inventory.clone(),
886                },
887                &[(&input, staged)],
888            )?;
889            // The worker's own notes (such as the long-edge validation
890            // note) propagate; the span mapping appends any low-confidence
891            // ones and applies the meaningful-text floor.
892            let mut warnings = body.warnings;
893            let (text, low_confidence) = render_ocr_spans(&body.spans, source.len())?;
894            warnings.extend(low_confidence);
895            // The direct raster path keeps the label it always had; the
896            // provider child names its own source, so a consumer can
897            // tell recognized pixels of a rendered vector from the
898            // markup the vector's own primary artifact carries.
899            let segment_source = if is_svg {
900                OCR_SEGMENT_SOURCE_OCR
901            } else {
902                OCR_SEGMENT_SOURCE_DOCUMENT
903            };
904            Ok(Outcome {
905                converter_id: IMAGE_PIXEL_OCR_ID.to_string(),
906                converter_version: IMAGE_PIXEL_OCR_VERSION.to_string(),
907                detected_format: detected_format.to_string(),
908                artifact_kind: ArtifactKind::Ocr,
909                segments: vec![Segment::span(0, text.len(), segment_source)],
910                text,
911                warnings,
912                media: None,
913            })
914        }
915    }
916
917    /// The OCR protocol adapter. Engine-unpinned: reachable only
918    /// through an explicit runner until a rules bump pins an engine.
919    pub struct OcrAdapter {
920        runner: Runner,
921    }
922
923    impl OcrAdapter {
924        /// An adapter over an explicit runner.
925        pub fn new(runner: Runner) -> OcrAdapter {
926            OcrAdapter { runner }
927        }
928    }
929
930    impl Converter for OcrAdapter {
931        fn id(&self) -> &'static str {
932            OCR_ADAPTER_ID
933        }
934
935        fn version(&self) -> &'static str {
936            OCR_ADAPTER_VERSION
937        }
938
939        fn convert(
940            &self,
941            source: &[u8],
942            detected_format: &str,
943        ) -> std::result::Result<Outcome, ConvertError> {
944            if !matches!(detected_format, "png" | "jpeg" | "tiff") {
945                return Err(ConvertError {
946                    code: "unclaimed_format",
947                    message: format!("the OCR adapter does not handle {detected_format}"),
948                });
949            }
950            let input = format!("input.{detected_format}");
951            let body: OcrOk = call(
952                &self.runner,
953                "ocr",
954                &OcrRequest {
955                    input: input.clone(),
956                    kind: OcrInput::Image,
957                    inventory: Vec::new(),
958                },
959                &[(&input, source)],
960            )?;
961            // Propagate the worker's own notes rather than starting a
962            // fresh vec, then append the low-confidence ones.
963            let mut warnings = body.warnings;
964            let mut lines = Vec::new();
965            for (index, span) in body.spans.iter().enumerate() {
966                if !(0.0..=1.0).contains(&span.confidence) {
967                    return Err(ConvertError {
968                        code: "adapter_error",
969                        message: format!(
970                            "span {index} confidence {} is outside 0 to 1",
971                            span.confidence
972                        ),
973                    });
974                }
975                if span.confidence < OCR_CONFIDENCE_WARNING {
976                    warnings.push(format!(
977                        "ocr_low_confidence: span {index} at {:.2}",
978                        span.confidence
979                    ));
980                }
981                lines.push(single_line(&span.text));
982            }
983            let text = normalize_text(&finish_lines(lines));
984            if !source.is_empty() && text.is_empty() {
985                return Err(empty_output(source.len()));
986            }
987            Ok(Outcome {
988                // Recognized text is OCR, not extraction.
989                artifact_kind: ArtifactKind::Ocr,
990                converter_id: OCR_ADAPTER_ID.to_string(),
991                converter_version: OCR_ADAPTER_VERSION.to_string(),
992                detected_format: detected_format.to_string(),
993                segments: vec![Segment::span(0, text.len(), "document")],
994                text,
995                warnings,
996                media: None,
997            })
998        }
999    }
1000
1001    /// The speech transcription adapter: audio decoded in-jail to a
1002    /// native-rate wav the pinned engine transcribes, behind the `[asr]`
1003    /// jail profile.
1004    ///
1005    /// wav, mp3, flac, and m4a stage into the jail by bare name. The
1006    /// worker decodes them with the pure-Rust symphonia crate, asserts
1007    /// the decoded-duration and decoded-size preflights and the silence
1008    /// bound, re-hashes the pinned runtime files, requires the backend
1009    /// probe's accelerator verdict, and execs the engine over the
1010    /// decoded wav. The parent does no audio parsing: it selects the
1011    /// worker mode, stages the bytes, and maps the fenced transcription
1012    /// to the outcome shape with [`ArtifactKind::Transcript`] and the
1013    /// media provenance block. The engine is machine-class scoped, so
1014    /// the transcript carries no cross-platform byte-equality claim;
1015    /// only the pure-Rust decode does.
1016    #[cfg(feature = "audio-asr")]
1017    pub struct AsrAdapter {
1018        /// The worker mode this converter drives. Production always
1019        /// uses the pinned-runtime `asr` mode; the test-only fake
1020        /// constructor selects the harness `asr-fake` mode.
1021        mode: &'static str,
1022        /// The parsed `[asr]` rules profile: the jail ceilings, the
1023        /// pinned language, and the expected runtime hashes.
1024        profile: crate::convert::AsrProfile,
1025        /// The runtime files the deployment wired in, role by role.
1026        inventory: Vec<(String, std::path::PathBuf)>,
1027        /// This instance's own runner over the `[asr]` jail profile,
1028        /// with literal-file grants for the wired runtime.
1029        runner: Result<Runner, RunnerError>,
1030    }
1031
1032    #[cfg(feature = "audio-asr")]
1033    impl AsrAdapter {
1034        /// An adapter over the rules-supplied `[asr]` profile and the
1035        /// deployment's runtime inventory. It drives the production
1036        /// `asr` worker mode, which fails closed with
1037        /// `asr-runtime-missing` until a deployment wires every pinned
1038        /// runtime role.
1039        pub fn new(
1040            profile: crate::convert::AsrProfile,
1041            inventory: &crate::convert::RuntimeInventory,
1042        ) -> AsrAdapter {
1043            let files = pinned_asr_runtime_files(&profile, inventory);
1044            let (exec_grants, read_grants) = asr_grant_lists(&files);
1045            // This is the one production mode that drives the pinned
1046            // accelerator engine, so it alone runs under the
1047            // accelerator jail class. The fake mode below spawns no
1048            // engine and runs under the base class.
1049            AsrAdapter {
1050                mode: "asr",
1051                runner: build_asr_runner(
1052                    &profile,
1053                    exec_grants,
1054                    read_grants,
1055                    crate::runner::jail::RuntimeProfile::Accelerator,
1056                ),
1057                profile,
1058                inventory: files,
1059            }
1060        }
1061
1062        /// A test-only converter that drives the harness `asr-fake`
1063        /// worker mode instead of the pinned-runtime mode. It shares
1064        /// the identical decode, preflight, silence, and outcome
1065        /// layers; only the engine stage is the deterministic fake,
1066        /// which needs no runtime files. The registry never constructs
1067        /// this, so a release build's audio converter always drives the
1068        /// production mode and never ships fake transcription.
1069        #[cfg(feature = "test-adapters")]
1070        pub fn new_fake(profile: crate::convert::AsrProfile) -> AsrAdapter {
1071            AsrAdapter {
1072                mode: "asr-fake",
1073                runner: build_asr_runner(
1074                    &profile,
1075                    Vec::new(),
1076                    Vec::new(),
1077                    crate::runner::jail::RuntimeProfile::Plain,
1078                ),
1079                profile,
1080                inventory: Vec::new(),
1081            }
1082        }
1083    }
1084
1085    /// The runtime files that may reach the jail: only a role whose
1086    /// expected hash the rules pin AND whose path the deployment
1087    /// supplied qualifies. Pin-first is the point: a supplied path for
1088    /// an unpinned role earns no jail grant and no wire entry, so the
1089    /// jail never gains a capability the versioned rules did not
1090    /// authorize, and the worker reports the role missing exactly as
1091    /// if no path had been supplied.
1092    #[cfg(feature = "audio-asr")]
1093    fn pinned_asr_runtime_files(
1094        profile: &crate::convert::AsrProfile,
1095        inventory: &crate::convert::RuntimeInventory,
1096    ) -> Vec<(String, std::path::PathBuf)> {
1097        let mut files = Vec::new();
1098        for role in ASR_FILE_ROLE_LABELS {
1099            if !profile.inventory.contains_key(role) {
1100                continue;
1101            }
1102            if let Some(path) = inventory.path(role) {
1103                files.push((role.to_string(), path.to_path_buf()));
1104            }
1105        }
1106        files
1107    }
1108
1109    /// Grant lists over the pin-filtered files: read and execute for
1110    /// the engine and probe binaries, read only for the weights.
1111    #[cfg(feature = "audio-asr")]
1112    fn asr_grant_lists(
1113        files: &[(String, std::path::PathBuf)],
1114    ) -> (Vec<std::path::PathBuf>, Vec<std::path::PathBuf>) {
1115        let mut exec_grants = Vec::new();
1116        let mut read_grants = Vec::new();
1117        for (role, path) in files {
1118            if role == ASR_ROLE_WEIGHTS {
1119                read_grants.push(path.clone());
1120            } else {
1121                exec_grants.push(path.clone());
1122            }
1123        }
1124        (exec_grants, read_grants)
1125    }
1126
1127    /// Maps the `[asr]` rules profile onto the runner limits. The
1128    /// address space stays unset, because the pinned accelerator
1129    /// runtime reserves a virtual range no sane cap sits above, and
1130    /// the parent-side resident-memory guard is the memory bound.
1131    #[cfg(feature = "audio-asr")]
1132    fn asr_runner_limits(profile: &crate::convert::AsrProfile) -> crate::runner::Limits {
1133        crate::runner::Limits {
1134            wall_timeout: std::time::Duration::from_secs(profile.wall_timeout_secs),
1135            max_response_bytes: profile.max_response_bytes,
1136            max_stderr_bytes: profile.max_stderr_bytes,
1137            address_space_bytes: None,
1138            cpu_seconds: profile.cpu_seconds,
1139            file_size_bytes: profile.file_size_bytes,
1140            max_processes: profile.max_processes,
1141            max_resident_bytes: Some(profile.max_resident_bytes),
1142        }
1143    }
1144
1145    /// Builds one runner for the `[asr]` jail profile with the
1146    /// literal-file runtime grants.
1147    #[cfg(feature = "audio-asr")]
1148    fn build_asr_runner(
1149        profile: &crate::convert::AsrProfile,
1150        exec_grants: Vec<std::path::PathBuf>,
1151        read_grants: Vec<std::path::PathBuf>,
1152        runtime_profile: crate::runner::jail::RuntimeProfile,
1153    ) -> Result<Runner, RunnerError> {
1154        Ok(Runner::with_grants(
1155            crate::runner::jail::platform_backend()?,
1156            crate::runner::locate_worker()?,
1157            asr_runner_limits(profile),
1158            exec_grants,
1159            read_grants,
1160            runtime_profile,
1161        ))
1162    }
1163
1164    #[cfg(feature = "audio-asr")]
1165    impl Converter for AsrAdapter {
1166        fn id(&self) -> &'static str {
1167            ASR_ADAPTER_ID
1168        }
1169
1170        fn version(&self) -> &'static str {
1171            ASR_ADAPTER_VERSION
1172        }
1173
1174        fn convert(
1175            &self,
1176            source: &[u8],
1177            detected_format: &str,
1178        ) -> std::result::Result<Outcome, ConvertError> {
1179            use crate::runner::protocol::bodies::{AsrOk, AsrRequest, InventoryEntry};
1180            if !matches!(detected_format, "wav" | "mp3" | "flac" | "m4a") {
1181                return Err(ConvertError {
1182                    code: "unclaimed_format",
1183                    message: format!("the ASR adapter does not handle {detected_format}"),
1184                });
1185            }
1186            let runner = self
1187                .runner
1188                .as_ref()
1189                .map_err(|e| runner_failure(e.clone()))?;
1190            let input = format!("input.{detected_format}");
1191            // A role travels only when the deployment supplied its path
1192            // and the rules pin its hash. A path that is not valid
1193            // UTF-8 cannot cross the wire and is treated as unsupplied,
1194            // so the worker fails closed on the missing role.
1195            let mut wire_inventory = Vec::new();
1196            for (role, path) in &self.inventory {
1197                let (Some(path), Some(expected)) =
1198                    (path.to_str(), self.profile.inventory.get(role))
1199                else {
1200                    continue;
1201                };
1202                wire_inventory.push(InventoryEntry {
1203                    role: role.clone(),
1204                    path: path.to_string(),
1205                    expected_blake3: expected.clone(),
1206                });
1207            }
1208            let body: AsrOk = call(
1209                runner,
1210                self.mode,
1211                &AsrRequest {
1212                    input: input.clone(),
1213                    language: Some(self.profile.language.clone()),
1214                    max_duration_seconds: self.profile.max_duration_seconds,
1215                    inventory: wire_inventory,
1216                },
1217                &[(&input, source)],
1218            )?;
1219            // The equality class fixes the speaker label at 1 on every
1220            // segment: no diarizer runs, so any other value is a
1221            // protocol violation, not information.
1222            if body.segments.iter().any(|segment| segment.speaker != 1) {
1223                return Err(ConvertError {
1224                    code: "asr-protocol-error",
1225                    message: "a transcription segment carries an unexpected speaker label"
1226                        .to_string(),
1227                });
1228            }
1229            let text = normalize_text(&render_transcript(&body.segments).map_err(|detail| {
1230                ConvertError {
1231                    code: "adapter_error",
1232                    message: detail,
1233                }
1234            })?);
1235            if !source.is_empty() && text.is_empty() {
1236                return Err(empty_output(source.len()));
1237            }
1238            // The media provenance: decoded duration, the pinned
1239            // language, and the engine identity by role label and the
1240            // rules-pinned hashes. speaker_count stays absent: no
1241            // diarizer ran, so no speaker count is known, and the
1242            // fixed display label is not a claim of one.
1243            let media = crate::manifest::Media {
1244                duration_seconds: body.duration_seconds,
1245                language: body
1246                    .language
1247                    .clone()
1248                    .or_else(|| Some(self.profile.language.clone())),
1249                model_id: Some(ASR_ROLE_WEIGHTS.to_string()),
1250                model_hash: self.profile.inventory.get(ASR_ROLE_WEIGHTS).cloned(),
1251                decode_options_hash: self.profile.inventory.get(ASR_ROLE_DECODE_POLICY).cloned(),
1252                speaker_count: None,
1253            };
1254            Ok(Outcome {
1255                // A speech transcript, not extracted text.
1256                artifact_kind: ArtifactKind::Transcript,
1257                converter_id: ASR_ADAPTER_ID.to_string(),
1258                converter_version: ASR_ADAPTER_VERSION.to_string(),
1259                detected_format: detected_format.to_string(),
1260                segments: vec![Segment::span(0, text.len(), "document")],
1261                text,
1262                warnings: Vec::new(),
1263                media: Some(media),
1264            })
1265        }
1266    }
1267
1268    /// The video protocol adapter. Engine-unpinned.
1269    pub struct VideoAdapter {
1270        runner: Runner,
1271    }
1272
1273    impl VideoAdapter {
1274        /// An adapter over an explicit runner.
1275        pub fn new(runner: Runner) -> VideoAdapter {
1276            VideoAdapter { runner }
1277        }
1278    }
1279
1280    impl Converter for VideoAdapter {
1281        fn id(&self) -> &'static str {
1282            VIDEO_ADAPTER_ID
1283        }
1284
1285        fn version(&self) -> &'static str {
1286            VIDEO_ADAPTER_VERSION
1287        }
1288
1289        fn convert(
1290            &self,
1291            source: &[u8],
1292            detected_format: &str,
1293        ) -> std::result::Result<Outcome, ConvertError> {
1294            if !matches!(detected_format, "mp4" | "mov") {
1295                return Err(ConvertError {
1296                    code: "unclaimed_format",
1297                    message: format!("the video adapter does not handle {detected_format}"),
1298                });
1299            }
1300            let input = format!("input.{detected_format}");
1301            let body: VideoOk = call(
1302                &self.runner,
1303                "video",
1304                &VideoRequest {
1305                    input: input.clone(),
1306                    sample_interval_seconds: 1.0,
1307                    max_frames: 10_000,
1308                },
1309                &[(&input, source)],
1310            )?;
1311            let text = normalize_text(&render_screen_states(&body.states).map_err(|detail| {
1312                ConvertError {
1313                    code: "adapter_error",
1314                    message: detail,
1315                }
1316            })?);
1317            if !source.is_empty() && text.is_empty() {
1318                return Err(empty_output(source.len()));
1319            }
1320            Ok(Outcome {
1321                artifact_kind: ArtifactKind::Text,
1322                converter_id: VIDEO_ADAPTER_ID.to_string(),
1323                converter_version: VIDEO_ADAPTER_VERSION.to_string(),
1324                detected_format: detected_format.to_string(),
1325                segments: vec![Segment::span(0, text.len(), "document")],
1326                text,
1327                warnings: Vec::new(),
1328                media: None,
1329            })
1330        }
1331    }
1332
1333    fn empty_output(source_len: usize) -> ConvertError {
1334        ConvertError {
1335            code: "empty_output",
1336            message: format!("source is {source_len} bytes but conversion produced no text"),
1337        }
1338    }
1339
1340    #[cfg(all(test, feature = "audio-asr"))]
1341    mod audio_grant_tests {
1342        use super::*;
1343
1344        // Pin-first grant assembly: a role the deployment supplies but
1345        // the rules do not pin reaches neither the wire inventory nor
1346        // either jail grant list, so the jail never gains a capability
1347        // the versioned rules did not authorize.
1348        #[test]
1349        fn a_supplied_but_unpinned_role_earns_no_grant_and_no_wire_entry() {
1350            let dir = tempfile::tempdir().unwrap();
1351            let make = |name: &str| {
1352                let path = dir.path().join(name);
1353                std::fs::write(&path, name).unwrap();
1354                path.canonicalize().unwrap()
1355            };
1356            let cli = make("cli");
1357            let weights = make("weights");
1358            let probe = make("probe");
1359            let mut pins = std::collections::BTreeMap::new();
1360            pins.insert(ASR_ROLE_CLI.to_string(), "0".repeat(64));
1361            pins.insert(ASR_ROLE_WEIGHTS.to_string(), "0".repeat(64));
1362            pins.insert(
1363                ASR_ROLE_DECODE_POLICY.to_string(),
1364                crate::hash::hash_bytes(ASR_DECODE_POLICY.as_bytes()),
1365            );
1366            let profile = crate::convert::AsrProfile {
1367                max_duration_seconds: 3600,
1368                wall_timeout_secs: 900,
1369                cpu_seconds: 600,
1370                max_resident_bytes: 6_442_450_944,
1371                max_response_bytes: 4_194_304,
1372                max_stderr_bytes: 4_194_304,
1373                file_size_bytes: 1_207_959_552,
1374                max_processes: 64,
1375                language: "en".to_string(),
1376                inventory: pins,
1377            };
1378            let mut supplied = crate::convert::RuntimeInventory::empty();
1379            supplied.set(ASR_ROLE_CLI, cli.clone()).unwrap();
1380            supplied.set(ASR_ROLE_WEIGHTS, weights.clone()).unwrap();
1381            // The probe role is supplied while this profile carries
1382            // no pin for it, the shape of a custom profile that omits
1383            // the optional probe row.
1384            supplied.set(ASR_ROLE_PROBE, probe.clone()).unwrap();
1385
1386            let files = pinned_asr_runtime_files(&profile, &supplied);
1387            assert_eq!(files.len(), 2, "{files:?}");
1388            assert!(files.iter().all(|(role, _)| role != ASR_ROLE_PROBE));
1389            let (exec_grants, read_grants) = asr_grant_lists(&files);
1390            assert_eq!(exec_grants, vec![cli.clone()]);
1391            assert_eq!(read_grants, vec![weights.clone()]);
1392
1393            // The built runner's jail spec carries exactly those
1394            // lists, so the excluded role is provably absent from the
1395            // spawn capabilities too.
1396            let adapter = AsrAdapter::new(profile, &supplied);
1397            let runner = adapter
1398                .runner
1399                .as_ref()
1400                .expect("this platform has a jail backend");
1401            let (spec_exec, spec_read) = runner.grant_lists();
1402            assert_eq!(spec_exec, std::slice::from_ref(&cli));
1403            assert_eq!(spec_read, std::slice::from_ref(&weights));
1404            assert!(!spec_exec.contains(&probe) && !spec_read.contains(&probe));
1405            // 128 test (c), audio half: the production transcription
1406            // spec runs under the accelerator class.
1407            assert_eq!(
1408                runner.runtime_profile(),
1409                crate::runner::jail::RuntimeProfile::Accelerator
1410            );
1411        }
1412
1413        // The fake transcription mode spawns no engine, so it never
1414        // asks for the accelerator allowances.
1415        #[cfg(feature = "test-adapters")]
1416        #[test]
1417        fn the_fake_transcription_mode_keeps_the_base_profile() {
1418            let profile = crate::convert::AsrProfile {
1419                max_duration_seconds: 3600,
1420                wall_timeout_secs: 900,
1421                cpu_seconds: 600,
1422                max_resident_bytes: 6_442_450_944,
1423                max_response_bytes: 4_194_304,
1424                max_stderr_bytes: 4_194_304,
1425                file_size_bytes: 1_207_959_552,
1426                max_processes: 64,
1427                language: "en".to_string(),
1428                inventory: std::collections::BTreeMap::new(),
1429            };
1430            let adapter = AsrAdapter::new_fake(profile);
1431            let runner = adapter
1432                .runner
1433                .as_ref()
1434                .expect("this platform has a jail backend");
1435            assert_eq!(
1436                runner.runtime_profile(),
1437                crate::runner::jail::RuntimeProfile::Plain
1438            );
1439        }
1440    }
1441
1442    #[cfg(all(test, feature = "svg-provider"))]
1443    mod svg_provider_grant_tests {
1444        use super::*;
1445
1446        /// The provider leg's jail carries exactly what the
1447        /// configuration named: the two pinned executables as literal
1448        /// grants, their closures as the jail's subpath grants, the
1449        /// deployment's service namespace, and the provider class. A
1450        /// capability none of those named cannot reach the jail.
1451        #[test]
1452        fn the_provider_leg_grants_exactly_what_the_configuration_named() {
1453            let dir = tempfile::tempdir().unwrap();
1454            let root = dir.path().canonicalize().unwrap();
1455            let make = |name: &str| {
1456                let path = root.join(name);
1457                std::fs::write(&path, name).unwrap();
1458                path
1459            };
1460            let raster = make("raster");
1461            let encoder = make("encoder");
1462            // The closure holds the executable, as every enumerated
1463            // closure must.
1464            let closure = root.clone();
1465            let text = format!(
1466                "schema = \"{schema}\"\n\n\
1467                 [providers.svg.\"{raster_role}\"]\n\
1468                 path = \"{raster}\"\n\
1469                 closure_roots = [\"{closure}\"]\n\
1470                 jail_service_prefix = \"^ex\\\\.ample\\\\.\"\n\
1471                 jail_temp_env = \"EXAMPLE_TMPDIR\"\n\n\
1472                 [providers.svg.\"{encoder_role}\"]\n\
1473                 path = \"{encoder}\"\n",
1474                schema = crate::convert::provider::PROVIDER_SCHEMA,
1475                raster_role = crate::convert::provider::PROVIDER_ROLE_RASTER,
1476                encoder_role = crate::convert::provider::PROVIDER_ROLE_ENCODER,
1477                raster = raster.display(),
1478                encoder = encoder.display(),
1479                closure = closure.display(),
1480            );
1481            let mut limits = crate::convert::ImageOcrLimits::default();
1482            for (label, version) in [
1483                (crate::convert::provider::PROVIDER_ROLE_RASTER, "1.2.3"),
1484                (crate::convert::provider::PROVIDER_ROLE_ENCODER, "4.5.6"),
1485            ] {
1486                limits.svg_provider.insert(
1487                    label.to_string(),
1488                    crate::convert::provider::ProviderPin {
1489                        blake3: "0".repeat(64),
1490                        version: version.to_string(),
1491                        closure_blake3: None,
1492                    },
1493                );
1494            }
1495            let config = crate::convert::provider::ProviderConfig::parse(&text).unwrap();
1496            let svg = config.svg().expect("both roles");
1497            let adapter = ImagePixelOcr::with_svg_provider(
1498                limits,
1499                &crate::convert::RuntimeInventory::empty(),
1500                svg,
1501            );
1502            let leg = adapter.svg.as_ref().expect("the leg is wired");
1503            let runner = leg
1504                .runner
1505                .as_ref()
1506                .expect("this platform has a jail backend");
1507            let (exec, read) = runner.grant_lists();
1508            assert_eq!(exec, [raster.clone(), encoder.clone()]);
1509            assert!(read.is_empty(), "{read:?}");
1510            assert_eq!(
1511                runner.runtime_profile(),
1512                crate::runner::jail::RuntimeProfile::Provider
1513            );
1514            let jail = runner.provider_jail().expect("the provider parameters");
1515            assert_eq!(jail.closures(), std::slice::from_ref(&closure));
1516            let _ = &encoder;
1517            assert_eq!(jail.service_prefix(), Some(r"^ex\.ample\."));
1518            assert_eq!(jail.temp_env(), Some("EXAMPLE_TMPDIR"));
1519            // The role entries the worker re-verifies carry the pins
1520            // and never a product name of their own.
1521            let roles: Vec<&str> = leg.roles.iter().map(|entry| entry.role.as_str()).collect();
1522            assert_eq!(
1523                roles,
1524                vec![
1525                    crate::convert::provider::PROVIDER_ROLE_RASTER,
1526                    crate::convert::provider::PROVIDER_ROLE_ENCODER
1527                ]
1528            );
1529            assert_eq!(leg.roles[0].version, "1.2.3");
1530            assert_eq!(leg.roles[1].version, "4.5.6");
1531        }
1532
1533        /// The direct raster path is unchanged by a configured
1534        /// provider: its own runner keeps the base class, so the
1535        /// widened profile reaches only the leg it was measured for.
1536        #[test]
1537        fn a_configured_provider_never_moves_the_raster_path_off_the_base_class() {
1538            let adapter = ImagePixelOcr::new(
1539                crate::convert::ImageOcrLimits::default(),
1540                &crate::convert::RuntimeInventory::empty(),
1541            );
1542            let runner = adapter
1543                .runner
1544                .as_ref()
1545                .expect("this platform has a jail backend");
1546            assert_eq!(
1547                runner.runtime_profile(),
1548                crate::runner::jail::RuntimeProfile::Plain
1549            );
1550            assert!(runner.provider_jail().is_none());
1551        }
1552    }
1553
1554    #[cfg(all(test, feature = "image-ocr"))]
1555    mod image_grant_tests {
1556        use super::*;
1557
1558        // The image worker may carry an executable grant for its own
1559        // engine, and that grant alone must never buy the accelerator
1560        // allowances: the flag stays off for this mode, so its jail
1561        // renders the base profile plus its literal-file lines only.
1562        #[test]
1563        fn an_image_worker_with_an_engine_grant_never_asks_for_accelerator_allowances() {
1564            let dir = tempfile::tempdir().unwrap();
1565            let engine = dir.path().join("engine");
1566            std::fs::write(&engine, b"engine bytes").unwrap();
1567            let engine = engine.canonicalize().unwrap();
1568            let mut limits = crate::convert::ImageOcrLimits::default();
1569            limits
1570                .inventory
1571                .insert("engine-cli".to_string(), "0".repeat(64));
1572            let mut supplied = crate::convert::RuntimeInventory::empty();
1573            supplied.set("engine-cli", engine.clone()).unwrap();
1574            let adapter = ImagePixelOcr::new(limits, &supplied);
1575            let runner = adapter
1576                .runner
1577                .as_ref()
1578                .expect("this platform has a jail backend");
1579            let (spec_exec, _) = runner.grant_lists();
1580            assert_eq!(spec_exec, std::slice::from_ref(&engine));
1581            // 128 test (c), image half: an executable grant of its own
1582            // never moves this spec off the base class.
1583            assert_eq!(
1584                runner.runtime_profile(),
1585                crate::runner::jail::RuntimeProfile::Plain
1586            );
1587        }
1588    }
1589}
1590
1591#[cfg(all(unix, feature = "audio-asr"))]
1592pub use imp::AsrAdapter;
1593#[cfg(all(unix, feature = "image-metadata"))]
1594pub use imp::ImageMetadata;
1595#[cfg(all(unix, feature = "image-ocr"))]
1596pub use imp::ImagePixelOcr;
1597#[cfg(unix)]
1598pub use imp::{OcrAdapter, PdfSubprocess, RecordsSubprocess, VideoAdapter};
1599
1600#[cfg(not(unix))]
1601mod imp {
1602    use super::*;
1603    use crate::convert::RecordsLimits;
1604
1605    fn no_backend() -> ConvertError {
1606        ConvertError {
1607            code: "sandbox_unavailable",
1608            message: "no jail backend exists for this platform, refusing to run adapters"
1609                .to_string(),
1610        }
1611    }
1612
1613    /// The subprocess PDF adapter on a platform without a jail
1614    /// backend. It refuses every run.
1615    pub struct PdfSubprocess;
1616
1617    impl Converter for PdfSubprocess {
1618        fn id(&self) -> &'static str {
1619            PDF_SUBPROCESS_ID
1620        }
1621
1622        fn version(&self) -> &'static str {
1623            PDF_SUBPROCESS_VERSION
1624        }
1625
1626        fn convert(
1627            &self,
1628            _source: &[u8],
1629            _detected_format: &str,
1630        ) -> std::result::Result<Outcome, ConvertError> {
1631            Err(no_backend())
1632        }
1633    }
1634
1635    /// The records worker adapter on a platform without a jail
1636    /// backend. It refuses every run.
1637    pub struct RecordsSubprocess;
1638
1639    impl RecordsSubprocess {
1640        /// An adapter that refuses every run, ceilings unused.
1641        pub fn new(_limits: RecordsLimits) -> RecordsSubprocess {
1642            RecordsSubprocess
1643        }
1644    }
1645
1646    impl Converter for RecordsSubprocess {
1647        fn id(&self) -> &'static str {
1648            RECORDS_SUBPROCESS_ID
1649        }
1650
1651        fn version(&self) -> &'static str {
1652            RECORDS_SUBPROCESS_VERSION
1653        }
1654
1655        fn convert(
1656            &self,
1657            _source: &[u8],
1658            _detected_format: &str,
1659        ) -> std::result::Result<Outcome, ConvertError> {
1660            Err(no_backend())
1661        }
1662    }
1663}
1664
1665#[cfg(not(unix))]
1666pub use imp::{PdfSubprocess, RecordsSubprocess};
1667
1668/// Renders speech segments to the transcript format:
1669/// `[HH:MM:SS -> HH:MM:SS] Speaker N: text`, one line per segment,
1670/// ordered by start time.
1671pub fn render_transcript(segments: &[crate::runner_bodies::AsrSegment]) -> Result<String, String> {
1672    let mut ordered: Vec<_> = segments.iter().collect();
1673    ordered.sort_by(|a, b| {
1674        a.start_seconds
1675            .total_cmp(&b.start_seconds)
1676            .then(a.end_seconds.total_cmp(&b.end_seconds))
1677            .then(a.speaker.cmp(&b.speaker))
1678    });
1679    let mut lines = Vec::new();
1680    for segment in ordered {
1681        if segment.end_seconds < segment.start_seconds {
1682            return Err(format!(
1683                "segment ends at {} before it starts at {}",
1684                segment.end_seconds, segment.start_seconds
1685            ));
1686        }
1687        lines.push(format!(
1688            "[{} -> {}] Speaker {}: {}",
1689            format_timecode(segment.start_seconds)?,
1690            format_timecode(segment.end_seconds)?,
1691            segment.speaker,
1692            single_line(&segment.text)
1693        ));
1694    }
1695    Ok(finish_lines(lines))
1696}
1697
1698/// Renders deduplicated screen states to the transcript format:
1699/// `[on-screen @ HH:MM:SS] text`, ordered by first appearance, with
1700/// consecutive identical text collapsed.
1701pub fn render_screen_states(
1702    states: &[crate::runner_bodies::ScreenState],
1703) -> Result<String, String> {
1704    let mut ordered: Vec<_> = states.iter().collect();
1705    ordered.sort_by(|a, b| {
1706        a.first_seen_seconds
1707            .total_cmp(&b.first_seen_seconds)
1708            .then(a.text.cmp(&b.text))
1709    });
1710    let mut lines = Vec::new();
1711    let mut previous: Option<String> = None;
1712    for state in ordered {
1713        let text = single_line(&state.text);
1714        if previous.as_deref() == Some(text.as_str()) {
1715            continue;
1716        }
1717        lines.push(format!(
1718            "[on-screen @ {}] {}",
1719            format_timecode(state.first_seen_seconds)?,
1720            text
1721        ));
1722        previous = Some(text);
1723    }
1724    Ok(finish_lines(lines))
1725}
1726
1727fn finish_lines(lines: Vec<String>) -> String {
1728    if lines.is_empty() {
1729        String::new()
1730    } else {
1731        let mut text = lines.join("\n");
1732        text.push('\n');
1733        text
1734    }
1735}
1736
1737/// Renders recognized spans to normalized text plus the low-confidence
1738/// warnings, applying two floors so a well-formed but content-free
1739/// recognition cannot pass as a blank success.
1740///
1741/// A span confidence outside 0 to 1 is a protocol error. A span under
1742/// [`OCR_CONFIDENCE_WARNING`] adds one low-confidence warning. After the
1743/// lines are joined and normalized, the meaningful-text floor applies:
1744/// on a non-empty source, text with no meaningful character (only
1745/// whitespace, control, or invisible-format code points, exactly the
1746/// class the PDF recovery path gates on) fails closed with
1747/// `empty_output` rather than emitting a blank artifact. This is where a
1748/// well-formed but empty recognition envelope is caught: the envelope
1749/// grammar keeps `ocr-protocol-error` for malformed stdout, and an
1750/// empty-but-well-formed recognition lands here as `empty_output`.
1751#[cfg(all(unix, feature = "image-ocr"))]
1752pub(crate) fn render_ocr_spans(
1753    spans: &[crate::runner::protocol::bodies::OcrSpan],
1754    source_len: usize,
1755) -> Result<(String, Vec<String>), ConvertError> {
1756    let mut warnings = Vec::new();
1757    let mut lines = Vec::new();
1758    for (index, span) in spans.iter().enumerate() {
1759        if !(0.0..=1.0).contains(&span.confidence) {
1760            return Err(ConvertError {
1761                code: "ocr-protocol-error",
1762                message: format!(
1763                    "span {index} confidence {} is outside 0 to 1",
1764                    span.confidence
1765                ),
1766            });
1767        }
1768        if span.confidence < OCR_CONFIDENCE_WARNING {
1769            warnings.push(format!(
1770                "ocr_low_confidence: span {index} at {:.2}",
1771                span.confidence
1772            ));
1773        }
1774        lines.push(single_line(&span.text));
1775    }
1776    let text = normalize_text(&finish_lines(lines));
1777    if source_len > 0 && !text.chars().any(crate::convert::is_meaningful) {
1778        return Err(ConvertError {
1779            code: "empty_output",
1780            message: format!("source is {source_len} bytes but conversion produced no text"),
1781        });
1782    }
1783    Ok((text, warnings))
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788    use super::*;
1789    use crate::runner_bodies::{AsrSegment, ScreenState};
1790
1791    #[test]
1792    fn recovered_pdf_text_is_recorded_under_the_recovery_id() {
1793        // The markdown path keeps the adapter id; a recovered document
1794        // names the recovery path, and both share the adapter version
1795        // so the checkpoint key still skips an unchanged recovered PDF.
1796        assert_eq!(
1797            pdf_converter_identity(false),
1798            (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
1799        );
1800        assert_eq!(
1801            pdf_converter_identity(true),
1802            (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
1803        );
1804        assert_ne!(PDF_RECOVERED_ID, PDF_SUBPROCESS_ID);
1805        assert_eq!(PDF_RECOVERED_VERSION, PDF_SUBPROCESS_VERSION);
1806    }
1807
1808    #[test]
1809    fn timecodes_render_as_hours_minutes_seconds() {
1810        assert_eq!(format_timecode(0.0).unwrap(), "00:00:00");
1811        assert_eq!(format_timecode(12.9).unwrap(), "00:00:12");
1812        assert_eq!(format_timecode(182.0).unwrap(), "00:03:02");
1813        assert_eq!(format_timecode(3661.0).unwrap(), "01:01:01");
1814        assert!(format_timecode(-1.0).is_err());
1815        assert!(format_timecode(f64::NAN).is_err());
1816    }
1817
1818    #[test]
1819    fn transcripts_render_one_ordered_line_per_segment() {
1820        let segments = vec![
1821            AsrSegment {
1822                start_seconds: 19.0,
1823                end_seconds: 24.0,
1824                speaker: 2,
1825                text: "second\nline".to_string(),
1826            },
1827            AsrSegment {
1828                start_seconds: 12.0,
1829                end_seconds: 19.0,
1830                speaker: 1,
1831                text: "first remark".to_string(),
1832            },
1833        ];
1834        assert_eq!(
1835            render_transcript(&segments).unwrap(),
1836            "[00:00:12 -> 00:00:19] Speaker 1: first remark\n\
1837             [00:00:19 -> 00:00:24] Speaker 2: second line\n"
1838        );
1839    }
1840
1841    #[test]
1842    fn transcripts_reject_a_segment_that_ends_before_it_starts() {
1843        let segments = vec![AsrSegment {
1844            start_seconds: 5.0,
1845            end_seconds: 4.0,
1846            speaker: 1,
1847            text: "impossible".to_string(),
1848        }];
1849        assert!(render_transcript(&segments).is_err());
1850    }
1851
1852    #[test]
1853    fn screen_states_render_deduplicated_in_order() {
1854        let states = vec![
1855            ScreenState {
1856                first_seen_seconds: 182.0,
1857                text: "Quarterly revenue".to_string(),
1858            },
1859            ScreenState {
1860                first_seen_seconds: 10.0,
1861                text: "Agenda".to_string(),
1862            },
1863            ScreenState {
1864                first_seen_seconds: 200.0,
1865                text: "Quarterly revenue".to_string(),
1866            },
1867        ];
1868        assert_eq!(
1869            render_screen_states(&states).unwrap(),
1870            "[on-screen @ 00:00:10] Agenda\n[on-screen @ 00:03:02] Quarterly revenue\n"
1871        );
1872    }
1873}