use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::ontology::extract::gguf::{model, Rung};
use crate::ontology::rdf::{iri, Graph, Term, RDF_TYPE};
pub const SCHEMA: &str = "apr-model-ladder-receipt/v1";
pub const SCHEMA_V2: &str = "apr-model-ladder-receipt/v2";
pub const SCHEMAS: [&str; 2] = [SCHEMA, SCHEMA_V2];
pub const EVIDENCE_DIR: &str = "evidence/dogfood/models";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub id: String,
pub present: bool,
pub sha_ok: bool,
pub sha256: Option<String>,
pub required: bool,
pub green: bool,
pub capability_passed: bool,
pub backends: BTreeMap<String, (bool, bool)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InventoryItem {
pub file: String,
pub sha256: Option<String>,
pub arch: Option<String>,
pub quant: Option<String>,
pub context_length: Option<u64>,
pub thinking_modes: Option<Vec<String>>,
pub thinking_markers: Option<Vec<String>>,
pub chat_template_sha256: Option<String>,
pub owes_long_rungs: Option<bool>,
pub weights_bytes: Option<u64>,
pub kv_bytes_per_token: Option<u64>,
pub workspace_bytes: Option<u64>,
pub kv_dtype: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CellRow {
pub sha256: Option<String>,
pub file: String,
pub verb: String,
pub thinking: String,
pub context: String,
pub prompt_tokens: Option<u64>,
pub max_tokens: Option<u64>,
pub think_closed: Option<bool>,
pub answer_chars: Option<u64>,
pub ttft_ms: Option<f64>,
pub required_bytes: Option<u64>,
pub available_bytes: Option<u64>,
pub verdict: String,
pub backend: String,
pub fallback: Option<bool>,
pub rc: Option<i64>,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Receipt {
pub file: String,
pub schema: String,
pub host: String,
pub version: String,
pub sha: String,
pub apr_sha: Option<String>,
pub cc: String,
pub gpu: String,
pub gpu_mem_total_bytes: Option<u64>,
pub gpu_mem_free_bytes: Option<u64>,
pub rows: Vec<Row>,
pub inventory: Vec<InventoryItem>,
pub cells: Vec<CellRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReceiptError {
pub file: String,
pub what: String,
}
impl std::fmt::Display for ReceiptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.file, self.what)
}
}
impl std::error::Error for ReceiptError {}
pub fn read_all(root: &Path) -> Result<Vec<Receipt>, ReceiptError> {
read_dir(&root.join(EVIDENCE_DIR), root)
}
pub fn read_dir(dir: &Path, root: &Path) -> Result<Vec<Receipt>, ReceiptError> {
let mut files = Vec::new();
walk(dir, &mut files);
files.sort();
let mut out = Vec::new();
for f in files {
let rel = f
.strip_prefix(root)
.unwrap_or(&f)
.to_string_lossy()
.replace('\\', "/");
let text = std::fs::read_to_string(&f).map_err(|e| ReceiptError {
file: rel.clone(),
what: format!("unreadable: {e}"),
})?;
out.push(parse(&rel, &text)?);
}
Ok(out)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().and_then(|x| x.to_str()) == Some("json") {
out.push(p);
}
}
}
pub fn parse(file: &str, text: &str) -> Result<Receipt, ReceiptError> {
let err = |what: String| ReceiptError {
file: file.to_string(),
what,
};
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| err(format!("not JSON: {e}")))?;
let schema = v
.get("schema")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !SCHEMAS.contains(&schema) {
return Err(err(format!(
"schema {schema:?} is not {} — refused by name",
SCHEMAS.join(" or ")
)));
}
let s = |k: &str| {
v.get(k)
.map(|x| match x {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.unwrap_or_default()
};
Ok(Receipt {
file: file.to_string(),
schema: schema.to_string(),
host: s("host"),
version: s("version"),
sha: s("sha"),
apr_sha: str_of(&v, "apr_sha").map(|x| x.to_ascii_lowercase()),
cc: s("cc"),
gpu: s("gpu"),
gpu_mem_total_bytes: v
.get("gpu_mem_total_bytes")
.and_then(serde_json::Value::as_u64),
gpu_mem_free_bytes: v
.get("gpu_mem_free_bytes")
.and_then(serde_json::Value::as_u64),
rows: list(&v, "rungs").map(parse_row).collect(),
inventory: list(&v, "inventory").map(parse_inventory_item).collect(),
cells: list(&v, "cells").map(parse_cell).collect(),
})
}
fn list<'a>(v: &'a serde_json::Value, key: &str) -> impl Iterator<Item = &'a serde_json::Value> {
v.get(key)
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
}
fn str_of(v: &serde_json::Value, key: &str) -> Option<String> {
v.get(key)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
}
fn bool_of(v: &serde_json::Value, key: &str) -> bool {
v.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn gate_passed(cap: Option<&serde_json::Value>) -> bool {
cap.is_some_and(|c| bool_of(c, "passed") && !bool_of(c, "skipped"))
}
fn parse_row(r: &serde_json::Value) -> Row {
let mut backends = BTreeMap::new();
if let Some(bs) = r.get("backends").and_then(serde_json::Value::as_object) {
for (name, st) in bs {
backends.insert(name.clone(), (bool_of(st, "ran"), bool_of(st, "fallback")));
}
}
Row {
id: str_of(r, "id").unwrap_or_default(),
present: bool_of(r, "present"),
sha_ok: bool_of(r, "sha_ok"),
sha256: str_of(r, "sha256").map(|x| x.to_ascii_lowercase()),
required: bool_of(r, "required"),
green: bool_of(r, "green"),
capability_passed: gate_passed(r.get("capability_match")),
backends,
}
}
fn parse_inventory_item(r: &serde_json::Value) -> InventoryItem {
InventoryItem {
file: str_of(r, "file").unwrap_or_default(),
sha256: str_of(r, "sha256").map(|x| x.to_ascii_lowercase()),
arch: str_of(r, "arch"),
quant: str_of(r, "quant"),
context_length: r.get("context_length").and_then(serde_json::Value::as_u64),
thinking_modes: strings(r, "thinking_modes"),
thinking_markers: strings(r, "thinking_markers"),
chat_template_sha256: str_of(r, "chat_template_sha256"),
owes_long_rungs: r
.get("owes_long_rungs")
.and_then(serde_json::Value::as_bool),
weights_bytes: r.get("weights_bytes").and_then(serde_json::Value::as_u64),
kv_bytes_per_token: r
.get("kv_bytes_per_token")
.and_then(serde_json::Value::as_u64),
workspace_bytes: r.get("workspace_bytes").and_then(serde_json::Value::as_u64),
kv_dtype: str_of(r, "kv_dtype"),
}
}
fn parse_cell(r: &serde_json::Value) -> CellRow {
CellRow {
sha256: str_of(r, "sha256").map(|x| x.to_ascii_lowercase()),
file: str_of(r, "file").unwrap_or_default(),
verb: str_of(r, "verb").unwrap_or_default(),
thinking: str_of(r, "thinking").unwrap_or_default(),
context: str_of(r, "context").unwrap_or_default(),
prompt_tokens: r.get("prompt_tokens").and_then(serde_json::Value::as_u64),
max_tokens: r.get("max_tokens").and_then(serde_json::Value::as_u64),
think_closed: r.get("think_closed").and_then(serde_json::Value::as_bool),
answer_chars: r.get("answer_chars").and_then(serde_json::Value::as_u64),
ttft_ms: r.get("ttft_ms").and_then(serde_json::Value::as_f64),
required_bytes: r.get("required_bytes").and_then(serde_json::Value::as_u64),
available_bytes: r.get("available_bytes").and_then(serde_json::Value::as_u64),
verdict: str_of(r, "verdict").unwrap_or_default(),
backend: str_of(r, "backend").unwrap_or_default(),
fallback: r.get("fallback").and_then(serde_json::Value::as_bool),
rc: r.get("rc").and_then(serde_json::Value::as_i64),
reason: str_of(r, "reason").unwrap_or_default(),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolveStats {
pub receipts: usize,
pub witnesses: usize,
pub hex_mismatches: usize,
pub unmeasured_rows: usize,
pub green_on: usize,
pub missing_green_hosts: usize,
}
pub fn resolve(g: &mut Graph, rungs: &[Rung], receipts: &[Receipt]) -> ResolveStats {
let mut stats = ResolveStats {
receipts: receipts.len(),
..ResolveStats::default()
};
let all_hosts: BTreeSet<String> = receipts.iter().map(|r| r.host.clone()).collect();
for rung in rungs {
let s = iri("model", &rung.sha256);
let green_hosts = witness_rows(g, &s, rung, receipts, &mut stats);
for h in &green_hosts {
stats.green_on += 1;
g.insert(s.clone(), model("greenOn"), Term::string(h));
}
let expected: BTreeSet<String> = if rung.hosts.is_empty() {
all_hosts.clone()
} else {
rung.hosts.iter().cloned().collect()
};
for h in expected.difference(&green_hosts) {
stats.missing_green_hosts += 1;
g.insert(s.clone(), model("missingGreenHost"), Term::string(h));
}
}
stats
}
fn witness_rows(
g: &mut Graph,
s: &str,
rung: &Rung,
receipts: &[Receipt],
stats: &mut ResolveStats,
) -> BTreeSet<String> {
let mut green_hosts: BTreeSet<String> = BTreeSet::new();
for rec in receipts {
for row in rec.rows.iter().filter(|r| r.id == rung.id) {
let Some(hex) = &row.sha256 else {
stats.unmeasured_rows += 1;
g.insert(
s.to_string(),
model("unmeasuredRow"),
Term::string(&rec.file),
);
continue;
};
if hex != &rung.sha256 {
stats.hex_mismatches += 1;
g.insert(
s.to_string(),
model("receiptHexMismatch"),
Term::string(format!("{}: {hex}", rec.file)),
);
continue;
}
stats.witnesses += 1;
let node = emit_receipt_node(g, rec, row, rung);
g.insert(s.to_string(), model("parityReceipt"), Term::iri(node));
if row.green && row.capability_passed && backends_ok(rung, row) {
green_hosts.insert(rec.host.clone());
}
}
}
green_hosts
}
fn backends_ok(rung: &Rung, row: &Row) -> bool {
rung.backends.iter().all(|b| {
row.backends
.get(b)
.is_some_and(|(ran, fallback)| *ran && !*fallback)
})
}
fn emit_receipt_node(g: &mut Graph, rec: &Receipt, row: &Row, rung: &Rung) -> String {
let node = iri(
"receipt",
&format!("{}/{}/{}", rec.version, rec.host, row.id),
);
g.insert(node.clone(), RDF_TYPE, Term::iri(model("Receipt")));
g.insert(node.clone(), model("receiptHost"), Term::string(&rec.host));
g.insert(
node.clone(),
model("receiptVersion"),
Term::string(&rec.version),
);
g.insert(node.clone(), model("receiptFile"), Term::string(&rec.file));
g.insert(node.clone(), model("cc"), Term::string(&rec.cc));
g.insert(node.clone(), model("gpu"), Term::string(&rec.gpu));
g.insert(node.clone(), model("green"), Term::boolean(row.green));
g.insert(
node.clone(),
model("capabilityPassed"),
Term::boolean(row.capability_passed),
);
g.insert(
node.clone(),
model("backendsOk"),
Term::boolean(backends_ok(rung, row)),
);
if let Some(h) = &row.sha256 {
g.insert(node.clone(), model("receiptSha256"), Term::string(h));
}
for (b, (ran, fallback)) in &row.backends {
g.insert(
node.clone(),
model("backendRan"),
Term::string(format!(
"{b}={}",
if *ran && !*fallback {
"ok"
} else if *ran {
"fallback"
} else {
"no"
}
)),
);
}
g.insert(
node.clone(),
model("receiptOf"),
Term::iri(iri("model", &rung.sha256)),
);
node
}
fn strings(r: &serde_json::Value, key: &str) -> Option<Vec<String>> {
r.get(key).and_then(serde_json::Value::as_array).map(|a| {
a.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn rung(id: &str, sha: &str, hosts: &[&str], required: bool) -> Rung {
Rung {
id: id.into(),
sha256: sha.into(),
arch: "qwen2".into(),
gguf: format!("{id}.gguf"),
backends: vec!["cpu".into(), "cuda".into()],
hosts: hosts.iter().map(|h| (*h).to_string()).collect(),
required,
contract: "ladder".into(),
}
}
fn receipt(host: &str, cc: &str, rows: &str) -> Receipt {
parse(
&format!("evidence/dogfood/models/0.68.1/{host}.json"),
&format!(
r#"{{"schema":"apr-model-ladder-receipt/v1","host":"{host}","version":"0.68.1","sha":"abc","cc":"{cc}","gpu":"g","rungs":[{rows}]}}"#
),
)
.expect("parses")
}
const OK: &str = r#"{"id":"a","present":true,"sha_ok":true,"sha256":"aaaa","required":true,"green":true,"capability_match":{"passed":true,"skipped":false},"backends":{"cpu":{"ran":true,"fallback":false},"cuda":{"ran":true,"fallback":false}}}"#;
#[test]
fn a_foreign_schema_is_refused_by_name() {
let e = parse("x.json", r#"{"schema":"something/v9","rungs":[]}"#).expect_err("refused");
assert!(e.what.contains("something/v9"), "{e}");
assert!(e.what.contains(SCHEMA), "{e}");
assert!(e.what.contains(SCHEMA_V2), "{e}");
}
#[test]
fn a_v2_receipt_joins_its_rungs_like_v1_and_carries_inventory_apr_sha_and_cells() {
let row = OK.replace(
r#""id":"a","#,
r#""id":"a","file":"a.gguf","inventory_only":false,"#,
);
let text = format!(
r#"{{"schema":"apr-model-ladder-receipt/v2","host":"lambda","version":"0.69.1","sha":"abc",
"apr_sha":"ABCDEF0123456789ABCDEF0123456789ABCDEF01","cc":"8.9","gpu":"g",
"inventory":[{{"file":"a.gguf","sha256":"AAAA","bytes":4}}],
"cells":[{{"sha256":"aaaa","file":"a.gguf","verb":"chat","context":"golden","prompt_tokens":40,
"verdict":"pass","backend":"cuda","fallback":false,"rc":0,"reason":""}}],
"rungs":[{row}]}}"#
);
let rec = parse("evidence/dogfood/models/0.69.1/lambda.json", &text).expect("v2 parses");
assert_eq!(rec.schema, SCHEMA_V2);
assert_eq!(
rec.apr_sha.as_deref(),
Some("abcdef0123456789abcdef0123456789abcdef01")
);
assert_eq!(rec.inventory.len(), 1);
assert_eq!(rec.inventory[0].sha256.as_deref(), Some("aaaa"));
assert_eq!(rec.cells.len(), 1);
assert_eq!(rec.cells[0].verb, "chat");
assert_eq!(rec.cells[0].fallback, Some(false));
let mut g = Graph::new();
let st = resolve(&mut g, &[rung("a", "aaaa", &[], true)], &[rec]);
assert_eq!((st.witnesses, st.green_on), (1, 1));
let v3 =
parse("x.json", r#"{"schema":"apr-model-ladder-receipt/v3"}"#).expect_err("v3 refused");
assert!(v3.what.contains("v3"), "{v3}");
}
#[test]
fn a_witness_yields_a_receipt_edge_and_a_green_host() {
let mut g = Graph::new();
let r = rung("a", "aaaa", &[], true);
let st = resolve(&mut g, &[r], &[receipt("lambda", "8.9", OK)]);
assert_eq!(st.witnesses, 1);
assert_eq!(st.green_on, 1);
assert_eq!(st.missing_green_hosts, 0);
let s = iri("model", "aaaa");
assert_eq!(g.objects(&s, &model("parityReceipt")).len(), 1);
let node = g.objects(&s, &model("parityReceipt"))[0]
.as_iri()
.expect("iri")
.to_string();
assert_eq!(
g.objects(&node, &model("cc"))[0].as_literal().map(|l| l.0),
Some("8.9")
);
assert!(!g.to_ntriples().contains("_:"));
}
#[test]
fn a_row_without_sha256_is_not_a_witness_and_a_different_hex_is_a_mismatch() {
let mut g = Graph::new();
let r = rung("a", "aaaa", &[], true);
let no_sha = OK.replace(r#""sha256":"aaaa","#, "");
let bad = OK.replace(r#""sha256":"aaaa""#, r#""sha256":"bbbb""#);
let st = resolve(
&mut g,
&[r],
&[
receipt("lambda", "8.9", &no_sha),
receipt("gx10", "12.1", &bad),
],
);
assert_eq!(st.witnesses, 0);
assert_eq!(st.unmeasured_rows, 1);
assert_eq!(st.hex_mismatches, 1);
let s = iri("model", "aaaa");
let m = g.objects(&s, &model("receiptHexMismatch"));
assert_eq!(m.len(), 1);
assert!(m[0]
.as_literal()
.map(|l| l.0)
.unwrap_or("")
.contains("gx10.json: bbbb"));
assert_eq!(st.missing_green_hosts, 2);
}
#[test]
fn a_skipped_capability_gate_or_a_fallback_backend_is_not_green() {
let skipped = OK.replace(r#""skipped":false"#, r#""skipped":true"#);
let fallback = OK.replace(
r#""cuda":{"ran":true,"fallback":false}"#,
r#""cuda":{"ran":true,"fallback":true}"#,
);
for (name, rows) in [("skipped", skipped), ("fallback", fallback)] {
let mut g = Graph::new();
let st = resolve(
&mut g,
&[rung("a", "aaaa", &[], true)],
&[receipt("lambda", "8.9", &rows)],
);
assert_eq!(st.witnesses, 1, "{name}: still a witness");
assert_eq!(st.green_on, 0, "{name}: not green");
assert_eq!(st.missing_green_hosts, 1, "{name}");
}
}
#[test]
fn hosts_scope_the_green_requirement_and_absence_means_every_host_with_a_receipt() {
let scoped = rung("a", "aaaa", &["lambda"], true);
let open = rung("a", "aaaa", &[], true);
let lambda_green = receipt("lambda", "8.9", OK);
let gx10_fallback = receipt(
"gx10",
"12.1",
&OK.replace(
r#""cuda":{"ran":true,"fallback":false}"#,
r#""cuda":{"ran":true,"fallback":true}"#,
),
);
let mut g = Graph::new();
let st = resolve(
&mut g,
&[scoped],
&[lambda_green.clone(), gx10_fallback.clone()],
);
assert_eq!(
st.missing_green_hosts, 0,
"gx10 is not listed, so its red receipt is not this rung's"
);
assert_eq!(st.green_on, 1, "lambda, the one listed host, is green");
let s = iri("model", "aaaa");
assert!(g.objects(&s, &model("missingGreenHost")).is_empty());
let mut g = Graph::new();
let st = resolve(&mut g, &[open], &[lambda_green, gx10_fallback]);
assert_eq!(st.green_on, 1);
assert_eq!(
st.missing_green_hosts, 1,
"no hosts: → both hosts expected, gx10 missing"
);
}
}