flowrs-engine/crates/flowrs-core/tests/provenance_rebase.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

717 lines
27 KiB
Rust

//! The ownership gate of work/provenance: 10,000 random interleavings of a person's edits
//! and an agent's patches, and in none does a handle the person wrote change unless the
//! patch names it and Review marks it.
//!
//! The oracle is the test's own. It does not ask `provenance` who wrote anything: it keeps
//! its own record of each handle's last writer, from a plain reading of the graph before
//! and after every step (a node's widget, each parameter's value, what feeds each input),
//! and its own list of what each patch names, from the statements it wrote. The code under
//! test then has to agree with it on every step:
//!
//! - a patch that changes a person's handle without naming it is refused, with the graph
//! left as it was — and one that changes none is never refused;
//! - an applied patch overrides exactly the person's handles it changed: each is in the
//! list Review marks ([`edit::apply_proposal_marked`], what the IDE's review uses), each
//! has a row of the diff to mark, and nothing else is listed;
//! - after every step, each handle's stored author is the oracle's.
//!
//! The person edits the graph the way the IDE does, through
//! [`provenance::edit_as`]; the agent's patches go through `apply_proposal_marked`, the
//! one door every surface (the IDE review, MCP, WebMCP) applies a proposal by. One in ten
//! refused patches also goes through the checker, which must refuse it at its `apply`
//! stage. An import's handles are the person's work too, and a third of the runs start
//! from an imported graph.
use flowrs_core::graph::corpus;
use flowrs_core::graph::diff;
use flowrs_core::graph::dsl::{self, patch::ports_of};
use flowrs_core::graph::edit::{self, Proposal};
use flowrs_core::graph::ir::{Graph, NodeId, WireMode};
use flowrs_core::graph::provenance::{self, Agent, Author, Handle};
use flowrs_core::graph::verify::{self, Expect, InBrowser, Stage, Status, Verification};
use flowrs_core::graph::widget::{WidgetRegistry, builtin_registry};
use std::collections::{BTreeMap, BTreeSet};
const INTERLEAVINGS: usize = 10_000;
/// SplitMix64: small, fast, and the same sequence on every machine.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
fn chance(&mut self, percent: u64) -> bool {
self.next() % 100 < percent
}
fn pick<T: Clone>(&mut self, v: &[T]) -> Option<T> {
(!v.is_empty()).then(|| v[self.below(v.len())].clone())
}
}
/// Who last wrote a handle, as the oracle records it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Who {
Person,
Import,
Agent,
}
impl Who {
fn protected(self) -> bool {
matches!(self, Who::Person | Who::Import)
}
}
/// The graph as the oracle reads it: every handle and its state.
fn state(g: &Graph) -> BTreeMap<Handle, String> {
let mut s = BTreeMap::new();
for n in g.nodes.values() {
s.insert(Handle::Node { node: n.id.0 }, n.type_id.clone());
for (k, v) in &n.properties {
s.insert(
Handle::Parameter {
node: n.id.0,
key: k.clone(),
},
v.clone(),
);
}
}
let mut feeds: BTreeMap<(u64, String), Vec<String>> = BTreeMap::new();
for w in g.wires.values() {
feeds
.entry((w.to_node.0, w.to_port.clone()))
.or_default()
.push(format!(
"{}.{} {:?} {}",
w.from_node.0, w.from_port, w.mode, w.feedback
));
}
for ((node, port), mut v) in feeds {
v.sort();
s.insert(Handle::Input { node, port }, v.join("|"));
}
s
}
/// Every handle whose state differs.
fn changed(a: &BTreeMap<Handle, String>, b: &BTreeMap<Handle, String>) -> BTreeSet<Handle> {
a.keys()
.chain(b.keys())
.filter(|h| a.get(*h) != b.get(*h))
.cloned()
.collect()
}
/// After a step: changed handles on nodes that still exist are `who`'s; the handles of a
/// node that is gone are forgotten (there is nothing left to protect).
fn record(oracle: &mut BTreeMap<Handle, Who>, after: &Graph, delta: &BTreeSet<Handle>, who: Who) {
for h in delta {
if after.nodes.contains_key(&h.node()) {
oracle.insert(h.clone(), who);
} else {
oracle.remove(h);
}
}
oracle.retain(|h, _| after.nodes.contains_key(&h.node()));
}
/// What a patch names, written down as the patch is generated.
#[derive(Default)]
struct Names {
handles: BTreeSet<Handle>,
removed: BTreeSet<u64>,
}
impl Names {
fn covers(&self, h: &Handle) -> bool {
self.handles.contains(h)
|| matches!(h, Handle::Parameter { node, .. } | Handle::Input { node, .. }
if self.removed.contains(node))
}
}
fn ids(g: &Graph) -> Vec<NodeId> {
let mut v: Vec<NodeId> = g.nodes.keys().copied().collect();
v.sort();
v
}
/// A value an agent might set `key` of `type_id` to: one of its choices, or a number
/// near its default.
fn agent_value(rng: &mut Rng, reg: &WidgetRegistry, type_id: &str, key: &str, cur: &str) -> String {
if let Some(p) = reg
.get(type_id)
.and_then(|w| w.parameters().into_iter().find(|p| p.name == key))
{
if let Some(c) = rng.pick(&p.choices) {
return c;
}
if let Ok(d) = p.default.parse::<f64>() {
let v = d + rng.below(4) as f64;
return if p.ty.contains('f') {
format!("{v:.1}")
} else {
format!("{}", v as i64)
};
}
}
match cur.parse::<i64>() {
Ok(n) => (n + 1 + rng.below(5) as i64).to_string(),
Err(_) => cur.to_string(),
}
}
/// One random agent patch against `g`, and what it names.
fn agent_patch(
rng: &mut Rng,
g: &Graph,
reg: &WidgetRegistry,
types: &[String],
) -> (String, Names) {
let mut out = Vec::new();
let mut names = Names::default();
let nodes = ids(g);
let n_stmts = 1 + rng.below(3);
for _ in 0..n_stmts {
let Some(x) = rng.pick(&nodes) else { break };
if names.removed.contains(&x.0) {
continue;
}
let node = &g.nodes[&x];
let h = dsl::handle(x);
match rng.below(8) {
// set a value
0 | 1 => {
let mut keys: Vec<String> = node.properties.keys().cloned().collect();
if let Some(w) = reg.get(&node.type_id) {
keys.extend(w.parameters().into_iter().map(|p| p.name));
}
keys.sort();
keys.dedup();
if let Some(k) = rng.pick(&keys) {
let cur = node.properties.get(&k).cloned().unwrap_or_default();
let v = agent_value(rng, reg, &node.type_id, &k, &cur);
out.push(format!("set {h} {{ {k}: {v} }};"));
names
.handles
.insert(Handle::Parameter { node: x.0, key: k });
}
}
// unset one
2 => {
let mut keys: Vec<String> = node.properties.keys().cloned().collect();
keys.sort();
if let Some(k) = rng.pick(&keys) {
out.push(format!("unset {h}.{k};"));
names
.handles
.insert(Handle::Parameter { node: x.0, key: k });
}
}
// remove it, naming some of what it feeds
3 => {
out.push(format!("remove {h};"));
names.handles.insert(Handle::Node { node: x.0 });
names.removed.insert(x.0);
let mut fed: Vec<(NodeId, String)> = g
.wires
.values()
.filter(|w| w.from_node == x && w.to_node != x)
.map(|w| (w.to_node, w.to_port.clone()))
.collect();
fed.sort();
fed.dedup();
for (m, p) in fed {
if rng.chance(50) && !names.removed.contains(&m.0) {
out.push(format!("{}.{p} = _;", dsl::handle(m)));
names.handles.insert(Handle::Input { node: m.0, port: p });
}
}
}
// rewire an input, or leave it open
4 | 5 => {
let ins = ports_of(reg, &node.type_id, false).unwrap_or_default();
let Some(p) = rng.pick(&ins) else { continue };
let src = rng
.pick(&nodes)
.filter(|y| *y != x && !names.removed.contains(&y.0));
let text = match src.and_then(|y| {
let outs = ports_of(reg, &g.nodes[&y].type_id, true).unwrap_or_default();
rng.pick(&outs).map(|o| format!("{}.{o}", dsl::handle(y)))
}) {
Some(s) if rng.chance(75) => s,
_ => "_".to_string(),
};
out.push(format!("{h}.{p} = {text};"));
names.handles.insert(Handle::Input { node: x.0, port: p });
}
// restate it with nothing, naming some of what that drops
6 => {
let mut fed: Vec<String> = g
.wires
.values()
.filter(|w| w.to_node == x)
.map(|w| w.to_port.clone())
.collect();
fed.sort();
fed.dedup();
let mut args = Vec::new();
for p in fed {
if rng.chance(50) {
args.push(format!("{p}: _"));
names.handles.insert(Handle::Input { node: x.0, port: p });
}
}
let ty = match node.type_id.as_str() {
"IntLiteral" if rng.chance(50) => "FloatLiteral",
"FloatLiteral" if rng.chance(50) => "IntLiteral",
t => t,
};
// Each value: restated (with a new value), dropped on purpose, or dropped.
let mut keys: Vec<String> = node.properties.keys().cloned().collect();
keys.sort();
let (mut props, mut unsets) = (Vec::new(), Vec::new());
for k in keys {
match rng.below(3) {
0 => {
let cur = node.properties[&k].clone();
let v = agent_value(rng, reg, ty, &k, &cur);
props.push(format!("{k}: {v}"));
}
1 => unsets.push(format!("unset {h}.{k};")),
_ => continue,
}
names
.handles
.insert(Handle::Parameter { node: x.0, key: k });
}
out.push(format!(
"replace {h} = {ty}({}) {{ {} }};",
args.join(", "),
props.join(", ")
));
out.extend(unsets);
names.handles.insert(Handle::Node { node: x.0 });
}
// a new node fed by this one
_ => {
let outs = ports_of(reg, &node.type_id, true).unwrap_or_default();
let Some(ty) = rng.pick(types) else { continue };
let ins = ports_of(reg, &ty, false).unwrap_or_default();
let arg = match (rng.pick(&outs), rng.pick(&ins)) {
(Some(o), Some(i)) => format!("{i}: {h}.{o}"),
_ => String::new(),
};
out.push(format!("let a{} = {ty}({arg});", rng.below(1000)));
}
}
}
(out.join("\n"), names)
}
/// One random edit by the person, as the IDE makes it.
fn person_edit(rng: &mut Rng, g: &mut Graph, reg: &WidgetRegistry, types: &[String]) {
let nodes = ids(g);
let Some(x) = rng.pick(&nodes) else { return };
let kind = rng.below(8);
provenance::edit_as(g, &Author::Human, |g| match kind {
// set a value
0..=2 => {
let node = g.get_node_mut(x).unwrap();
let mut keys: Vec<String> = node.properties.keys().cloned().collect();
keys.push("label".into());
keys.sort();
let k = keys[rng.below(keys.len())].clone();
node.properties
.insert(k, (rng.below(50) as i64 - 10).to_string());
}
// clear one
3 => {
let node = g.get_node_mut(x).unwrap();
let mut keys: Vec<String> = node.properties.keys().cloned().collect();
keys.sort();
if let Some(k) = rng.pick(&keys) {
node.properties.remove(&k);
}
}
// draw a wire onto an input
4 => {
let Some(y) = rng.pick(&nodes).filter(|y| *y != x) else {
return;
};
let outs = ports_of(reg, &g.nodes[&y].type_id, true).unwrap_or_default();
let ins = ports_of(reg, &g.nodes[&x].type_id, false).unwrap_or_default();
if let (Some(o), Some(i)) = (rng.pick(&outs), rng.pick(&ins)) {
let mode = [WireMode::Move, WireMode::Borrow, WireMode::Copy][rng.below(3)];
let _ = g.connect_replacing(y, &o, x, &i, mode);
}
}
// delete a wire
5 => {
let mut ws: Vec<_> = g.wires.keys().copied().collect();
ws.sort();
if let Some(w) = rng.pick(&ws) {
g.remove_wire(w);
}
}
// delete the node
6 => {
g.remove_node(x);
}
// add one, and move another (layout: no one's)
_ => {
if let Some(ty) = rng.pick(types) {
let id = g.add_node(&ty, 10.0, 10.0);
g.get_node_mut(id)
.unwrap()
.properties
.insert("label".into(), "mine".into());
}
if let Some(n) = g.get_node_mut(x) {
n.position.0 += 40.0;
}
}
});
}
fn empty_verification() -> Verification {
serde_json::from_value(serde_json::json!({ "stages": [], "findings": [] })).unwrap()
}
/// The oracle's author for a stored one.
fn stored_is(a: &Author, who: Who, agent: &Author) -> bool {
match who {
Who::Person => *a == Author::Human,
Who::Import => matches!(a, Author::Imported { .. }),
Who::Agent => a == agent || matches!(a, Author::Agent { .. }),
}
}
#[derive(Default, Debug)]
struct Tally {
interleavings: usize,
person_edits: usize,
patches: usize,
did_not_apply: usize,
applied: usize,
refused: usize,
overrides: usize,
overridden_patches: usize,
checked_refusals: usize,
violations: usize,
}
#[test]
fn ten_thousand_interleavings_never_change_a_persons_handle_unnamed_or_unmarked() {
let reg = builtin_registry();
let bases: Vec<Graph> = corpus::entries()
.iter()
.filter_map(|e| e.parse().ok().map(|p| p.graph))
.filter(|g| g.nodes.len() >= 2)
.collect();
assert!(bases.len() >= 10, "{} corpus graphs", bases.len());
let mut types: Vec<String> = bases
.iter()
.flat_map(|g| g.nodes.values().map(|n| n.type_id.clone()))
.collect();
types.sort();
types.dedup();
let mut rng = Rng(0x0f10_5eed_2026_1022);
let mut t = Tally::default();
let mut first_violation: Option<String> = None;
for run in 0..INTERLEAVINGS {
let mut g = bases[rng.below(bases.len())].clone();
let mut oracle: BTreeMap<Handle, Who> = BTreeMap::new();
// Where the graph came from: unattributed, an earlier agent's, or an import.
match run % 3 {
0 => {}
1 => provenance::label(&mut g, &Author::agent("test/earlier", "c0")),
_ => {
provenance::label(
&mut g,
&Author::Imported {
dialect: "puredata".into(),
},
);
for h in state(&g).into_keys() {
oracle.insert(h, Who::Import);
}
}
}
let steps = 4 + rng.below(9);
for step in 0..steps {
let before = g.clone();
let s0 = state(&before);
if rng.chance(50) {
person_edit(&mut rng, &mut g, reg, &types);
t.person_edits += 1;
let delta = changed(&s0, &state(&g));
record(&mut oracle, &g, &delta, Who::Person);
} else {
t.patches += 1;
let (patch, names) = agent_patch(&mut rng, &before, reg, &types);
let Ok(applied) = dsl::apply_patch(&before, &patch, reg) else {
t.did_not_apply += 1;
continue;
};
let delta = changed(&s0, &state(&applied.graph));
let protected: BTreeSet<Handle> = delta
.iter()
.filter(|h| oracle.get(*h).is_some_and(|w| w.protected()))
.cloned()
.collect();
let unnamed: Vec<&Handle> = protected.iter().filter(|h| !names.covers(h)).collect();
let agent = Agent::new("test/model", &format!("r{run}s{step}"));
let by = agent.author("");
let proposal = Proposal {
patch: patch.clone(),
explain: String::new(),
graph: applied.graph.clone(),
changes: applied.changes.clone(),
verification: empty_verification(),
base_key: edit::key_of(&before),
rounds: 1,
variant: None,
ledger: Vec::new(),
rationale: Vec::new(),
asked: String::new(),
agent,
overrides: Vec::new(),
app: None,
app_base: String::new(),
};
let mut bad = |why: String| {
t.violations += 1;
first_violation.get_or_insert_with(|| {
format!("run {run} step {step}: {why}\npatch:\n{patch}")
});
};
match edit::apply_proposal_marked(&g, &proposal, reg) {
Err(why) => {
t.refused += 1;
if unnamed.is_empty() {
bad(format!(
"refused a patch that names every person's handle it changes: {why}"
));
}
// The checker refuses it too, at `apply`, naming the handle.
if t.refused % 10 == 1 && !unnamed.is_empty() {
t.checked_refusals += 1;
let c = verify::check_patch(
&before,
&patch,
reg,
&InBrowser,
&Expect::default(),
);
let v = &c.verification;
let at_apply =
matches!(v.status(Stage::Apply), Some(Status::Failed(_)));
let says = v
.errors()
.any(|f| unnamed.iter().any(|h| f.message.contains(&h.render())));
if !(at_apply && says) {
bad(format!("the checker did not refuse it at apply: {v:?}"));
}
}
// Nothing changed: `g` was never replaced.
}
Ok((after, overrides)) => {
t.applied += 1;
if !unnamed.is_empty() {
bad(format!(
"changed {unnamed:?}, which the person wrote, unnamed"
));
}
let marked: BTreeSet<Handle> =
overrides.iter().map(|o| o.handle.clone()).collect();
if marked != protected {
bad(format!(
"Review marks {marked:?}; the person's handles changed are {protected:?}"
));
}
let rows = diff::diff(&before, &after);
for o in &overrides {
if !rows.iter().any(|c| o.handle.is_about(c)) {
bad(format!("{} is marked but no change is about it", o.at));
}
}
if state(&after) != state(&applied.graph) {
bad("the applied graph is not the patch's".into());
}
t.overrides += overrides.len();
t.overridden_patches += usize::from(!overrides.is_empty());
g = after;
record(&mut oracle, &g, &delta, Who::Agent);
// What changed is the agent's, as stored.
for h in delta.iter().filter(|h| g.nodes.contains_key(&h.node())) {
let a = provenance::author_of(&g, h);
if a != by {
bad(format!("{} is stored as {a:?}, not the agent", h.render()));
}
}
}
}
}
// After every step, every handle the oracle knows is stored as its author.
for (h, who) in &oracle {
let a = provenance::author_of(&g, h);
if !stored_is(&a, *who, &Author::Unknown) {
t.violations += 1;
first_violation.get_or_insert_with(|| {
format!(
"run {run} step {step}: {} is stored as {a:?}; the oracle says {who:?}",
h.render()
)
});
}
}
}
t.interleavings += 1;
}
eprintln!("provenance property: {t:#?}");
assert_eq!(t.interleavings, INTERLEAVINGS);
assert_eq!(t.violations, 0, "{}", first_violation.unwrap_or_default());
// The run exercised the rule, not only its absence.
assert!(t.refused >= 500, "{t:?}");
assert!(t.overridden_patches >= 500, "{t:?}");
assert!(t.applied >= 5_000, "{t:?}");
assert!(t.checked_refusals >= 50, "{t:?}");
}
/// The edit loop holds the model to the rule like any other check: an unnamed change to a
/// person's handle fails `apply` at the statement that made it, the model names it in its
/// repair, and the verified proposal carries what it overrides and who proposed it.
#[test]
fn the_edit_loop_refuses_an_unnamed_override_and_takes_a_named_one() {
use flowrs_core::graph::edit::{Options, Outcome, Session, Turn};
let reg = builtin_registry();
let mut base = corpus::get("float_sum").unwrap().parse().unwrap().graph;
provenance::label(&mut base, &Author::Human);
let agent = Agent::new("anthropic/claude-test", "conv-7");
let mut s = Session::new(
base.clone(),
reg,
"use 1 instead of the second number",
Expect::default(),
Options {
clarify: false,
agent: agent.clone(),
..Options::default()
},
);
let t = s.submit("```patch\nremove n1;\n```", &InBrowser);
assert!(matches!(t, Turn::Ask), "{t:?}");
let repair = &s.messages().last().unwrap().content;
assert!(
repair.contains("`remove n1` also disconnects n2.b, which the person wired"),
"{repair}"
);
let Turn::Done(o) = s.submit(
"```patch\nremove n1;\nlet one = FloatLiteral { value: 1 };\nn2.b = one.value;\n```",
&InBrowser,
) else {
panic!("not done")
};
let Outcome::Verified(p) = *o else {
panic!("{o:?}")
};
let at: BTreeSet<&str> = p.overrides.iter().map(|o| o.at.as_str()).collect();
assert_eq!(
at,
BTreeSet::from(["n1", "n1.value", "n2.b"]),
"{:?}",
p.overrides
);
assert_eq!(p.agent, agent);
let (after, overrides) = edit::apply_proposal_marked(&base, &p, reg).unwrap();
assert_eq!(overrides, p.overrides);
let by = Author::agent("anthropic/claude-test", "conv-7");
let b = Handle::Input {
node: 2,
port: "b".into(),
};
assert_eq!(provenance::author_of(&after, &b), by);
assert_eq!(
after.nodes[&NodeId(0)].origin.params["value"],
Author::Human
);
assert_eq!(
provenance::node_authors(&after, NodeId(2))
.unwrap()
.author(),
Author::Mixed
);
// n0, n2, n3; n0.value; n0.value→n2.a and n2.sum→n3.value.
assert_eq!(provenance::share(&after).human, 6);
}
/// The page's WebMCP tools are an agent's edits too: one that would change a person's
/// handle unnamed is refused and changes nothing; a named one is stamped as the page agent's
/// and says what it overrode.
#[test]
fn the_pages_agent_tools_hold_to_the_rule() {
use flowrs_core::graph::agent;
let mut g = corpus::get("float_sum").unwrap().parse().unwrap().graph;
provenance::label(&mut g, &Author::Human);
let before = flowrs_core::graph::document::save(&g);
let r = agent::call(
&mut g,
"graph_remove_node",
&serde_json::json!({ "node": 1 }),
);
assert_eq!(r["ok"], false, "{r}");
assert!(r["error"].as_str().unwrap().contains("n2.b"), "{r}");
assert_eq!(
flowrs_core::graph::document::save(&g),
before,
"nothing changed"
);
let r = agent::call(
&mut g,
"graph_set_properties",
&serde_json::json!({ "node": 0, "properties": { "value": "4" } }),
);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(r["overrides"][0]["at"], "n0.value");
let h = Handle::Parameter {
node: 0,
key: "value".into(),
};
assert_eq!(
provenance::author_of(&g, &h),
Author::agent(provenance::UNKNOWN_MODEL, agent::WEBMCP_CONVERSATION)
);
// A patch through the page: checked, refused unnamed, applied named and stamped.
let r = agent::call(
&mut g,
"graph_patch_apply",
&serde_json::json!({ "patch": "remove n1;" }),
);
assert_eq!(r["ok"], false, "{r}");
let r = agent::call(
&mut g,
"graph_patch_apply",
&serde_json::json!({ "patch": "let k = FloatLiteral { value: 4 };\nn2.b = k.value;" }),
);
assert_eq!(r["applied"], true, "{r}");
assert_eq!(r["overrides"][0]["at"], "n2.b");
let b = Handle::Input {
node: 2,
port: "b".into(),
};
assert!(matches!(
provenance::author_of(&g, &b),
Author::Agent { .. }
));
}