open-standards/wai/wai-rs/tests/derive_edge.rs
Transaction Science 033265f8e6 deriving edge: AI-origin labels take the origin claim's terms (assist, generate)
The label a record carries is the top of the order an origin claim
gives its steps (capture < convert < assist < generate), so one
vocabulary serves both; every derivation this revision pins is a convert
step and raises no label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 04:58:07 -04:00

204 lines
10 KiB
Rust

//! `wai.edge.derivation` (extensions/deriving-edge.md): the committed corpus
//! `edge-conformance/vectors.json` regenerates byte for byte from its fixed seeds, every
//! vector in it gets the outcome it states, §9 lists exactly the reasons the reference
//! reports and §4 exactly its derivations; and the reference edge, `wai_edge`, run twice over
//! a publisher's clip, makes a chain of two hops that verifies back to the clip, and that is
//! refused once a byte of it changes. The generator itself asserts that the corpus gives every
//! reason.
#![cfg(feature = "derive_edge")]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{json, Value};
use wai::derive_edge::{corpus, Derivation, Reason};
use wai::jwp_delivery::GroupBuilder;
use wai::jwp_hash::content_hash;
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> BTreeMap<String, String> {
let dir = wai_dir().join("edge-conformance");
std::fs::read_dir(&dir)
.expect("the committed corpus")
.flatten()
.filter(|e| e.path().is_file())
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
.filter(|(n, _)| n != "README.md" && n != "edge_verify.py")
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
.collect()
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
let files = committed();
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_edge_vectors gen`");
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
fn section(text: &str, heading: &str) -> String {
let at = text.find(heading).unwrap_or_else(|| panic!("no {heading}"));
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
text[at..end].to_string()
}
fn first_cells(section: &str) -> Vec<String> {
section.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect()
}
/// §9 lists exactly the codes `Reason::ALL` has, each once, in order; §4's table lists
/// exactly the derivations, in order.
#[test]
fn the_extension_lists_the_reasons_and_the_derivations() {
let text = std::fs::read_to_string(wai_dir().join("extensions/deriving-edge.md")).expect("the extension");
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(first_cells(&section(&text, "\n## 9. Reasons")), want, "§9's table and Reason::ALL differ");
let s4 = section(&text, "\n## 4. The derivations");
let table: String = s4.lines().take_while(|l| !l.starts_with("### ")).collect::<Vec<_>>().join("\n");
let want: Vec<String> = Derivation::ALL.iter().map(|d| d.id().to_string()).collect();
assert_eq!(first_cells(&table), want, "§4's table and Derivation::ALL differ");
}
// ───────────────────────────── the reference edge ─────────────────────────────
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn sha256(b: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(b).into()
}
fn seed(name: &str) -> [u8; 32] {
sha256(format!("wai-edge-test:{name}").as_bytes())
}
fn wai1(manifest: &str, payload: &[u8]) -> Vec<u8> {
let mut out = b"WAI1".to_vec();
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
out.extend_from_slice(manifest.as_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
out
}
/// The C2PA Manifest Store the test binds to the clip: a byte string standing for one, which
/// the edge names by its SHA-256 and does not parse.
const STORE: &[u8] = b"C2PA Manifest Store stand-in bound to the clip";
/// A publisher's bundle: one `wai.video.int_motion` clip of two frames, in a group it signed,
/// with a C2PA Manifest Store bound to it.
fn source_bundle(dir: &Path) -> (PathBuf, String, [u8; 32]) {
let frames: Vec<Vec<u8>> = (0..2u32).map(|f| (0..6 * 4 * 3u32).map(|i| ((i * 11 + f * 50) % 256) as u8).collect()).collect();
let clip = wai::int_video_enc::encode(&frames, 4, 6, &wai::int_video_enc::EncParams { block: 2, search: 1, mv_precision: 1, q: 1 }).unwrap();
let manifest = r#"{"wai":"1.0","media":"video","intent":"replicate","model_requirement":{"capability":"wai.video.int_motion"},"conditioning":{"kind":"int_motion"},"target":{"frames":2,"h":4,"w":6}}"#;
let env = wai1(manifest, &clip.wiv1);
let key = ed25519_dalek::SigningKey::from_bytes(&seed("publisher"));
let mut b = GroupBuilder::default();
b.add(content_hash(&env), 0, "origin", None);
let (g, objs) = b.finish(&key, "did:web:source.example#pub", 1, 0, "2026-10-20T00:00:00Z", "2026-10-20T00:00:00Z", None, false).unwrap();
let bundle = json!({
"records": [],
"receipts": [g.to_json(), objs[0].to_json()],
"objects": { hex(&content_hash(&env)): hex(&env) },
"claims": [],
"c2pa": { hex(&content_hash(&env)): [hex(STORE)] },
});
let path = dir.join("source.json");
std::fs::write(&path, serde_json::to_string(&bundle).unwrap()).unwrap();
(path, hex(&key.verifying_key().to_bytes()), content_hash(&env))
}
fn edge(args: &[&str]) -> (bool, Value) {
let out = Command::new(env!("CARGO_BIN_EXE_wai_edge")).args(args).output().expect("wai_edge runs");
let text = String::from_utf8_lossy(&out.stdout);
let v = serde_json::from_str(text.trim()).unwrap_or_else(|_| panic!("wai_edge {args:?}: {text} {}", String::from_utf8_lossy(&out.stderr)));
(out.status.success(), v)
}
#[test]
fn the_reference_edge_makes_a_chain_that_verifies() {
let dir = std::env::temp_dir().join(format!("wai-edge-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (source, publisher, clip) = source_bundle(&dir);
let s = |p: &Path| p.to_string_lossy().into_owned();
for (name, n) in [("a", "edge-a"), ("b", "edge-b")] {
std::fs::write(dir.join(format!("{name}.key")), hex(&seed(n))).unwrap();
}
// Hop 1: edge A rebuilds frame 1 as a pinned still.
let hop1 = dir.join("hop1.json");
let (ok, made) = edge(&[
"derive", "--bundle", &s(&source), "--out", &s(&hop1), "--key", &s(&dir.join("a.key")),
"--signer-id", "did:web:edge-a.example#derive", "--derivation", "wai.derive.identity",
"--output", "wai.image.png", "--input", &format!("{}@1", hex(&clip)),
"--track", "10", "--group", "0", "--time", "2026-10-20T12:00:00Z", "--source", &publisher,
"--personal", &hex(&sha256(STORE)), "--synthesis", &format!("{}=generate", hex(&clip)),
]);
assert!(ok, "{made}");
let still = made["output"].as_str().unwrap().to_owned();
// Hop 2: edge B crops it, with a metered figure.
let hop2 = dir.join("hop2.json");
let (ok, made) = edge(&[
"derive", "--bundle", &s(&hop1), "--out", &s(&hop2), "--key", &s(&dir.join("b.key")),
"--signer-id", "did:web:edge-b.example#derive", "--derivation", "wai.derive.crop",
"--params", r#"{"x":1,"y":1,"w":4,"h":2}"#, "--output", "wai.image.png", "--input", &still,
"--energy", r#"{"joules_micro":2500,"energy_provenance":"ModelBased","energy_uncertainty":null,"energy_counter_filtering":null,"energy_calibration_ref":null}"#,
"--track", "20", "--group", "0", "--time", "2026-10-20T12:00:01Z", "--source", &publisher,
]);
assert!(ok, "{made}");
let crop = made["output"].as_str().unwrap().to_owned();
let (ok, v) = edge(&["verify", "--bundle", &s(&hop2), "--subject", &crop, "--source", &publisher, "--require-recompute"]);
assert!(ok, "{v}");
let hops = v["facts"]["hops"].as_array().unwrap();
assert_eq!(hops.len(), 2);
assert!(hops.iter().all(|h| h["checked"] == "recomputed" && h["check"] == "replicate"), "{v}");
assert_eq!(v["facts"]["sources"][0]["object"], hex(&clip));
assert_eq!(v["facts"]["energy"], json!([{"class": "ModelBased", "joules_micro": 2500}]));
assert_eq!(v["facts"]["unmetered"], 1);
// The store bound to the clip, carried through both hops in the compartment hop 1 stated,
// and the clip's label with it.
assert_eq!(
v["facts"]["provenance"],
json!([{"input": 0, "kind": "c2pa", "digest": hex(&sha256(STORE)), "compartment": "personal"}])
);
assert!(hops.iter().all(|h| h["synthesis"] == "generate"), "{v}");
// A sink that does not take the publisher as a source finds a hop missing.
let (ok, v) = edge(&["verify", "--bundle", &s(&hop2), "--subject", &crop, "--source", &hex(&[7; 32])]);
assert!(!ok);
assert_eq!(v["reason"], "chain_skips_hop");
// A byte of the still changed: the bundle no longer verifies.
let mut b: Value = serde_json::from_str(&std::fs::read_to_string(&hop2).unwrap()).unwrap();
let env = b["objects"][&still].as_str().unwrap().to_owned();
let flipped = format!("{}{}", &env[..env.len() - 1], if env.ends_with('0') { "1" } else { "0" });
b["objects"][&still] = json!(flipped);
let tampered = dir.join("tampered.json");
std::fs::write(&tampered, serde_json::to_string(&b).unwrap()).unwrap();
let (ok, v) = edge(&["verify", "--bundle", &s(&tampered), "--subject", &crop, "--source", &publisher]);
assert!(!ok);
assert_eq!(v["reason"], "output_hash_mismatch", "{v}");
// The edge does not derive from an input whose chain does not verify.
let out = Command::new(env!("CARGO_BIN_EXE_wai_edge"))
.args([
"derive", "--bundle", &s(&tampered), "--out", &s(&dir.join("never.json")), "--key", &s(&dir.join("a.key")),
"--signer-id", "did:web:edge-a.example#derive", "--derivation", "wai.derive.mirror",
"--params", r#"{"flip":"left-right"}"#, "--output", "wai.image.png", "--input", &still,
"--track", "10", "--group", "1", "--time", "2026-10-20T12:00:02Z", "--source", &publisher,
])
.output()
.unwrap();
assert!(!out.status.success());
let _ = std::fs::remove_dir_all(&dir);
}