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:
co-authored by
Claude Fable 5.1
parent
aef0fe1396
commit
baf36319b7
+163
-10
@@ -70,6 +70,17 @@ pub struct PlanNeed {
|
||||
/// were complete).
|
||||
#[serde(default)]
|
||||
pub skippable: Vec<String>,
|
||||
/// Who presses the button that moves a case INTO a stage, where it
|
||||
/// is not `handled_by`: another group, several groups, or
|
||||
/// `submitter` - the person who sent the form, through their own
|
||||
/// link (the form must then collect a required `email`). This is
|
||||
/// how roles overlap on one aggregate: a project manager prices a
|
||||
/// change, the client approves it, a site manager completes it.
|
||||
#[serde(default)]
|
||||
pub moves_by: BTreeMap<String, Vec<String>>,
|
||||
/// Groups that see these cases on their desk and move nothing.
|
||||
#[serde(default)]
|
||||
pub watched_by: Vec<String>,
|
||||
pub collects: Vec<Field>,
|
||||
#[serde(default)]
|
||||
pub max_fields: Option<usize>,
|
||||
@@ -113,6 +124,22 @@ impl Plan {
|
||||
if need.stages.len() < 2 {
|
||||
bail!(at("stages needs a first stage and at least one finished one"));
|
||||
}
|
||||
for (state, movers) in &need.moves_by {
|
||||
if !need.stages[1..].contains(state) {
|
||||
bail!(at(&format!("moves_by names {state:?}, which is not a stage a case can move into")));
|
||||
}
|
||||
if movers.is_empty() || movers.iter().any(|m| !is_id(m)) {
|
||||
bail!(at(&format!("moves_by {state:?} must list groups or `submitter`")));
|
||||
}
|
||||
if movers.iter().any(|m| m == SUBMITTER)
|
||||
&& !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional)
|
||||
{
|
||||
bail!(at("a move by the submitter is authorized by their email, so the form must collect a required `email` of type email"));
|
||||
}
|
||||
}
|
||||
if need.watched_by.iter().any(|g| !is_id(g)) {
|
||||
bail!(at("watched_by must list group identifiers"));
|
||||
}
|
||||
for state in need.done_when.iter().chain(&need.skippable) {
|
||||
if !need.stages.contains(state) {
|
||||
bail!(at(&format!("{state:?} is not one of its stages")));
|
||||
@@ -139,8 +166,14 @@ impl Plan {
|
||||
for (bucket, needs) in self.by_bucket() {
|
||||
let first = needs[0];
|
||||
for other in &needs[1..] {
|
||||
if other.stages != first.stages || other.done_when != first.done_when || other.skippable != first.skippable {
|
||||
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when and skippable", first.id, other.id);
|
||||
if other.stages != first.stages
|
||||
|| other.done_when != first.done_when
|
||||
|| other.skippable != first.skippable
|
||||
|| other.moves_by != first.moves_by
|
||||
|| other.watched_by != first.watched_by
|
||||
|| other.handled_by != first.handled_by
|
||||
{
|
||||
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable and who moves and watches it", first.id, other.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,11 +289,12 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
let buckets: Map<String, Value> = plan
|
||||
.by_bucket()
|
||||
.into_iter()
|
||||
.filter(|(_, needs)| needs.iter().any(|n| n.handled_by == group))
|
||||
.filter(|(_, needs)| on_desk(needs[0], &group))
|
||||
.map(|(bucket, needs)| {
|
||||
let moves: Map<String, Value> = machine(needs[0])
|
||||
// Only the buttons this group presses; a watcher gets none.
|
||||
let moves: Map<String, Value> = moves_of(needs[0], &group)
|
||||
.into_iter()
|
||||
.flat_map(|(from, tos)| tos.into_iter().map(move |to| (format!("{from}->{to}"), Value::String(humanize(&to)))))
|
||||
.map(|(from, to)| (format!("{from}->{to}"), Value::String(humanize(&to))))
|
||||
.collect();
|
||||
(bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves }))
|
||||
})
|
||||
@@ -276,11 +310,68 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }),
|
||||
);
|
||||
}
|
||||
// The submitter's own page: reached by their private link, it
|
||||
// offers the moves that are theirs to make.
|
||||
for need in &plan.needs {
|
||||
let theirs = moves_of(need, SUBMITTER);
|
||||
if theirs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect();
|
||||
words.insert(
|
||||
decide_path(need),
|
||||
json!({
|
||||
"question": "What would you like to do?",
|
||||
"description": "",
|
||||
"moves": targets.iter().map(|to| ((*to).clone(), json!({
|
||||
"heading": humanize(to), "description": "", "button": humanize(to),
|
||||
}))).collect::<Map<_, _>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
fn decide_path(need: &PlanNeed) -> String {
|
||||
format!("questions/decide/{}.yaml", need.id)
|
||||
}
|
||||
|
||||
const SUBMITTER: &str = "submitter";
|
||||
|
||||
/// Who presses the button into `to`.
|
||||
fn movers<'a>(need: &'a PlanNeed, to: &str) -> Vec<&'a str> {
|
||||
match need.moves_by.get(to) {
|
||||
Some(list) => list.iter().map(String::as_str).collect(),
|
||||
None => vec![need.handled_by.as_str()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The (from, to) moves of a bucket that `who` may make.
|
||||
fn moves_of(need: &PlanNeed, who: &str) -> Vec<(String, String)> {
|
||||
machine(need)
|
||||
.into_iter()
|
||||
.flat_map(|(from, tos)| tos.into_iter().map(move |to| (from.clone(), to)))
|
||||
.filter(|(_, to)| movers(need, to).contains(&who))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A group's desk shows a bucket when it moves something in it, is
|
||||
/// answerable for it, or watches it.
|
||||
fn on_desk(need: &PlanNeed, group: &str) -> bool {
|
||||
need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()
|
||||
}
|
||||
|
||||
fn groups(plan: &Plan) -> BTreeSet<String> {
|
||||
plan.needs.iter().flat_map(|n| [Some(n.handled_by.clone()), n.enters_as.clone()]).flatten().collect()
|
||||
plan.needs
|
||||
.iter()
|
||||
.flat_map(|n| {
|
||||
[Some(n.handled_by.clone()), n.enters_as.clone()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(n.watched_by.iter().cloned())
|
||||
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Takes the model's strings where it gave them, key by key, and keeps
|
||||
@@ -422,7 +513,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
let w = &words[&format!("questions/{group}/index.yaml")];
|
||||
let mut alternatives = Vec::new();
|
||||
for (bucket, needs) in plan.by_bucket() {
|
||||
if !needs.iter().any(|n| n.handled_by == group) {
|
||||
if !on_desk(needs[0], &group) {
|
||||
continue;
|
||||
}
|
||||
let bw = &w["buckets"][bucket];
|
||||
@@ -434,8 +525,11 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"features": [{ "name": "", "description": "", "resource": {
|
||||
"source": { "kind": "kv", "bucket": bucket },
|
||||
"requires_group": group,
|
||||
"transitions": machine(needs[0]).into_iter().flat_map(|(from, tos)| {
|
||||
tos.into_iter().map(|to| json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })).collect::<Vec<_>>()
|
||||
// This group's buttons only. The same bucket sits on
|
||||
// other groups' desks with theirs; a watcher's list
|
||||
// is empty, which portal renders read-only.
|
||||
"transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| {
|
||||
json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })
|
||||
}).collect::<Vec<_>>(),
|
||||
}}],
|
||||
}));
|
||||
@@ -456,6 +550,31 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
);
|
||||
}
|
||||
|
||||
// A submitter's own moves: a followup page (nested, not an index)
|
||||
// whose alternatives are self_transitions, authorized at runtime by
|
||||
// the record's link plus the email they gave.
|
||||
for need in &plan.needs {
|
||||
let theirs = moves_of(need, SUBMITTER);
|
||||
if theirs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = decide_path(need);
|
||||
let w = &words[&path];
|
||||
let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect();
|
||||
files.insert(
|
||||
path,
|
||||
json!({
|
||||
"name": s(&w["question"]),
|
||||
"description": s(&w["description"]),
|
||||
"alternatives": targets.iter().map(|to| json!({
|
||||
"name": s(&w["moves"][to.as_str()]["heading"]),
|
||||
"description": s(&w["moves"][to.as_str()]["description"]),
|
||||
"self_transition": { "bucket": need.lands_in, "to": to, "label": s(&w["moves"][to.as_str()]["button"]) },
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The spec the verifier holds the site to. `via` is the heading the
|
||||
// words gave that need, filled in mechanically.
|
||||
files.insert(
|
||||
@@ -474,6 +593,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"max_steps": 1, "max_fields": n.max_fields.unwrap_or(required),
|
||||
});
|
||||
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
|
||||
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
|
||||
if !others.is_empty() { need["also_moved_by"] = json!(others); }
|
||||
need
|
||||
}).collect::<Vec<_>>(),
|
||||
"personas": plan.personas,
|
||||
@@ -484,7 +605,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
}
|
||||
|
||||
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
|
||||
let purpose = if path == "questions/index.yaml" {
|
||||
let purpose = if path.contains("/decide/") {
|
||||
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
|
||||
} else if path == "questions/index.yaml" {
|
||||
"the front door: the first page any visitor sees. `question` is the page's one question; under `needs`, each entry is one alternative the visitor can pick, with the form behind it."
|
||||
} else if path.ends_with("/index.yaml") {
|
||||
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open."
|
||||
@@ -572,6 +695,7 @@ mod tests {
|
||||
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None,
|
||||
lands_in: "b".into(), handled_by: "g".into(),
|
||||
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
|
||||
moves_by: BTreeMap::new(), watched_by: vec![],
|
||||
collects: vec![], max_fields: None,
|
||||
}
|
||||
}
|
||||
@@ -634,6 +758,35 @@ mod tests {
|
||||
assert!(files["needs.yaml"].contains("enters: /crew/report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n";
|
||||
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
|
||||
// The project manager prices and may decline; approving and completing are not theirs.
|
||||
let pm = &files["questions/project_managers/index.yaml"];
|
||||
assert!(pm.contains("to: priced") && pm.contains("to: declined"));
|
||||
assert!(!pm.contains("to: approved") && !pm.contains("to: completed"));
|
||||
// The site manager completes, and nothing else.
|
||||
let site = &files["questions/site_managers/index.yaml"];
|
||||
assert!(site.contains("to: completed") && !site.contains("to: priced"));
|
||||
// Accounts see the bucket and move nothing.
|
||||
let accounts = &files["questions/accounts/index.yaml"];
|
||||
assert!(accounts.contains("bucket: change_orders") && accounts.contains("transitions: []"));
|
||||
// The client decides through their own link.
|
||||
let decide = &files["questions/decide/change.yaml"];
|
||||
assert!(decide.contains("self_transition") && decide.contains("to: approved") && decide.contains("to: declined"));
|
||||
// The spec tells the verifier whose desks count.
|
||||
assert!(files["needs.yaml"].contains("also_moved_by:\n - site_managers"));
|
||||
|
||||
// A submitter's move without an email to authorize it is refused.
|
||||
let no_email = yaml.replace(" - { name: email, type: email }\n", "");
|
||||
let plan: Plan = serde_yaml::from_str(&no_email).unwrap();
|
||||
assert!(plan.validate().unwrap_err().to_string().contains("required `email`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() {
|
||||
let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap();
|
||||
|
||||
+155
-31
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user