rate: the desks are scored on their buttons

The inside of the business reads button labels and nothing else. For
every desk and bucket with two or more buttons, a worker is told what
just happened to a case, in the state's own words, and picks the
button; scored right of total with what each miss was taken for. The
vel's neighbour-help desk scores 2 of 4 and the notice board 1 of 2:
the first numbers a desk has ever had.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-24 03:44:52 +02:00
co-authored by Claude Fable 5.1
parent 5e313b7d40
commit 9089503524
2 changed files with 88 additions and 2 deletions
+11
View File
@@ -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/`),
+77 -2
View File
@@ -156,8 +156,57 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option<Value> {
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<String> {
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<String> = 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\": <the button's number>}}",
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<Vec<(String, String, String)>> {
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<Value> {
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.