//! The Agent Trace export (work/provenance): every record validates against the //! specification's own schema (0.1.0, pinned in `tests/fixtures/agent-trace`), and its line //! ranges are exactly the node markers of the generated program, for every corpus graph. //! //! The ranges are checked against this test's own reading of the markers — it walks the //! annotated program line by line rather than calling `node_spans` — and each range's //! contributor against what the test itself wrote into the graph's origins. use flowrs_core::codegen::rust::{self, NODE_END, NODE_MARKER}; use flowrs_core::graph::corpus; use flowrs_core::graph::ir::{Graph, NodeId}; use flowrs_core::graph::provenance::{self, Author, TraceOptions}; use serde_json::Value; use std::collections::BTreeMap; const SCHEMA: &str = "tests/fixtures/agent-trace/trace-record.schema.json"; /// The pin recorded in the fixture's README. const PINNED_SHA256: &str = "f86ded10e845445a21093283092752192b6c614093168035da93a47f696ca5eb"; fn schema_text() -> Vec { std::fs::read(format!("{}/{SCHEMA}", env!("CARGO_MANIFEST_DIR"))).unwrap() } fn validator() -> jsonschema::Validator { let schema: Value = serde_json::from_slice(&schema_text()).unwrap(); jsonschema::options() .should_validate_formats(true) .build(&schema) .expect("the schema compiles") } fn errors(v: &jsonschema::Validator, record: &Value) -> Vec { v.iter_errors(record) .map(|e| format!("{} at {}", e, e.instance_path())) .collect() } #[test] fn the_schema_is_the_pinned_one() { use sha2::Digest; let digest = sha2::Sha256::digest(schema_text()); let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(hex, PINNED_SHA256, "the fixture is not the pinned schema"); let schema: Value = serde_json::from_slice(&schema_text()).unwrap(); assert_eq!( schema["$id"], "https://agent-trace.dev/schemas/v1/trace-record.json" ); } /// The validator is live: the specification's own example version, "0.1.0", is one its /// schema rejects (which is why flowRS writes "0.1"), and so is a range with no end. #[test] fn the_validator_refuses_what_the_schema_refuses() { let v = validator(); let mut g = corpus::entries()[0].parse().unwrap().graph; provenance::label(&mut g, &Author::Human); let good = serde_json::to_value(provenance::agent_trace(&g, &TraceOptions::default()).unwrap()) .unwrap(); assert!(errors(&v, &good).is_empty(), "{:?}", errors(&v, &good)); let mut version = good.clone(); version["version"] = "0.1.0".into(); assert!(!errors(&v, &version).is_empty()); let mut no_end = good.clone(); no_end["files"][0]["conversations"][0]["ranges"][0] .as_object_mut() .unwrap() .remove("end_line"); assert!(!errors(&v, &no_end).is_empty()); let mut kind = good.clone(); kind["files"][0]["conversations"][0]["contributor"]["type"] = "robot".into(); assert!(!errors(&v, &kind).is_empty()); let mut stamp = good; stamp["timestamp"] = "yesterday".into(); assert!(!errors(&v, &stamp).is_empty(), "date-time is checked"); } /// `(node, first, last)` for every node's lines, read straight off the markers. fn marked(g: &Graph) -> Vec<(u64, usize, usize)> { let annotated = rust::primitives_to_rust_annotated(g, 1).unwrap(); let mut line = 0usize; let mut open: Vec<(u64, usize)> = Vec::new(); let mut out = Vec::new(); for l in annotated.lines() { let t = l.trim_start(); if let Some(rest) = t.strip_prefix(NODE_END) { let id: u64 = rest.trim().parse().unwrap(); let k = open.iter().rposition(|(n, _)| *n == id).unwrap(); let (n, first) = open.remove(k); if line >= first { out.push((n, first, line)); } } else if let Some(rest) = t.strip_prefix(NODE_MARKER) { open.push((rest.trim().parse().unwrap(), line + 1)); } else { line += 1; } } assert!(open.is_empty(), "a marker with no end"); out.sort(); out } /// Who the test made each node's writers, and so the contributor its range must carry. #[derive(Clone, Copy, Debug)] enum Made { Unknown, Person, Agent, /// An agent's graph whose every second node the person created. Mixed, } const MODEL: &str = "anthropic/claude-test"; fn made(g: &Graph, how: Made) -> (Graph, BTreeMap) { let mut g = g.clone(); let agent = Author::agent(MODEL, "conv-1"); match how { Made::Unknown => {} Made::Person => provenance::label(&mut g, &Author::Human), Made::Agent | Made::Mixed => provenance::label(&mut g, &agent), } let mut ids: Vec = g.nodes.keys().copied().collect(); ids.sort(); let mut want = BTreeMap::new(); for (i, id) in ids.iter().enumerate() { let has_more = !g.nodes[id].properties.is_empty() || g.wires.values().any(|w| w.to_node == *id); let kind = match how { Made::Unknown => "unknown", Made::Person => "human", Made::Agent => "ai", Made::Mixed if i % 2 == 0 => { g.nodes.get_mut(id).unwrap().origin.node = Author::Human; if has_more { "mixed" } else { "human" } } Made::Mixed => "ai", }; want.insert(id.0, kind); } (g, want) } #[test] fn every_corpus_graphs_trace_validates_and_ranges_over_its_node_markers() { let v = validator(); let (mut graphs, mut records, mut ranges) = (0usize, 0usize, 0usize); for e in corpus::entries() { let base = e.parse().unwrap().graph; graphs += 1; for how in [Made::Unknown, Made::Person, Made::Agent, Made::Mixed] { let (g, want) = made(&base, how); let opts = TraceOptions { timestamp: "2026-10-22T08:00:00Z".into(), vcs: Some(( "git".into(), "0123456789abcdef0123456789abcdef01234567".into(), )), ..Default::default() }; let record = provenance::agent_trace(&g, &opts).unwrap(); let json = serde_json::to_value(&record).unwrap(); let bad = errors(&v, &json); assert!(bad.is_empty(), "{} ({how:?}): {bad:?}\n{json:#}", e.slug); records += 1; // Every range is a node's marked lines, and every node's marked lines are a // range, under the contributor the test made it. let mut got: Vec<(usize, usize, String, Option)> = Vec::new(); for c in &record.files[0].conversations { for r in &c.ranges { got.push(( r.start_line, r.end_line, c.contributor.kind.clone(), c.contributor.model_id.clone(), )); assert!( r.content_hash.starts_with("fnv1a64:") && r.content_hash.len() == 24, "{}", r.content_hash ); } } got.sort(); let mut expect: Vec<(usize, usize, String, Option)> = marked(&g) .into_iter() .map(|(n, first, last)| { let kind = want[&n]; let model = matches!(kind, "ai" | "mixed").then(|| MODEL.to_string()); (first, last, kind.to_string(), model) }) .collect(); expect.sort(); assert_eq!(got, expect, "{} ({how:?})", e.slug); // The metadata maps each range back to its node. let nodes = json["metadata"]["dev.flowrs"]["nodes"].as_array().unwrap(); let mut meta: Vec<(u64, usize, usize)> = nodes .iter() .map(|n| { ( n["node"].as_u64().unwrap(), n["start_line"].as_u64().unwrap() as usize, n["end_line"].as_u64().unwrap() as usize, ) }) .collect(); meta.sort(); assert_eq!(meta, marked(&g), "{}", e.slug); // An agent's conversation is linked; a person's has no URL. for c in &record.files[0].conversations { assert_eq!( c.url.is_some(), matches!(c.contributor.kind.as_str(), "ai" | "mixed"), "{}: {c:?}", e.slug ); } ranges += expect.len(); } } eprintln!( "agent trace: {graphs} corpus graphs × 4 authorships = {records} records valid against \ the 0.1.0 schema, {ranges} ranges, each a node's markers" ); assert_eq!(records, graphs * 4); assert!(graphs >= 17); }