flowrs-engine/crates/flowrs-core/tests/agent_trace.rs
flowRS fd958c0547 flowRS engine, cut from flowRS 3451b065
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:04:39 -05:00

229 lines
8.9 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}