//! A business's needs, stated apart from how its pages word them. //! //! A content repo may ship a `needs.yaml` at its root: each *need* says //! who wants what, where they enter, which bucket the answer must land //! in, and which group has to be able to carry it to a finished state. //! Two things are checked against it: //! //! - **Structure** (`check`): deterministic. A path of submissions //! leads from the entry page to the bucket, within an effort budget; //! the handling group has a desk over that bucket; every finished //! state is reachable through the moves that desk offers, and no //! record strands on the way. //! - **Wording** (`tasks` / `score`): each *persona* is a visitor in //! their own words. Every page on their path becomes one typed //! `choice` question for an external answering engine - the page's //! question, its alternatives as the options - and the engine's picks //! are scored against the alternatives that actually meet the need. //! A misroute is a wording defect: the page said one thing and the //! visitor heard another. //! //! The engine is deliberately outside this crate: tasks go out as //! JSONL, answers come back as JSONL, so any classifier or LLM can sit //! in between and be compared on the same tasks. use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use serde::{Deserialize, Serialize}; use crate::aggregates::AggregateSchema; use crate::content::{action_target_id, Alternative, Question}; #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct NeedsFile { #[serde(default)] pub needs: Vec, #[serde(default)] pub personas: Vec, /// The tones of voice the site is told in, one storyline each /// (portal docs, Storylines). Named here so the report can say /// which tone a storyline index is; the checks read the first. #[serde(default)] pub tones: Vec, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Need { pub id: String, /// The kind of visitor - prose, for the reader of the report. pub who: String, pub wants: String, /// The page they arrive on. #[serde(default = "root")] pub enters: String, /// The group they hold when they arrive; unset means anonymous. #[serde(default)] pub enters_as: Option, /// The alternative on the entry page that serves this need, when /// several record into the same bucket. #[serde(default)] pub via: Option, /// The bucket their answer must be recorded into. pub lands_in: String, /// The group that must be able to see the record and move it. pub handled_by: String, /// The states that count as handled. pub done_when: Vec, /// Submissions the visitor may be asked for before the record /// exists. #[serde(default = "default_max_steps")] pub max_steps: usize, /// Required fields across the whole path - the onboarding effort /// budget. #[serde(default)] pub max_fields: Option, /// Declared, and knowingly not open yet: what is missing reports /// as a warning, so the gap stays visible without failing CI. #[serde(default)] pub planned: bool, /// Open before and closed for now (full up, out of season): its /// card is on the page with no form. Reports like `planned`. #[serde(default)] pub paused: bool, /// Each state of the bucket in plain words, in the site's /// language, for the scorer: state ids are ASCII, and a Norwegian /// `ikke_lost` reads as English to a model. #[serde(default)] pub stage_words: std::collections::BTreeMap, /// The finished states that are a good outcome for the business - /// a subset of `done_when`. "lost" is finished; "won" is success. /// What a simulation or a live bucket report counts toward. #[serde(default)] pub success: Vec, /// Every stage the business said a case goes through, in its own /// order, first to last. Written from how the business describes /// its work, before any state machine exists, so that a machine /// which quietly skips a stage ("we go and look, then we quote" /// becoming open -> quoted) fails instead of passing. #[serde(default)] pub stages: Vec, /// Other groups whose desks carry some of this need's moves: a /// site manager closing what a project manager opened. Their /// buttons count toward reaching `done_when` exactly as /// `handled_by`'s do. `handled_by` must still hold a desk of its /// own - someone has to be answerable for the record. #[serde(default)] pub also_moved_by: Vec, } fn root() -> String { "/".to_string() } fn default_max_steps() -> usize { 2 } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Persona { pub id: String, pub need: String, /// The visitor's situation in their own words - never the page's. pub text: String, } pub fn parse(raw: &str) -> anyhow::Result { serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing needs.yaml: {e}")) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Level { Fail, Warn, } #[derive(Debug)] pub struct Finding { /// The need's id, or the bucket's name when `scope` is "bucket". pub need: String, pub level: Level, pub message: String, /// "need" for a need's own path; "bucket" for what holds for every /// record in a bucket whichever need brought it in. pub scope: &'static str, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Step { pub page: String, pub alternative: String, } /// One alternative on a page that still meets the need, with the /// cheapest way on from it. struct Viable { alternative: String, path: Vec, fields: usize, records_with_action: Option, } type Questions = HashMap; fn visible(q: &Question, as_group: Option<&str>) -> bool { match &q.qualifies { None => true, Some(group) => as_group == Some(group.as_str()), } } /// What a person has to fill in: required fields, less the ones that /// carry rather than ask - a `record` naming the case an answer is /// about, a `hidden` preset - which cost nobody anything. fn required_fields(alt: &Alternative) -> usize { alt.features .iter() .flat_map(|f| &f.requirements) .filter(|r| !r.optional && r.kind != "record" && r.kind != "hidden") .count() } /// The alternatives on `page` from which the need is still met within /// `remaining` submissions. fn viable( questions: &Questions, need: &Need, page: &Question, remaining: usize, entry: bool, include_disabled: bool, trail: &mut Vec, ) -> Vec { let mut out = Vec::new(); if remaining == 0 { return out; } for alt in page.alternatives.iter().filter(|a| include_disabled || !a.disabled) { if entry && need.via.as_deref().is_some_and(|via| via != alt.name) { continue; } let step = Step { page: page.id.clone(), alternative: alt.name.clone(), }; let fields = required_fields(alt); if alt.record_as.as_deref() == Some(need.lands_in.as_str()) { out.push(Viable { alternative: alt.name.clone(), path: vec![step], fields, records_with_action: Some(alt.action.is_some()), }); continue; } let Some(target) = alt .action .as_deref() .and_then(|a| action_target_id(questions, a)) .and_then(|id| questions.get(&id)) else { continue; }; if trail.contains(&target.id) || !visible(target, need.enters_as.as_deref()) { continue; } trail.push(target.id.clone()); let onward = viable(questions, need, target, remaining - 1, false, include_disabled, trail); trail.pop(); if let Some(best) = onward.into_iter().min_by_key(|v| (v.path.len(), v.fields)) { let mut path = vec![step]; path.extend(best.path); out.push(Viable { alternative: alt.name.clone(), path, fields: fields + best.fields, records_with_action: best.records_with_action, }); } } out } fn entry_viable(questions: &Questions, need: &Need, include_disabled: bool) -> Option> { let page = questions.get(&need.enters)?; let mut trail = vec![page.id.clone()]; Some(viable(questions, need, page, need.max_steps, true, include_disabled, &mut trail)) } /// The cheapest path that meets the need, if any. pub fn path_for(questions: &Questions, need: &Need) -> Option> { entry_viable(questions, need, false)? .into_iter() .min_by_key(|v| (v.path.len(), v.fields)) .map(|v| v.path) } struct Desk { page: String, group: Option, /// The page's own `qualifies`, kept apart from the resource's /// `requires_group`: when both are set and differ, only members of /// both ever see the buttons. page_group: Option, /// (from, to, button label) moves: Vec<(String, String, String)>, } fn desks_over(questions: &Questions, bucket: &str) -> Vec { let mut desks = Vec::new(); for q in questions.values() { for resource in q .alternatives .iter() .flat_map(|a| &a.features) .filter_map(|f| f.resource.as_ref()) .filter(|r| r.bucket() == Some(bucket)) { desks.push(Desk { page: q.id.clone(), group: resource.requires_group.clone().or_else(|| q.qualifies.clone()), page_group: q.qualifies.clone(), moves: resource .transitions .iter() .map(|t| (t.from.clone(), t.to.clone(), t.label.clone())) .collect(), }); } } desks.sort_by(|a, b| a.page.cmp(&b.page)); desks } /// Self-service moves in `bucket` - a visitor holding their own /// record's link, no group involved (unsubscribe, withdraw) - as /// (from, to), from the states each may be made from. fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String, String)> { questions .values() .flat_map(|q| &q.alternatives) .filter_map(|a| a.self_transition.as_ref()) .filter(|s| s.bucket == bucket) .flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone()))) .collect() } /// The moves a clock makes in `schema` (`after:` on a state), as /// (from, to). Nobody presses them, and they still move records. fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> { let mut moves: Vec<(String, String)> = schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect(); moves.sort(); moves } /// States reachable from `start` along `moves`. fn reachable(start: &str, moves: &[(String, String)]) -> HashSet { let mut reached = HashSet::from([start.to_string()]); let mut queue = VecDeque::from([start.to_string()]); while let Some(state) = queue.pop_front() { for (_, to) in moves.iter().filter(|(from, _)| *from == state) { if reached.insert(to.clone()) { queue.push_back(to.clone()); } } } reached } /// Who else offers `from -> to` in the bucket, as "page (group)", for /// a finding that has to say where the missing button already is. fn offered_elsewhere(desks: &[Desk], from: &str, to: &str) -> Vec { desks .iter() .filter(|d| d.moves.iter().any(|(f, t, _)| f == from && t == to)) .map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated"))) .collect() } /// Structural check of every need. `Fail` findings mean the business /// need is not met by the content as written. pub fn check( file: &NeedsFile, questions: &Questions, aggregates: &HashMap, ) -> Vec { let mut findings = Vec::new(); let mut seen = HashSet::new(); // (bucket, state) a need already reported - stranding on its way // to done, or out of its reach - so the bucket pass does not say // it twice. let mut strands_reported: HashSet<(String, String)> = HashSet::new(); let mut unreached_reported: HashSet<(String, String)> = HashSet::new(); for need in &file.needs { let mut report = |level: Level, message: String| { findings.push(Finding { need: need.id.clone(), scope: "need", level: if need.planned || need.paused { Level::Warn } else { level }, message: match (need.paused, need.planned, level == Level::Fail) { (true, _, true) => format!("paused, not open now: {message}"), (false, true, true) => format!("planned, not met yet: {message}"), _ => message, }, }) }; if !seen.insert(need.id.as_str()) { report(Level::Fail, "declared more than once".to_string()); continue; } // The visitor's side: can they get their answer recorded? match questions.get(&need.enters) { None => report(Level::Fail, format!("entry page {:?} does not exist", need.enters)), Some(page) if !visible(page, need.enters_as.as_deref()) => report( Level::Fail, format!( "entry page {:?} is gated on {:?}, which this visitor does not hold", need.enters, page.qualifies ), ), Some(page) => { if page.responsible.is_none() { report( Level::Warn, format!("entry page {:?} names no one responsible to turn to", need.enters), ); } let best = entry_viable(questions, need, false) .unwrap_or_default() .into_iter() .min_by_key(|v| (v.path.len(), v.fields)); match best { None if entry_viable(questions, need, true).is_some_and(|v| !v.is_empty()) => { let blocked = entry_viable(questions, need, true) .unwrap_or_default() .into_iter() .min_by_key(|v| (v.path.len(), v.fields)) .map(|v| v.path) .unwrap_or_default(); let at = blocked .iter() .find(|s| { questions[&s.page] .alternatives .iter() .any(|a| a.name == s.alternative && a.disabled) }) .map(|s| format!("{:?} on {}", s.alternative, s.page)) .unwrap_or_default(); report( Level::Fail, format!( "the path exists but is closed: {at} is `disabled`, so the answer is advertised and refused" ), ) } None => report( Level::Fail, format!( "no path from {:?}{} records into {:?} within {} submission(s)", need.enters, need.via .as_ref() .map(|v| format!(" via {v:?}")) .unwrap_or_default(), need.lands_in, need.max_steps ), ), Some(best) => { if let Some(budget) = need.max_fields { if best.fields > budget { report( Level::Fail, format!( "the path asks for {} required field(s), over the budget of {budget}", best.fields ), ); } } if best.records_with_action == Some(false) { report( Level::Warn, "the recording alternative has no `action` - the visitor is never told what happens next".to_string(), ); } } } } } // The business's side: can the handling group finish it? let Some(schema) = aggregates.get(&need.lands_in) else { report( Level::Fail, format!("aggregates.yaml declares no state machine for {:?}", need.lands_in), ); continue; }; let desks = desks_over(questions, &need.lands_in); let own: Vec<&Desk> = desks .iter() .filter(|d| { d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g)) }) .collect(); if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) { let others: Vec = desks .iter() .map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated"))) .collect(); report( Level::Fail, format!( "no desk gated on {:?} reads {:?}{}", need.handled_by, need.lands_in, if others.is_empty() { String::new() } else { format!(" - it is read by {}", others.join(", ")) } ), ); continue; } // Every move that carries this need's records: the buttons on // the desks of handled_by/also_moved_by, the visitor's own // links, and the clocks. let mut moves: Vec<(String, String)> = own .iter() .flat_map(|d| &d.moves) .filter(|(from, to, _)| schema.allowed(from).contains(to)) .map(|(from, to, _)| (from.clone(), to.clone())) .collect(); moves.extend(self_moves(questions, &need.lands_in, &schema.initial)); moves.extend(clock_moves(schema)); let exits = |state: &str| -> Vec { moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect() }; let movers = std::iter::once(need.handled_by.as_str()) .chain(need.also_moved_by.iter().map(String::as_str)) .map(|g| format!("{g:?}")) .collect::>() .join(" or "); // The fix for a missing move: the button on handled_by's desk, // or the group whose desk already has it added to also_moved_by. let fix_for = |from: &str, to: &str| -> String { let elsewhere = offered_elsewhere(&desks, from, to); let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk"); if elsewhere.is_empty() { format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}") } else { format!( "{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}", elsewhere.join(", ") ) } }; let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect(); // Reachability walks the whole machine (a finished state may // lead on to another: subscribed, then unsubscribed). Stranding // only counts on the way to the first finished state here; what // strands a record afterwards is the bucket's finding (below). let mut reached: HashSet = HashSet::from([schema.initial.clone()]); let mut queue = VecDeque::from([(schema.initial.clone(), false)]); let mut stranded = Vec::new(); while let Some((state, past_done)) = queue.pop_front() { let past_done = past_done || done.contains(state.as_str()); let next = exits(&state); if next.is_empty() && !past_done { stranded.push(state.clone()); } for to in next { if reached.insert(to.clone()) { queue.push_back((to, past_done)); } } } // The declared edges into `state` from somewhere a record does // get to: the moves that would close the gap. let ways_in = |state: &str| -> Vec<(String, String)> { let mut ways: Vec<(String, String)> = reached .iter() .filter(|from| schema.allowed(from).iter().any(|to| to == state)) .map(|from| (from.clone(), state.to_string())) .collect(); ways.sort(); ways }; let missing = |state: &str| -> String { match ways_in(state).first() { Some((from, to)) => format!(" - fix: {}", fix_for(from, to)), None => format!( " - and no state a record reaches has a declared edge into it: add one in aggregates.yaml under {:?}", need.lands_in ), } }; for state in &need.done_when { if !schema.has_state(state) { report( Level::Fail, format!("done_when names {state:?}, which {:?} has no such state", need.lands_in), ); } else if !reached.contains(state) { unreached_reported.insert((need.lands_in.clone(), state.clone())); report( Level::Fail, format!( "{:?} cannot move a record to {state:?} - no desk of theirs offers the moves{}", need.handled_by, missing(state) ), ); } } for stage in &need.stages { if !schema.has_state(stage) { report( Level::Fail, format!( "the business describes a stage {stage:?} that {:?} has no state for", need.lands_in ), ); } else if !reached.contains(stage) { unreached_reported.insert((need.lands_in.clone(), stage.clone())); report( Level::Fail, format!( "stage {stage:?} exists but {:?} is offered no moves that reach it{}", need.handled_by, missing(stage) ), ); } } // In the business's order: each working stage reachable from // the one before it, so a machine where "quoted" cannot follow // "surveyed" does not pass for one that goes surveyed -> quoted. // The finished states that close the list are alternatives // (won, lost), not a sequence, so they are left out. for pair in need.stages.windows(2) { let (before, after) = (&pair[0], &pair[1]); if done.contains(before.as_str()) || done.contains(after.as_str()) { continue; } if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) { report( Level::Fail, format!( "stage {after:?} comes after {before:?} for the business, but no move leads on from {before:?} to it{}", match schema.allowed(before).iter().find(|to| reachable(to, &moves).contains(after) || *to == after) { Some(to) => format!(" - fix: {}", fix_for(before, to)), None => format!(" - add an edge from {before:?} toward {after:?} in aggregates.yaml under {:?}", need.lands_in), } ), ); } } for state in &need.success { if !need.done_when.contains(state) { report( Level::Fail, format!("success names {state:?}, which is not one of done_when"), ); } } stranded.sort(); for state in &stranded { strands_reported.insert((need.lands_in.clone(), state.clone())); } for state in stranded { let fix = match schema.allowed(&state).first() { Some(to) => format!(" - fix: {}", fix_for(&state, to)), None => format!( " - and {:?} declares no way out of it: add {state:?} to done_when if it is finished, or an edge out in aggregates.yaml", need.lands_in ), }; report( Level::Fail, format!("records strand in {state:?}: {movers} is offered no move out of it{fix}"), ); } } findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported)); let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect(); for persona in &file.personas { if !need_ids.contains(persona.need.as_str()) { findings.push(Finding { need: persona.need.clone(), scope: "need", level: Level::Fail, message: format!("persona {:?} points at a need that is not declared", persona.id), }); } } findings } /// What must hold for every record in a bucket some need lands in, /// whichever need brought it: every state is somewhere a record can /// get to, no state a record gets to holds it with no way on (before /// done or after), every declared move is a button someone has, every /// desk can be seen by whoever its buttons are for, and every mail /// goes out from a state records reach, saying only what the record /// holds. fn operations( file: &NeedsFile, questions: &Questions, aggregates: &HashMap, strands_reported: &HashSet<(String, String)>, unreached_reported: &HashSet<(String, String)>, ) -> Vec { let mut findings = Vec::new(); let mut buckets: Vec<&str> = file.needs.iter().map(|n| n.lands_in.as_str()).collect(); buckets.sort(); buckets.dedup(); for bucket in buckets { let Some(schema) = aggregates.get(bucket) else { continue }; let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect(); // Only open needs make a gap fail; a bucket that only planned // or paused needs use reports it as a warning. let open = needs.iter().any(|n| !n.planned && !n.paused); let mut report = |level: Level, message: String| { findings.push(Finding { need: bucket.to_string(), scope: "bucket", level: if open { level } else { Level::Warn }, message, }) }; let desks = desks_over(questions, bucket); let mut moves: Vec<(String, String)> = desks .iter() .flat_map(|d| &d.moves) .filter(|(from, to, _)| schema.allowed(from).contains(to)) .map(|(from, to, _)| (from.clone(), to.clone())) .collect(); moves.extend(self_moves(questions, bucket, &schema.initial)); moves.extend(clock_moves(schema)); let reached = reachable(&schema.initial, &moves); let offered = |from: &str, to: &str| moves.iter().any(|(f, t)| f == from && t == to); // Where a missing button goes: the desk of the group answerable // for the bucket's first need. let home = needs .iter() .find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str()))) .map(|d| d.page.clone()) .unwrap_or_else(|| "the handling group's desk".to_string()); let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}"); let mut states: Vec<&String> = schema.event_for_state.keys().collect(); states.sort(); for state in &states { if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) { continue; } let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect(); ways.sort(); report( Level::Fail, format!( "state {state:?} is never reached: no desk, link or clock moves a record there - fix: {}", match ways.first() { Some(from) => button(from, state), None => "declare an edge into it from a state records reach in aggregates.yaml, or remove it".to_string(), } ), ); } for state in states.iter().filter(|s| reached.contains(**s)) { let out = schema.allowed(state); if out.is_empty() { continue; // a final state: nothing should move it } let offered_out: Vec<&String> = out.iter().filter(|to| offered(state, to)).collect(); if offered_out.is_empty() { if !strands_reported.contains(&(bucket.to_string(), (*state).clone())) { report( Level::Fail, format!( "records strand in {state:?}: {bucket:?} declares moves on to {} but no desk, link or clock offers one - fix: {}", out.join(", "), button(state, &out[0]) ), ); } continue; } for to in out.iter().filter(|to| !offered(state, to)) { report( Level::Warn, format!( "the move {state:?} -> {to:?} is declared but no desk, link or clock offers it - {}, or drop the edge from aggregates.yaml", button(state, to) ), ); } } for desk in &desks { if let (Some(page_group), Some(group)) = (&desk.page_group, &desk.group) { if page_group != group { report( Level::Warn, format!( "the desk on {} is gated twice: the page qualifies {page_group:?}, its list requires {group:?} - only members of both see the buttons; make them the same group", desk.page ), ); } } } // The fields a record of this bucket holds: everything asked on // a form that records into it. let fields: Vec<(&str, &str)> = questions .values() .flat_map(|q| &q.alternatives) .filter(|a| a.record_as.as_deref() == Some(bucket)) .flat_map(|a| &a.features) .flat_map(|f| &f.requirements) .map(|r| (r.name.as_str(), r.kind.as_str())) .collect(); let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect(); mail_states.sort_by(|a, b| a.0.cmp(b.0)); for (state, mail) in mail_states { if !reached.contains(state) { report(Level::Warn, format!("the mail on entering {state:?} never goes out: no record reaches {state:?}")); } let mut unknown: Vec = Vec::new(); for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) { let known = match key.split_once('.') { Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"), None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key), }; if !known && !unknown.contains(&key) { unknown.push(key); } } if !unknown.is_empty() { let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect(); names.sort(); names.dedup(); report( Level::Fail, format!( "the mail on entering {state:?} uses {}, which no form recording into {bucket:?} collects - it would go out blank there; the forms collect: {}", unknown.iter().map(|k| format!("{{{k}}}")).collect::>().join(", "), if names.is_empty() { "nothing".to_string() } else { names.join(", ") } ), ); } } } findings } /// What the mailer fills in on every state mail, whatever the record /// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`). const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"]; /// The `{key}`s in a mail template, read the way `mail::render` reads /// them: letters, digits, `_`, `-` and `.`, closed by `}`. fn placeholders(template: &str) -> Vec { let mut keys = Vec::new(); let mut rest = template; while let Some(start) = rest.find('{') { let after = &rest[start + 1..]; let len = after .char_indices() .take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.') .last() .map(|(i, c)| i + c.len_utf8()) .unwrap_or(0); if len > 0 && after[len..].starts_with('}') { keys.push(after[..len].to_string()); } rest = after; } keys } /// Everything a simulation of the business needs, resolved: each /// need's path with the choice on every page and the form that gets /// filled, each bucket's state machine with the buttons every desk /// offers, and the personas. Portal resolves it because only portal /// loads content the way the runtime does (derived ids, relative /// refs, sections); whoever simulates reads plain JSON. pub fn sim_model( file: &NeedsFile, questions: &Questions, aggregates: &HashMap, ) -> serde_json::Value { use serde_json::json; let mut needs = Vec::new(); let mut buckets = serde_json::Map::new(); for need in &file.needs { let Some(path) = path_for(questions, need) else { continue; }; let steps: Vec = path .iter() .enumerate() .map(|(i, step)| { let page = &questions[&step.page]; let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); let expect: Vec = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) .into_iter() .map(|v| v.alternative) .collect(); let fields: Vec = page .alternatives .iter() .find(|a| a.name == step.alternative) .into_iter() .flat_map(|a| &a.features) .flat_map(|f| &f.requirements) .map(|r| { json!({ "name": r.name, "label": r.label.clone().unwrap_or_else(|| r.name.clone()), "type": r.kind, "optional": r.optional, "options": r.options, }) }) .collect(); json!({ "page": step.page, "question": page.name, "context": page_context(page), "options": page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).map(|a| json!({ "name": a.name, "description": plain(&a.description), })).collect::>(), "expect": expect, "alternative": step.alternative, "fields": fields, }) }) .collect(); needs.push(json!({ "id": need.id, "who": need.who, "wants": need.wants, "enters_as": need.enters_as, "lands_in": need.lands_in, "handled_by": need.handled_by, "done_when": need.done_when, "success": need.success, "steps": steps, })); if let Some(schema) = aggregates.get(&need.lands_in) { // The simulator's shape: the states a visitor's own link // moves to (it does not model where from). let mut self_move_targets: Vec = self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect(); self_move_targets.dedup(); buckets.entry(need.lands_in.clone()).or_insert_with(|| { json!({ "initial": schema.initial, "transitions": schema.transitions, "words": need.stage_words, "desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({ "page": d.page, "group": d.group, "moves": d.moves.iter().map(|(from, to, label)| json!({ "from": from, "to": to, "label": label, })).collect::>(), })).collect::>(), "self_moves": self_move_targets, }) }); } } json!({ "needs": needs, "buckets": buckets, "personas": file.personas.iter().map(|p| json!({ "id": p.id, "need": p.need, "text": plain(&p.text), })).collect::>(), }) } /// One decision for the answering engine - the shape is laya's /// `predict(state, questions)` input, so an adapter is a loop and /// nothing more; any other engine reads the same fields. #[derive(Debug, Serialize)] pub struct Task { pub id: String, pub persona: String, pub need: String, pub page: String, pub state: BTreeMap<&'static str, String>, pub questions: BTreeMap<&'static str, ChoiceQuestion>, /// Every alternative that still meets the need from here. The /// engine never sees this. pub expect: Vec, } #[derive(Debug, Serialize)] pub struct ChoiceQuestion { #[serde(rename = "type")] pub kind: &'static str, pub instructions: String, pub criteria: BTreeMap, } fn plain(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } /// A card a visitor can act on: it leads to a page, records an /// answer, or moves a record. A card with none of those is read, not /// picked - the opener that says what the business does, the look /// around - and is context on the page, never an option. pub fn leads_somewhere(a: &Alternative) -> bool { a.action.is_some() || a.record_as.is_some() || a.self_transition.is_some() } /// What the page says before its options: every card that leads /// nowhere, its heading, description and features, as one line each. pub fn page_context(page: &Question) -> String { page.alternatives .iter() .filter(|a| !a.disabled && !leads_somewhere(a)) .map(|a| { let mut line = a.name.clone(); let description = plain(&a.description); if !description.is_empty() { line.push_str(&format!(": {description}")); } for f in &a.features { let text = plain(&f.description); match (f.name.trim().is_empty(), text.is_empty()) { (true, true) => {} (true, false) => line.push_str(&format!(" {text}")), (false, true) => line.push_str(&format!(" {}.", f.name.trim())), (false, false) => line.push_str(&format!(" {}: {text}", f.name.trim())), } } line }) .collect::>() .join("\n") } /// Every (persona, page-on-their-path) pair with a real choice on it. /// `skim` shows the engine only each alternative's heading - what a /// visitor who never reads the description has to go on. pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec { let mut out = Vec::new(); for persona in &file.personas { let Some(need) = file.needs.iter().find(|n| n.id == persona.need) else { continue; }; let Some(path) = path_for(questions, need) else { continue; }; for (i, step) in path.iter().enumerate() { let page = &questions[&step.page]; let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect(); if open.len() < 2 { continue; } let context = page_context(page); let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); let expect: Vec = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) .into_iter() .map(|v| v.alternative) .collect(); let criteria = open .iter() .map(|a| { let description = plain(&a.description); ( a.name.clone(), if skim || description.is_empty() { a.name.clone() } else { description }, ) }) .collect(); out.push(Task { id: format!("{}@{}", persona.id, step.page), persona: persona.id.clone(), need: need.id.clone(), page: step.page.clone(), state: if context.is_empty() { BTreeMap::from([("body", plain(&persona.text))]) } else { BTreeMap::from([("body", plain(&persona.text)), ("page", context)]) }, questions: BTreeMap::from([( "pick", ChoiceQuestion { kind: "choice", instructions: format!( "A visitor reads the page question {:?}. Which alternative do they choose?", page.name ), criteria, }, )]), expect, }); } } out } #[derive(Debug, Deserialize)] pub struct Answer { pub id: String, pub choice: String, #[serde(default)] pub confidence: Option, } #[derive(Debug, Default)] pub struct Score { pub total: usize, pub correct: usize, /// (task id, picked, expected, confidence) pub misroutes: Vec<(String, String, Vec, Option)>, /// Right, but the engine was under `hesitant_below` - wording that /// works and is still close to a coin flip. pub hesitant: Vec<(String, f64)>, pub unanswered: Vec, } impl Score { pub fn accuracy(&self) -> f64 { if self.total == 0 { 1.0 } else { self.correct as f64 / self.total as f64 } } } pub fn score(tasks: &[Task], answers: &[Answer], hesitant_below: f64) -> Score { let by_id: HashMap<&str, &Answer> = answers.iter().map(|a| (a.id.as_str(), a)).collect(); let mut score = Score { total: tasks.len(), ..Score::default() }; for task in tasks { match by_id.get(task.id.as_str()) { None => score.unanswered.push(task.id.clone()), Some(answer) if task.expect.contains(&answer.choice) => { score.correct += 1; if let Some(c) = answer.confidence.filter(|c| *c < hesitant_below) { score.hesitant.push((task.id.clone(), c)); } } Some(answer) => score.misroutes.push(( task.id.clone(), answer.choice.clone(), task.expect.clone(), answer.confidence, )), } } score } #[cfg(test)] mod tests { use super::*; use crate::aggregates::parse_aggregates_yaml; use crate::content::build_questions; fn site(desk_moves: &str) -> (Questions, HashMap) { let files = vec![ ( "index.yaml".to_string(), "name: What are you here for?\nresponsible: { name: A, contact: a@b.c }\nalternatives:\n - name: Hire us\n description: Bring a problem.\n action: /thanks\n record_as: projects\n features:\n - name: Brief\n requirements:\n - name: email\n type: email\n - name: brief\n type: textarea\n - name: budget\n optional: true\n - name: Follow along\n description: Notes by mail.\n action: /thanks\n record_as: subscribers\n".to_string(), ), ("thanks.yaml".to_string(), "name: Thanks\nfollowup: true\n".to_string()), ( "desk/index.yaml".to_string(), format!("name: Desk\nqualifies: owners\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: {{ kind: kv, bucket: projects }}\n requires_group: owners\n transitions:\n{desk_moves}"), ), ]; let aggregates = parse_aggregates_yaml( "aggregates:\n - bucket: projects\n initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: { event: subscribed }\n transitions: {}\n", ) .unwrap(); (build_questions(&files).unwrap(), aggregates) } const FULL_DESK: &str = " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n"; fn needs(extra: &str) -> NeedsFile { parse(&format!( "needs:\n - id: hire\n who: a client\n wants: work done\n lands_in: projects\n handled_by: owners\n done_when: [won, lost]\n{extra}personas:\n - id: founder\n need: hire\n text: I need someone to build our booking tool.\n" )) .unwrap() } fn fails(findings: &[Finding]) -> Vec<&str> { findings .iter() .filter(|f| f.level == Level::Fail) .map(|f| f.message.as_str()) .collect() } #[test] fn a_met_need_is_silent() { let (questions, aggregates) = site(FULL_DESK); let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates); assert!(findings.is_empty(), "{findings:?}"); } #[test] fn effort_budget_counts_required_fields_only() { let (questions, aggregates) = site(FULL_DESK); let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates); assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]); // A field that carries rather than asks is not effort: the // record an answer is about arrives on the link. let (mut questions, aggregates) = site(FULL_DESK); let front = questions.get_mut("/").unwrap(); let brief = &mut front.alternatives[0].features[0].requirements; let mut carrier = brief[0].clone(); carrier.name = "case".into(); carrier.kind = "record".into(); carrier.of = Some("projects".into()); carrier.optional = false; brief.push(carrier); let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates); assert!(fails(&findings).is_empty(), "{findings:?}"); } #[test] fn a_desk_missing_a_move_strands_records_and_cannot_finish() { // The desk can quote, but never close a quoted job. let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); let findings = check(&needs(""), &questions, &aggregates); assert_eq!( fails(&findings), vec![ "\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk", "records strand in \"quoted\": \"owners\" is offered no move out of it - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk", ] ); } #[test] fn a_finished_state_may_lead_on_to_another() { // quoted counts as handled here, yet won must still be // reachable past it; lost-after-quoted is not this need's // business, so nothing strands. let (questions, aggregates) = site(FULL_DESK); let mut file = needs(""); file.needs[0].done_when = vec!["quoted".to_string(), "won".to_string(), "lost".to_string()]; let findings = check(&file, &questions, &aggregates); assert!(findings.is_empty(), "{findings:?}"); } #[test] fn a_disabled_alternative_is_named_as_the_reason() { let (mut questions, aggregates) = site(FULL_DESK); questions.get_mut("/").unwrap().alternatives[0].disabled = true; let findings = check(&needs(""), &questions, &aggregates); assert_eq!( fails(&findings), vec!["the path exists but is closed: \"Hire us\" on / is `disabled`, so the answer is advertised and refused"] ); } #[test] fn success_must_be_a_finished_state_and_the_sim_model_carries_the_whole_flow() { let (questions, aggregates) = site(FULL_DESK); let mut file = needs(" success: [won]\n"); assert!(check(&file, &questions, &aggregates).is_empty()); let model = sim_model(&file, &questions, &aggregates); let need = &model["needs"][0]; assert_eq!(need["success"][0], "won"); let step = &need["steps"][0]; assert_eq!(step["question"], "What are you here for?"); assert_eq!(step["expect"][0], "Hire us"); assert_eq!(step["options"].as_array().unwrap().len(), 2); // The form the visitor fills, required and optional alike. let fields = step["fields"].as_array().unwrap(); assert_eq!(fields.len(), 3); assert_eq!(fields[2]["optional"], true); // The desk's buttons, labelled, per state. let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap(); assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win")); assert_eq!(model["buckets"]["projects"]["initial"], "open"); assert_eq!(model["personas"][0]["id"], "founder"); // A stage the business named must exist as a state. file.needs[0].stages = vec!["open".into(), "surveyed".into(), "quoted".into()]; assert_eq!( fails(&check(&file, &questions, &aggregates)), vec!["the business describes a stage \"surveyed\" that \"projects\" has no state for"] ); file.needs[0].stages = vec!["open".into(), "quoted".into(), "won".into()]; assert!(check(&file, &questions, &aggregates).is_empty()); file.needs[0].success = vec!["quoted".to_string()]; assert_eq!( fails(&check(&file, &questions, &aggregates)), vec!["success names \"quoted\", which is not one of done_when"] ); } #[test] fn a_need_can_be_carried_by_more_than_one_groups_desk() { // owners may only quote or lose; closing a quoted job is the // site managers' button, on a desk of their own. let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); let more = build_questions(&[( "site/index.yaml".to_string(), "name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(), )]) .unwrap(); questions.extend(more); let mut file = needs(""); // Alone, owners cannot finish it... assert_eq!(fails(&check(&file, &questions, &aggregates)).len(), 2); // ...with the site managers' buttons counted, they can. file.needs[0].also_moved_by = vec!["site_managers".to_string()]; assert!(check(&file, &questions, &aggregates).is_empty()); // But someone has to be answerable: handled_by needs its own desk. file.needs[0].handled_by = "nobody".to_string(); assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\"")); } #[test] fn the_wrong_group_holding_the_desk_is_a_failure() { let (questions, aggregates) = site(FULL_DESK); let mut file = needs(""); file.needs[0].handled_by = "sales".to_string(); let findings = check(&file, &questions, &aggregates); assert_eq!( fails(&findings), vec!["no desk gated on \"sales\" reads \"projects\" - it is read by /desk (owners)"] ); } #[test] fn via_pins_the_entry_alternative() { let (questions, aggregates) = site(FULL_DESK); let mut file = needs(""); file.needs[0].via = Some("Follow along".to_string()); let findings = check(&file, &questions, &aggregates); assert_eq!( fails(&findings), vec!["no path from \"/\" via \"Follow along\" records into \"projects\" within 2 submission(s)"] ); } #[test] fn tasks_carry_the_page_as_a_choice_and_score_separates_misroutes() { let (questions, _) = site(FULL_DESK); let file = needs(""); let tasks = tasks(&file, &questions, false); let skimmed = super::tasks(&file, &questions, true); assert_eq!(skimmed[0].questions["pick"].criteria["Follow along"], "Follow along"); assert_eq!(tasks.len(), 1); let task = &tasks[0]; assert_eq!(task.id, "founder@/"); assert_eq!(task.expect, vec!["Hire us"]); let q = &task.questions["pick"]; assert_eq!(q.kind, "choice"); assert_eq!(q.criteria["Follow along"], "Notes by mail."); assert!(q.instructions.contains("What are you here for?")); let right = vec![Answer { id: "founder@/".into(), choice: "Hire us".into(), confidence: Some(0.55) }]; let s = score(&tasks, &right, 0.6); assert_eq!((s.correct, s.misroutes.len(), s.hesitant.len()), (1, 0, 1)); let wrong = vec![Answer { id: "founder@/".into(), choice: "Follow along".into(), confidence: Some(0.9) }]; let s = score(&tasks, &wrong, 0.6); assert_eq!((s.correct, s.misroutes.len()), (0, 1)); assert_eq!(s.accuracy(), 0.0); assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]); } /// The fixture site with its aggregates replaced, for the checks /// that are about the state machine rather than the pages. fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap) { let (questions, _) = site(desk_moves); let aggregates = parse_aggregates_yaml(&format!( "aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n" )) .unwrap(); (questions, aggregates) } fn warns(findings: &[Finding]) -> Vec<&str> { findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect() } #[test] fn a_clock_is_a_move() { // Nobody closes a stale quote; six weeks do. That is not a strand. let (questions, aggregates) = site_with( " - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n", " initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted, after: { days: 42, to: lost } }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted]\n quoted: [won, lost]\n", ); let findings = check(&needs(""), &questions, &aggregates); assert!(fails(&findings).is_empty(), "{findings:?}"); } #[test] fn a_state_nobody_reaches_and_a_strand_after_done_are_the_buckets() { // "archived" follows "won" but no desk offers it; "parked" has // no way in at all. let (questions, aggregates) = site_with( FULL_DESK, " initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n archived: { event: archived }\n done: { event: done }\n parked: { event: parked }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n won: [archived]\n archived: [done]\n parked: [open]\n", ); let findings = check(&needs(""), &questions, &aggregates); let bucket: Vec<&Finding> = findings.iter().filter(|f| f.scope == "bucket").collect(); assert!(bucket.iter().all(|f| f.need == "projects"), "{bucket:?}"); assert_eq!( fails(&findings), vec![ "state \"archived\" is never reached: no desk, link or clock moves a record there - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk", "state \"done\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it", "state \"parked\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it", "records strand in \"won\": \"projects\" declares moves on to archived but no desk, link or clock offers one - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk", ] ); } #[test] fn an_edge_nobody_offers_is_a_warning_when_there_is_another_way_on() { let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n"); let findings = check(&needs(""), &questions, &aggregates); assert!(fails(&findings).is_empty(), "{findings:?}"); assert_eq!( warns(&findings), vec!["the move \"open\" -> \"lost\" is declared but no desk, link or clock offers it - add `{ from: open, to: lost, label: ... }` to the transitions on /desk, or drop the edge from aggregates.yaml"] ); } #[test] fn working_stages_must_follow_each_other() { // The business surveys, then quotes; this machine can only // quote straight from open, and surveying leads nowhere. let (questions, aggregates) = site_with( " - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n", " initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n", ); let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates); assert_eq!( fails(&findings), vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""] ); } #[test] fn a_mail_may_only_say_what_the_record_holds() { let (questions, aggregates) = site_with( FULL_DESK, " initial: open\n states:\n open: { event: submitted, mail: { to: submitter, subject: 'Got it, {email}', body: 'We read {brief} and will reply at {site}. {refnr} {link}' } }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n", ); let findings = check(&needs(""), &questions, &aggregates); assert_eq!( fails(&findings), vec!["the mail on entering \"open\" uses {refnr}, which no form recording into \"projects\" collects - it would go out blank there; the forms collect: brief, budget, email"] ); } #[test] fn a_desk_gated_twice_is_seen_only_by_members_of_both() { let (mut questions, aggregates) = site(FULL_DESK); questions.get_mut("/desk").unwrap().qualifies = Some("staff".into()); let findings = check(&needs(""), &questions, &aggregates); assert!( warns(&findings).contains(&"the desk on /desk is gated twice: the page qualifies \"staff\", its list requires \"owners\" - only members of both see the buttons; make them the same group"), "{findings:?}" ); } }