diff --git a/README.md b/README.md index 960e263..c23dd6c 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,17 @@ sentences and would pull every page down to "very easy". The `rate.json` in `runs/contractor-build-2026-09-21/` predates this and its `asked` and `next` columns are the old, pinned ones. +`rate --repo` also scores the **desks**, the inside of the business, +which reads button labels and nothing else: for every desk and bucket +with two or more buttons, a worker is told what has just happened to +a case (in the state's own words, never the label's) and picks the +button among that desk's, in a rotated order. Scored per desk as +right of total, with what each miss was mistaken for. Measured on +2026-09-24: the vel's neighbour-help desk 2 of 4 ("not solved" taken +for "connect a neighbour"), the notice board 1 of 2, the planned +contractor's subcontractor desk 4 of 7 (checking, approved and +rejected all crossed). A desk that scores full marks has labels a +stranger to the business could press right. ## Measured so far **Generated: a building contractor** (`runs/contractor-2026-09-21/`), diff --git a/src/rate.rs b/src/rate.rs index 17beda5..65670c5 100644 --- a/src/rate.rs +++ b/src/rate.rs @@ -156,8 +156,57 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option { load_page(repo, action) } -/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or -/// `questions/a/b/index.yaml`. +/// Every desk's buttons per bucket, from the simulation model: +/// (page, group, bucket, [(from, to, label)]). A desk with one button +/// for a bucket has nothing to confuse and is left out. +fn desk_buttons(model: &Value) -> Vec<(String, String, String, Vec<(String, String, String)>)> { + let mut out = Vec::new(); + for (bucket, spec) in model["buckets"].as_object().into_iter().flatten() { + for desk in spec["desks"].as_array().into_iter().flatten() { + let moves: Vec<(String, String, String)> = desk["moves"] + .as_array() + .into_iter() + .flatten() + .map(|m| (s(&m["from"]), s(&m["to"]), s(&m["label"]))) + .filter(|(_, _, l)| !l.is_empty()) + .collect(); + if moves.len() >= 2 { + out.push((s(&desk["page"]), s(&desk["group"]), bucket.clone(), moves)); + } + } + } + out.sort(); + out +} + +fn s(v: &Value) -> String { + v.as_str().unwrap_or_default().to_string() +} + +/// A worker on the desk, told what just happened to a case, picks +/// the button for it. The outcome is put in plain words from the +/// state's own name, never from the label, and the buttons are listed +/// in a seeded shuffle. +fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize) -> anyhow::Result { + let plain = |id: &str| id.replace(['_', '-'], " "); + let mut order: Vec<&str> = labels.to_vec(); + // A fixed rotation per move: the right answer is not always first. + let by = seed % order.len().max(1); + order.rotate_left(by); + let listed: Vec = order.iter().enumerate().map(|(i, l)| format!("{}. {l}", i + 1)).collect(); + let prompt = format!( + "You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": }}", + plain(group), + plain(bucket), + plain(from), + plain(to), + listed.join("\n") + ); + let reply = ask(cmd, &prompt, &seed.to_string())?; + let n = reply["choice"].as_u64().unwrap_or(0) as usize; + Ok(order.get(n.wrapping_sub(1)).map(|l| l.to_string()).unwrap_or_default()) +} + /// Every `mail:` on a state in the repo's aggregates.yaml, as /// (`bucket:state`, subject, body) with the `{placeholders}` taken /// out, so they read as the person will read them. @@ -193,6 +242,8 @@ pub(crate) fn mail_texts(repo: &Path) -> Option> { Some(out) } +/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or +/// `questions/a/b/index.yaml`. pub(crate) fn load_page(repo: &Path, route: &str) -> Option { let rel = route.trim_matches('/'); let candidates = if rel.is_empty() { @@ -248,6 +299,30 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { } let mut report = BTreeMap::new(); + // The desks: the inside of the business reads button labels and + // nothing else, so each desk is scored on whether a worker told + // what has just happened to a case picks the right button among + // that desk's buttons for the bucket. + let desks = desk_buttons(&model); + if !desks.is_empty() { + println!("{:<20} {:<24} {:>8} {}", "desk", "bucket", "buttons", "mistaken for"); + for (page, group, bucket, moves) in &desks { + let labels: Vec<&str> = moves.iter().map(|(_, _, l)| l.as_str()).collect(); + let mut right = 0; + let mut mistaken = Vec::new(); + for (i, (from, to, label)) in moves.iter().enumerate() { + let picked = button_pick(&cmd, group, bucket, from, to, &labels, i)?; + if picked == *label { + right += 1; + } else { + mistaken.push(format!("{to} as {picked:?}")); + } + } + println!("{:<20} {:<24} {:>3} of {:<2} {}", page, bucket, right, moves.len(), mistaken.join("; ")); + report.insert(format!("desk {page} {bucket}"), json!({ "right": right, "of": moves.len(), "mistaken": mistaken })); + } + println!(); + } // The mails first, when the repo carries any: a person reads a // mail alone, with no card around it, so its prose is rated on // its own - LIX and length.