Skip to main content

interscript_rs/imf/
conformance.rs

1//! The frozen conformance fixtures (interscript-models/conformance/):
2//! load the vectors files, resolve the referenced artifact, run every
3//! row byte-exactly (`pipeline.conformance.run_conformance` parity).
4//!
5//! Vectors file schema (validated like `validate_vectors`):
6//! `{model, index_version, decode:{mode}, source, vectors:[{src,out}]}`;
7//! `decode.mode` must be `single-row-greedy`. Comparison is byte-exact
8//! โ€” no normalization, no CER tolerance.
9
10use crate::error::{Error, Result};
11use crate::imf::infer::{self, InferenceEngine};
12use crate::imf::loader;
13use serde::Deserialize;
14use std::path::{Path, PathBuf};
15
16fn model_err(what: impl Into<String>) -> Error {
17    Error::Model(what.into())
18}
19
20#[derive(Deserialize)]
21struct VectorsYaml {
22    model: String,
23    #[serde(default)]
24    index_version: Option<u32>,
25    #[serde(default)]
26    decode: DecodeYaml,
27    #[serde(default)]
28    source: String,
29    vectors: Vec<VectorRow>,
30}
31
32#[derive(Deserialize, Default)]
33struct DecodeYaml {
34    #[serde(default)]
35    mode: Option<String>,
36}
37
38#[derive(Deserialize)]
39struct VectorRow {
40    src: String,
41    out: Option<String>,
42}
43
44/// A validated vectors file.
45pub struct Vectors {
46    pub model: String,
47    pub index_version: Option<u32>,
48    pub mode: String,
49    pub source: String,
50    pub rows: Vec<(String, String)>,
51}
52
53impl Vectors {
54    /// `load_vectors` + `validate_vectors` parity: model id required,
55    /// mode must be single-row-greedy, every row needs src and out.
56    pub fn load(path: &Path) -> Result<Vectors> {
57        let raw = std::fs::read_to_string(path)
58            .map_err(|e| model_err(format!("vectors read failed ({}): {e}", path.display())))?;
59        let parsed: VectorsYaml = serde_json::from_str(&raw)            .map_err(|e| model_err(format!("vectors parse failed ({}): {e}", path.display())))?;
60
61        let mut problems: Vec<String> = Vec::new();
62        if parsed.model.is_empty() {
63            problems.push("missing 'model' (the models.yaml id)".into());
64        }
65        let mode = parsed.decode.mode.clone().unwrap_or_default();
66        if mode != "single-row-greedy" {
67            problems.push(format!(
68                "decode.mode must be 'single-row-greedy', got {mode:?}"
69            ));
70        }
71        if parsed.vectors.is_empty() {
72            problems.push("'vectors' is empty โ€” a fixture pins at least one row".into());
73        }
74        for (i, row) in parsed.vectors.iter().enumerate() {
75            if row.src.is_empty() {
76                problems.push(format!("vectors[{i}]: missing 'src'"));
77            }
78            if row.out.is_none() {
79                problems.push(format!("vectors[{i}]: missing 'out' (expected output)"));
80            }
81        }
82        if !problems.is_empty() {
83            return Err(model_err(format!(
84                "invalid conformance vectors: {}",
85                problems.join("; ")
86            )));
87        }
88
89        Ok(Vectors {
90            model: parsed.model,
91            index_version: parsed.index_version,
92            mode,
93            source: parsed.source,
94            rows: parsed
95                .vectors
96                .into_iter()
97                .map(|r| (r.src, r.out.unwrap_or_default()))
98                .collect(),
99        })
100    }
101}
102
103/// Per-row outcome for reporting.
104pub struct Mismatch {
105    pub index: usize,
106    pub src: String,
107    pub expected: String,
108    pub got: String,
109}
110
111pub struct ConformanceResult {
112    pub model: String,
113    pub checked: usize,
114    pub mismatches: Vec<Mismatch>,
115}
116
117impl ConformanceResult {
118    pub fn passed(&self) -> bool {
119        self.mismatches.is_empty()
120    }
121}
122
123/// Run every row through the engine; byte-exact comparison (the
124/// fixture byte rule from the vector generator: `max(0, t-3)`, lossy
125/// UTF-8).
126pub fn run_conformance(
127    vectors: &Vectors,
128    engine: &mut dyn InferenceEngine,
129) -> Result<ConformanceResult> {
130    let mut mismatches = Vec::new();
131    for (i, (src, expected)) in vectors.rows.iter().enumerate() {
132        let ids = infer::greedy_generate(engine, src, infer::MAX_DECODE_STEPS)?;
133        let got = infer::ids_to_fixture_string(&ids);
134        if &got != expected {
135            mismatches.push(Mismatch {
136                index: i,
137                src: src.clone(),
138                expected: expected.clone(),
139                got,
140            });
141        }
142    }
143    Ok(ConformanceResult {
144        model: vectors.model.clone(),
145        checked: vectors.rows.len(),
146        mismatches,
147    })
148}
149
150/// Artifact resolution for a fixture model id, given an optional
151/// artifact directory (flat `<id>.zip` or cache layout
152/// `<id>/*.zip`) and the models.yaml index (renames honored).
153///
154/// Directory lookup matches a zip by file stem OR by the id declared
155/// in its `metadata.yaml` โ€” the renamed-release layout (a zip whose
156/// metadata still carries the pre-rename id, filed under the new id)
157/// resolves under both ids.
158pub fn resolve_artifact(
159    model_id: &str,
160    artifact_dir: Option<&Path>,
161    index: Option<&crate::imf::registry::Index>,
162) -> Result<PathBuf> {
163    if let Some(dir) = artifact_dir {
164        let canonical = index.map(|i| i.canonical_id(model_id).to_string());
165
166        // 1. flat layout: <dir>/<id>.zip
167        for candidate in [model_id, canonical.as_deref().unwrap_or("")].iter() {
168            if candidate.is_empty() {
169                continue;
170            }
171            let p = dir.join(format!("{candidate}.zip"));
172            if p.is_file() {
173                return Ok(p);
174            }
175        }
176        // 2. cache layout: <dir>/<id>/<filename>
177        for candidate in [model_id, canonical.as_deref().unwrap_or("")].iter() {
178            if candidate.is_empty() {
179                continue;
180            }
181            let sub = dir.join(candidate);
182            if sub.is_dir() {
183                if let Some(p) = first_zip(&sub) {
184                    return Ok(p);
185                }
186            }
187        }
188        // 3. metadata-id scan: any zip in the tree whose metadata
189        //    declares the id (covers pre-rename metadata ids).
190        let wanted: Vec<&str> = std::iter::once(model_id)
191            .chain(canonical.as_deref())
192            .collect();
193        let mut zips: Vec<PathBuf> = Vec::new();
194        collect_zips(dir, &mut zips, 0);
195        zips.sort();
196        for zip_path in &zips {
197            if let Ok(manifest) = loader::load_manifest(zip_path) {
198                if wanted.iter().any(|w| *w == manifest.id.as_str()) {
199                    return Ok(zip_path.clone());
200                }
201            }
202        }
203        return Err(model_err(format!(
204            "no artifact for {model_id:?} under {} (looked for <id>.zip, <id>/*.zip, metadata-id match)",
205            dir.display()
206        )));
207    }
208
209    // Registry path: index load + verified cache/download (SPEC ยง2).
210    let index = match index {
211        Some(i) => i.clone(),
212        None => crate::imf::registry::load_index(None)?,
213    };
214    crate::imf::registry::resolve(model_id, &index)
215}
216
217fn first_zip(dir: &Path) -> Option<PathBuf> {
218    let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
219        .ok()?
220        .filter_map(|e| e.ok().map(|e| e.path()))
221        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("zip"))
222        .collect();
223    entries.sort();
224    entries.into_iter().next()
225}
226
227fn collect_zips(dir: &Path, out: &mut Vec<PathBuf>, depth: usize) {
228    if depth > 4 {
229        return;
230    }
231    let entries = match std::fs::read_dir(dir) {
232        Ok(e) => e,
233        Err(_) => return,
234    };
235    for entry in entries.filter_map(|e| e.ok()) {
236        let path = entry.path();
237        if path.is_dir() {
238            collect_zips(&path, out, depth + 1);
239        } else if path.extension().and_then(|e| e.to_str()) == Some("zip") {
240            out.push(path);
241        }
242    }
243}