229 lines
8.9 KiB
Rust
229 lines
8.9 KiB
Rust
//! 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<u8> {
|
||
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<String> {
|
||
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<u64, &'static str>) {
|
||
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<NodeId> = 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<String>)> = 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<String>)> = 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);
|
||
}
|