Overlapping roles on one aggregate, relay routes, and a dry run

plan.yaml: `moves_by` says who presses the button into a stage when it
is not `handled_by` - other groups, several groups, or `submitter`,
the person who sent the form, through their own link (the form must
then collect a required email). `watched_by` puts a bucket on a
group's desk with no buttons. Each group's desk carries its own buttons
only; a submitter's moves become a followup page of self_transitions;
needs.yaml gains `also_moved_by` for portal's verifier.

simulate: a route is now a relay across every role that holds a legal
button, the submitter included, and each step is judged as the role
that presses it. `--dry` asks no model anything and walks every simple
route of every aggregate, for needs_replay to put through the real
engine.

plans/contractor-full.yaml: 13 needs, 6 roles, 10 aggregates. Verifier
green by construction; 40 routes, 78 transitions accepted by the real
engine, none refused. What it exposed is in its run README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-21 21:46:12 +02:00
co-authored by Claude Fable 5.1
parent aef0fe1396
commit baf36319b7
5 changed files with 669 additions and 41 deletions
+155 -31
View File
@@ -70,6 +70,8 @@ struct Case {
states: Vec<String>,
/// "state -> button", in the order pressed.
pressed: Vec<String>,
/// Who pressed each of them: a group, or `submitter`.
moved_by: Vec<String>,
touches: usize,
ask_backs: Vec<AskBack>,
outcome: Outcome,
@@ -115,6 +117,43 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
let mut cases_file = std::fs::File::create(out.join("cases.jsonl"))?;
let mut scores: BTreeMap<String, NeedScore> = BTreeMap::new();
// A dry run asks no model anything: it walks every route of every
// aggregate, so each button of each role is pressed once. Its cases
// are what `needs_replay` puts through the real engine.
if argv.iter().any(|a| a == "--dry") {
for need in model["needs"].as_array().into_iter().flatten() {
let id = need["id"].as_str().unwrap_or_default();
if only.as_deref().is_some_and(|o| o != id) {
continue;
}
let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()];
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
for (run, route) in all_routes(bucket, &done).into_iter().enumerate() {
let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()];
states.extend(route.iter().map(|m| m.to.clone()));
let case = Case {
persona: "route".to_string(),
need: id.to_string(),
run,
target: states.last().cloned().unwrap_or_default(),
record: BTreeMap::from([("note".to_string(), json!("dry run"))]),
pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(),
moved_by: route.iter().map(|m| m.by.clone()).collect(),
touches: route.len(),
ask_backs: Vec::new(),
outcome: Outcome::Finished(states.last().cloned().unwrap_or_default()),
states,
};
tally(scores.entry(id.to_string()).or_default(), &case);
writeln!(cases_file, "{}", serde_json::to_string(&case)?)?;
}
}
std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?;
let relays: usize = scores.values().map(|s| s.cases).sum();
println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display());
return Ok(());
}
for persona in model["personas"].as_array().into_iter().flatten() {
let need_id = persona["need"].as_str().unwrap_or_default();
if only.as_deref().is_some_and(|o| o != need_id) {
@@ -165,6 +204,7 @@ fn play(
record: BTreeMap::new(),
states: Vec::new(),
pressed: Vec::new(),
moved_by: Vec::new(),
touches: 0,
ask_backs: Vec::new(),
outcome: Outcome::Stuck("never started".to_string()),
@@ -237,17 +277,26 @@ fn play(
// collected, could this role really do what the button claims?
let bucket_name = need["lands_in"].as_str().unwrap_or_default();
let bucket = &model["buckets"][bucket_name];
let group = need["handled_by"].as_str().unwrap_or_default();
let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
let initial = bucket["initial"].as_str().unwrap_or("open").to_string();
let target = done.get(run % done.len().max(1)).copied().unwrap_or_default().to_string();
case.target = target.clone();
case.states.push(initial.clone());
let Some(route) = route_to(bucket, group, &initial, &target) else {
case.outcome = Outcome::Stuck(format!("no route of desk buttons from {initial} to {target}"));
let Some(route) = route_to(bucket, &initial, &target) else {
case.outcome = Outcome::Stuck(format!("no route of buttons from {initial} to {target}"));
return Ok(case);
};
for (step, (from, label, to)) in route.iter().enumerate() {
for (step, mv) in route.iter().enumerate() {
let (from, label, to, group) = (&mv.from, &mv.label, &mv.to, &mv.by);
case.moved_by.push(group.clone());
case.touches += 1;
case.pressed.push(format!("{from} -> {label}"));
case.states.push(to.clone());
// The submitter's own decision is theirs to make; there is no
// role whose information could be short.
if group == SUBMITTER {
continue;
}
let prompt = format!(
"You work at a business as one of its {group}. A case in `{bucket_name}` is in state `{from}`. It came from {}, who wants {}.\n\nThis is everything you have about it - what the form collected, plus any follow-up:\n{}\n\nThe next thing that happens to this case is the desk button \"{label}\", which moves it to `{to}`. Pressing it claims that this has really been done.\n\nUsing only what is in front of you, could you really do that now? Answer false only when a fact that ONLY THE PERSON can give is missing and you could not proceed without it. Facts you find out yourself while doing the work do not count: you learn a building's condition by visiting it, a firm's registration by looking it up.\nReply as JSON: {{\"can_act\": true or false, \"need_to_know\": \"<the one question you would send them>\" or null}}",
need["who"].as_str().unwrap_or_default(),
@@ -255,7 +304,6 @@ fn play(
serde_json::to_string_pretty(&case.record)?,
);
let reply = ask(handler_cmd, &prompt, &seed(100 + step))?;
case.touches += 1;
let question = reply["need_to_know"].as_str().unwrap_or_default().trim().to_string();
if reply["can_act"] == false && !question.is_empty() {
let answer = ask(
@@ -275,42 +323,90 @@ fn play(
});
case.touches += 1;
}
case.pressed.push(format!("{from} -> {label}"));
case.states.push(to.clone());
}
case.outcome = Outcome::Finished(target);
Ok(case)
}
/// The shortest chain of this group's desk buttons from one state to
/// another, as (from, button label, to).
fn route_to(bucket: &Value, group: &str, from: &str, to: &str) -> Option<Vec<(String, String, String)>> {
let moves: Vec<(String, String, String)> = bucket["desks"]
.as_array()
.into_iter()
.flatten()
.filter(|d| d["group"] == group)
.flat_map(|d| d["moves"].as_array().cloned().unwrap_or_default())
.filter_map(|m| Some((m["from"].as_str()?.to_string(), m["label"].as_str()?.to_string(), m["to"].as_str()?.to_string())))
.filter(|(f, _, t)| bucket["transitions"][f].as_array().is_some_and(|a| a.iter().any(|x| x == t.as_str())))
.collect();
pub(crate) const SUBMITTER: &str = "submitter";
/// One button: who holds it, and where it takes a case.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Move {
pub from: String,
pub label: String,
pub to: String,
pub by: String,
}
/// Every legal button on an aggregate, whoever holds it: each group's
/// desk, and the submitter's own link. Roles overlap on one aggregate,
/// so a route is a relay, not one desk's work.
pub(crate) fn all_moves(bucket: &Value) -> Vec<Move> {
let legal = |from: &str, to: &str| bucket["transitions"][from].as_array().is_some_and(|a| a.iter().any(|x| x == to));
let mut moves: Vec<Move> = Vec::new();
for desk in bucket["desks"].as_array().into_iter().flatten() {
for m in desk["moves"].as_array().into_iter().flatten() {
let (Some(from), Some(label), Some(to)) = (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) else { continue };
if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) {
moves.push(Move { from: from.into(), label: label.into(), to: to.into(), by: desk["group"].as_str().unwrap_or("ungated").into() });
}
}
}
let states: Vec<String> = bucket["transitions"].as_object().map(|t| t.keys().cloned().collect()).unwrap_or_default();
for to in bucket["self_moves"].as_array().into_iter().flatten().filter_map(Value::as_str) {
for from in &states {
if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) {
moves.push(Move { from: from.clone(), label: "their own link".into(), to: to.into(), by: SUBMITTER.into() });
}
}
}
moves
}
/// The shortest relay of buttons from one state to another.
fn route_to(bucket: &Value, from: &str, to: &str) -> Option<Vec<Move>> {
let moves = all_moves(bucket);
let mut queue = std::collections::VecDeque::from([(from.to_string(), Vec::new())]);
let mut seen = std::collections::HashSet::from([from.to_string()]);
while let Some((state, path)) = queue.pop_front() {
if state == to {
return Some(path);
}
for (f, label, t) in moves.iter().filter(|(f, _, _)| *f == state) {
if seen.insert(t.clone()) {
for mv in moves.iter().filter(|m| m.from == state) {
if seen.insert(mv.to.clone()) {
let mut next = path.clone();
next.push((f.clone(), label.clone(), t.clone()));
queue.push_back((t.clone(), next));
next.push(mv.clone());
queue.push_back((mv.to.clone(), next));
}
}
}
None
}
/// Every simple route from the first state to any finished one: what a
/// dry run walks, so that each button of each role is pressed at least
/// once against the real engine.
pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec<Move>, out: &mut Vec<Vec<Move>>) {
if done.contains(&state) {
out.push(path.clone());
return;
}
for mv in moves.iter().filter(|m| m.from == state) {
if path.iter().any(|p| p.from == mv.to) {
continue;
}
path.push(mv.clone());
walk(&mv.to, done, moves, path, out);
path.pop();
}
}
let mut out = Vec::new();
walk(bucket["initial"].as_str().unwrap_or("open"), done, &all_moves(bucket), &mut Vec::new(), &mut out);
out
}
/// Models often answer "Heading: its description" or change the case.
/// The pick is the longest option heading the answer starts with;
/// anything else is kept verbatim and counts as a misroute.
@@ -512,19 +608,46 @@ mod tests {
// Offered by the desk, refused by the machine.
{"from": "open", "to": "won", "label": "Shortcut"}
]},
{"group": "other", "moves": [{"from": "open", "to": "quoted", "label": "Theirs"}]}
{"group": "other", "moves": [{"from": "quoted", "to": "lost", "label": "Theirs"}]}
]
})
}
#[test]
fn routes_follow_this_groups_legal_buttons_only() {
let route = route_to(&bucket(), "pm", "open", "won").unwrap();
let labels: Vec<&str> = route.iter().map(|(_, l, _)| l.as_str()).collect();
assert_eq!(labels, ["Looked", "Quote sent", "Won"]);
assert_eq!(route_to(&bucket(), "pm", "open", "lost").unwrap().len(), 1);
assert!(route_to(&bucket(), "nobody", "open", "won").is_none());
assert_eq!(route_to(&bucket(), "pm", "open", "open").unwrap().len(), 0);
fn a_route_is_a_relay_of_legal_buttons_across_roles() {
// "Shortcut" is offered by a desk and refused by the machine.
assert!(!all_moves(&bucket()).iter().any(|m| m.label == "Shortcut"));
// Losing a quoted job is the other group's button, so the way
// from open to that ending is a relay: two roles, one case.
let route = route_to(&bucket(), "surveyed", "lost").unwrap();
let relay: Vec<(&str, &str)> = route.iter().map(|m| (m.label.as_str(), m.by.as_str())).collect();
assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]);
assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0);
assert!(route_to(&bucket(), "won", "open").is_none());
}
#[test]
fn the_submitters_own_link_is_a_move_like_any_other() {
let mut b = bucket();
b["self_moves"] = json!(["lost"]);
let theirs: Vec<Move> = all_moves(&b).into_iter().filter(|m| m.by == SUBMITTER).collect();
// Only where the machine allows it and no desk already holds it:
// open->lost is the project manager's and quoted->lost the other
// group's, which leaves surveyed->lost to the link.
let froms: Vec<&str> = theirs.iter().map(|m| m.from.as_str()).collect();
assert_eq!(froms, ["surveyed"]);
}
#[test]
fn a_dry_run_walks_every_route_to_every_ending() {
let routes = all_routes(&bucket(), &["won", "lost"]);
let ends: Vec<String> = routes.iter().map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)).collect();
// open->lost; open->surveyed->quoted->won; and the relay
// open->surveyed->quoted->lost, closed by the other group.
assert_eq!(ends.len(), 3, "{ends:?}");
assert_eq!(ends.iter().filter(|e| *e == "3:lost").count(), 1);
assert!(ends.contains(&"1:lost".to_string()) && ends.contains(&"3:won".to_string()));
assert!(routes.iter().any(|r| r.iter().any(|m| m.by == "other")));
}
#[test]
@@ -538,6 +661,7 @@ mod tests {
record: BTreeMap::new(),
states: vec![],
pressed: vec!["open -> Looked".into(), "surveyed -> Quote sent".into()],
moved_by: vec!["pm".into(), "pm".into()],
touches: 3,
ask_backs: vec![
AskBack { state: "open".into(), button: "Looked".into(), question: "Which floor?".into(), answerable: true },