interscript_rs/imf/
conformance.rs1use 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
44pub 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 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
103pub 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
123pub 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
150pub 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 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 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 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 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}