2026-09-22 17:25:52 +02:00
//! 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 };
2026-09-30 15:29:30 +02:00
use crate ::aggregates ::AggregateSchema ;
use crate ::content ::{ action_target_id , Alternative , Question };
2026-09-22 17:25:52 +02:00
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NeedsFile {
#[serde(default)]
pub needs : Vec < Need > ,
#[serde(default)]
pub personas : Vec < Persona > ,
2026-09-30 15:17:20 +02:00
/// 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 < String > ,
2026-09-22 17:25:52 +02:00
}
#[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 < String > ,
/// The alternative on the entry page that serves this need, when
/// several record into the same bucket.
#[serde(default)]
pub via : Option < String > ,
/// 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 < String > ,
/// 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 < usize > ,
/// 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 ,
2026-09-30 20:09:14 +02:00
/// 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 ,
2026-09-24 05:17:11 +02:00
/// 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 < String , String > ,
2026-09-22 17:25:52 +02:00
/// 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 < String > ,
/// 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 < String > ,
/// 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 < String > ,
}
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 < NeedsFile > {
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 {
2026-10-02 11:25:49 +02:00
/// The need's id, or the bucket's name when `scope` is "bucket".
2026-09-22 17:25:52 +02:00
pub need : String ,
pub level : Level ,
pub message : String ,
2026-10-02 11:25:49 +02:00
/// "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 ,
2026-10-03 10:48:09 +02:00
/// What kind of gap it is, stable across releases: `strand`,
/// `edge-unoffered`, `done-unreachable`, ... (docs/lint.md).
pub code : & 'static str ,
/// What in the content it is about: a state, a move (`from>to`), a
/// page. With the scope, need and code it makes the finding's id.
pub subject : String ,
}
impl Finding {
/// The same gap keeps the same id however its message is worded,
/// so a fix round can tell what it closed, what is still open and
/// what it broke. FNV-1a over scope, need, code and subject.
pub fn id ( & self ) -> String {
let mut hash : u64 = 0xcbf2_9ce4_8422_2325 ;
for part in [ self . scope , & self . need , self . code , & self . subject ] {
for byte in part . bytes (). chain ( std ::iter ::once ( 0 )) {
hash ^= u64 ::from ( byte );
hash = hash . wrapping_mul ( 0x0100_0000_01b3 );
}
}
format! ( " {:08x} " , hash >> 32 )
}
/// The change that closes it, when the lint knows one: what follows
/// "fix:" in the message.
pub fn fix ( & self ) -> Option <& str > {
self . message . split_once ( " - fix: " ). map ( | ( _ , fix ) | fix )
}
/// One line of `--findings` JSONL.
pub fn to_json ( & self ) -> serde_json ::Value {
serde_json ::json! ({
"id" : self . id (),
"level" : match self . level { Level ::Fail => "fail" , Level ::Warn => "warn" },
"code" : self . code ,
"scope" : self . scope ,
"need" : self . need ,
"subject" : self . subject ,
"message" : self . message ,
"fix" : self . fix (),
})
}
2026-09-22 17:25:52 +02:00
}
#[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 < Step > ,
fields : usize ,
records_with_action : Option < bool > ,
}
type Questions = HashMap < String , Question > ;
fn visible ( q : & Question , as_group : Option <& str > ) -> bool {
match & q . qualifies {
None => true ,
Some ( group ) => as_group == Some ( group . as_str ()),
}
}
2026-09-30 08:57:03 +02:00
/// 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.
2026-09-22 17:25:52 +02:00
fn required_fields ( alt : & Alternative ) -> usize {
alt . features
. iter ()
. flat_map ( | f | & f . requirements )
2026-09-30 08:57:03 +02:00
. filter ( | r | ! r . optional && r . kind != "record" && r . kind != "hidden" )
2026-09-22 17:25:52 +02:00
. 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 < String > ,
) -> Vec < Viable > {
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 < Vec < Viable >> {
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 < Vec < Step >> {
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 < String > ,
2026-10-02 11:25:49 +02:00
/// 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 < String > ,
2026-09-22 17:25:52 +02:00
/// (from, to, button label)
moves : Vec < ( String , String , String ) > ,
}
fn desks_over ( questions : & Questions , bucket : & str ) -> Vec < Desk > {
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 ()),
2026-10-02 11:25:49 +02:00
page_group : q . qualifies . clone (),
2026-09-22 17:25:52 +02:00
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
}
2026-10-02 11:25:49 +02:00
/// 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 ) > {
2026-09-22 17:25:52 +02:00
questions
. values ()
. flat_map ( | q | & q . alternatives )
. filter_map ( | a | a . self_transition . as_ref ())
. filter ( | s | s . bucket == bucket )
2026-10-02 11:25:49 +02:00
. 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 < String > {
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 < String > {
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" )))
2026-09-22 17:25:52 +02:00
. 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 < String , AggregateSchema > ,
) -> Vec < Finding > {
let mut findings = Vec ::new ();
let mut seen = HashSet ::new ();
2026-10-02 11:25:49 +02:00
// (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 ();
2026-09-22 17:25:52 +02:00
for need in & file . needs {
2026-10-03 10:48:09 +02:00
let mut report = | level : Level , code : & 'static str , subject : String , message : String | {
2026-09-22 17:25:52 +02:00
findings . push ( Finding {
need : need . id . clone (),
2026-10-02 11:25:49 +02:00
scope : "need" ,
2026-10-03 10:48:09 +02:00
code ,
subject ,
2026-09-30 20:09:14 +02:00
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 ,
2026-09-22 17:25:52 +02:00
},
})
};
if ! seen . insert ( need . id . as_str ()) {
2026-10-03 10:48:09 +02:00
report ( Level ::Fail , "need-duplicate" , String ::new (), "declared more than once" . to_string ());
2026-09-22 17:25:52 +02:00
continue ;
}
// The visitor's side: can they get their answer recorded?
match questions . get ( & need . enters ) {
2026-10-03 10:48:09 +02:00
None => report ( Level ::Fail , "entry-missing" , need . enters . clone (), format! ( "entry page {:?} does not exist" , need . enters )),
2026-09-22 17:25:52 +02:00
Some ( page ) if ! visible ( page , need . enters_as . as_deref ()) => report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"entry-gated" ,
need . enters . clone (),
2026-09-22 17:25:52 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"entry-no-responsible" ,
need . enters . clone (),
2026-09-22 17:25:52 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"path-closed" ,
at . clone (),
2026-09-22 17:25:52 +02:00
format! (
"the path exists but is closed: {at} is `disabled`, so the answer is advertised and refused"
),
)
}
None => report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"path-missing" ,
String ::new (),
2026-09-22 17:25:52 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"path-over-budget" ,
String ::new (),
2026-09-22 17:25:52 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"path-no-action" ,
String ::new (),
2026-09-22 17:25:52 +02:00
"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 ,
2026-10-03 10:48:09 +02:00
"machine-missing" ,
need . lands_in . clone (),
2026-09-22 17:25:52 +02:00
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 < String > = desks
. iter ()
. map ( | d | format! ( " {} ( {} )" , d . page , d . group . as_deref (). unwrap_or ( "ungated" )))
. collect ();
report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"desk-missing" ,
need . handled_by . clone (),
2026-09-22 17:25:52 +02:00
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 ;
}
2026-10-02 11:25:49 +02:00
// 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 ));
2026-09-22 17:25:52 +02:00
let exits = | state : & str | -> Vec < String > {
2026-10-02 11:25:49 +02:00
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 ::< Vec < _ >> ()
. 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 ( ", " )
)
}
2026-09-22 17:25:52 +02:00
};
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
2026-10-02 11:25:49 +02:00
// only counts on the way to the first finished state here; what
// strands a record afterwards is the bucket's finding (below).
2026-09-22 17:25:52 +02:00
let mut reached : HashSet < String > = 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 ));
}
}
}
2026-10-02 11:25:49 +02:00
// 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! (
2026-10-03 10:48:09 +02:00
" - fix: no state a record reaches has a declared edge into it; add one in aggregates.yaml under {:?} " ,
2026-10-02 11:25:49 +02:00
need . lands_in
),
}
};
2026-09-22 17:25:52 +02:00
for state in & need . done_when {
if ! schema . has_state ( state ) {
report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"done-unknown" ,
state . clone (),
2026-09-22 17:25:52 +02:00
format! ( "done_when names {state:?} , which {:?} has no such state" , need . lands_in ),
);
} else if ! reached . contains ( state ) {
2026-10-02 11:25:49 +02:00
unreached_reported . insert (( need . lands_in . clone (), state . clone ()));
2026-09-22 17:25:52 +02:00
report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"done-unreachable" ,
state . clone (),
2026-09-22 17:25:52 +02:00
format! (
2026-10-02 11:25:49 +02:00
" {:?} cannot move a record to {state:?} - no desk of theirs offers the moves {} " ,
need . handled_by ,
missing ( state )
2026-09-22 17:25:52 +02:00
),
);
}
}
for stage in & need . stages {
if ! schema . has_state ( stage ) {
report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"stage-unknown" ,
stage . clone (),
2026-09-22 17:25:52 +02:00
format! (
"the business describes a stage {stage:?} that {:?} has no state for" ,
need . lands_in
),
);
} else if ! reached . contains ( stage ) {
2026-10-02 11:25:49 +02:00
unreached_reported . insert (( need . lands_in . clone (), stage . clone ()));
2026-09-22 17:25:52 +02:00
report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"stage-unreachable" ,
stage . clone (),
2026-09-22 17:25:52 +02:00
format! (
2026-10-02 11:25:49 +02:00
"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 ,
2026-10-03 10:48:09 +02:00
"stage-order" ,
format! ( " {before} > {after} " ),
2026-10-02 11:25:49 +02:00
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 )),
2026-10-03 10:48:09 +02:00
None => format! ( " - fix: add an edge from {before:?} toward {after:?} in aggregates.yaml under {:?} " , need . lands_in ),
2026-10-02 11:25:49 +02:00
}
2026-09-22 17:25:52 +02:00
),
);
}
}
for state in & need . success {
if ! need . done_when . contains ( state ) {
report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"success-not-done" ,
state . clone (),
2026-09-22 17:25:52 +02:00
format! ( "success names {state:?} , which is not one of done_when" ),
);
}
}
stranded . sort ();
2026-10-02 11:25:49 +02:00
for state in & stranded {
strands_reported . insert (( need . lands_in . clone (), state . clone ()));
}
2026-09-22 17:25:52 +02:00
for state in stranded {
2026-10-02 11:25:49 +02:00
let fix = match schema . allowed ( & state ). first () {
Some ( to ) => format! ( " - fix: {} " , fix_for ( & state , to )),
None => format! (
2026-10-03 10:48:09 +02:00
" - fix: {:?} declares no way out of it; add {state:?} to done_when if it is finished, or an edge out in aggregates.yaml" ,
2026-10-02 11:25:49 +02:00
need . lands_in
),
};
2026-09-22 17:25:52 +02:00
report (
Level ::Fail ,
2026-10-03 10:48:09 +02:00
"strand" ,
state . clone (),
2026-10-02 11:25:49 +02:00
format! ( "records strand in {state:?} : {movers} is offered no move out of it {fix} " ),
2026-09-22 17:25:52 +02:00
);
}
}
2026-10-02 11:25:49 +02:00
findings . extend ( operations ( file , questions , aggregates , & strands_reported , & unreached_reported ));
2026-09-22 17:25:52 +02:00
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 (),
2026-10-02 11:25:49 +02:00
scope : "need" ,
2026-10-03 10:48:09 +02:00
code : "persona-unknown-need" ,
subject : persona . id . clone (),
2026-09-22 17:25:52 +02:00
level : Level ::Fail ,
message : format ! ( "persona {:?} points at a need that is not declared" , persona . id ),
});
}
}
findings
}
2026-10-02 11:25:49 +02:00
/// 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 < String , AggregateSchema > ,
strands_reported : & HashSet < ( String , String ) > ,
unreached_reported : & HashSet < ( String , String ) > ,
) -> Vec < Finding > {
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 );
2026-10-03 10:48:09 +02:00
let mut report = | level : Level , code : & 'static str , subject : String , message : String | {
2026-10-02 11:25:49 +02:00
findings . push ( Finding {
need : bucket . to_string (),
scope : "bucket" ,
2026-10-03 10:48:09 +02:00
code ,
subject ,
2026-10-02 11:25:49 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"state-unreached" ,
( * state ). clone (),
2026-10-02 11:25:49 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"strand" ,
( * state ). clone (),
2026-10-02 11:25:49 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"edge-unoffered" ,
format! ( " {state} > {to} " ),
2026-10-02 11:25:49 +02:00
format! (
2026-10-03 10:48:09 +02:00
"the move {state:?} -> {to:?} is declared but no desk, link or clock offers it - fix: {} , or drop the edge from aggregates.yaml" ,
2026-10-02 11:25:49 +02:00
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 ,
2026-10-03 10:48:09 +02:00
"desk-gated-twice" ,
desk . page . clone (),
2026-10-02 11:25:49 +02:00
format! (
2026-10-03 10:48:09 +02:00
"the desk on {} is gated twice: the page qualifies {page_group:?} , its list requires {group:?} , so only members of both see the buttons - fix: make them the same group" ,
2026-10-02 11:25:49 +02:00
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 ) {
2026-10-03 10:48:09 +02:00
report ( Level ::Warn , "mail-unreachable" , state . clone (), format! ( "the mail on entering {state:?} never goes out: no record reaches {state:?} - fix: make {state:?} reachable, or drop its mail" ));
2026-10-02 11:25:49 +02:00
}
let mut unknown : Vec < String > = 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 ,
2026-10-03 10:48:09 +02:00
"mail-unknown-field" ,
state . clone (),
2026-10-02 11:25:49 +02:00
format! (
2026-10-03 10:48:09 +02:00
"the mail on entering {state:?} uses {} , which no form recording into {bucket:?} collects, so it would go out blank there - fix: use one of the fields the forms collect ( {} ), or ask for it on the form" ,
2026-10-02 11:25:49 +02:00
unknown . iter (). map ( | k | format! ( " {{ {k} }} " )). collect ::< Vec < _ >> (). 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 < String > {
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
}
2026-09-22 17:25:52 +02:00
/// 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 < String , AggregateSchema > ,
) -> 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 < serde_json ::Value > = path
. iter ()
. enumerate ()
. map ( | ( i , step ) | {
let page = & questions [ & step . page ];
let mut trail : Vec < String > = path [ ..= i ]. iter (). map ( | s | s . page . clone ()). collect ();
let expect : Vec < String > =
viable ( questions , need , page , need . max_steps - i , i == 0 , false , & mut trail )
. into_iter ()
. map ( | v | v . alternative )
. collect ();
let fields : Vec < serde_json ::Value > = 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 ,
2026-09-23 22:36:13 +02:00
"context" : page_context ( page ),
"options" : page . alternatives . iter (). filter ( | a | ! a . disabled && leads_somewhere ( a )). map ( | a | json! ({
2026-09-22 17:25:52 +02:00
"name" : a . name ,
"description" : plain ( & a . description ),
})). collect ::< Vec < _ >> (),
"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 ) {
2026-10-02 11:25:49 +02:00
// 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 < String > =
self_moves ( questions , & need . lands_in , & schema . initial ). into_iter (). map ( | ( _ , to ) | to ). collect ();
self_move_targets . dedup ();
2026-09-22 17:25:52 +02:00
buckets . entry ( need . lands_in . clone ()). or_insert_with ( || {
json! ({
"initial" : schema . initial ,
"transitions" : schema . transitions ,
2026-09-24 05:17:11 +02:00
"words" : need . stage_words ,
2026-09-22 17:25:52 +02:00
"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 ::< Vec < _ >> (),
})). collect ::< Vec < _ >> (),
2026-10-02 11:25:49 +02:00
"self_moves" : self_move_targets ,
2026-09-22 17:25:52 +02:00
})
});
}
}
json! ({
"needs" : needs ,
"buckets" : buckets ,
"personas" : file . personas . iter (). map ( | p | json! ({
"id" : p . id , "need" : p . need , "text" : plain ( & p . text ),
})). collect ::< Vec < _ >> (),
})
}
/// 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 < String > ,
}
#[derive(Debug, Serialize)]
pub struct ChoiceQuestion {
#[serde(rename = "type" )]
pub kind : & 'static str ,
pub instructions : String ,
pub criteria : BTreeMap < String , String > ,
}
fn plain ( text : & str ) -> String {
text . split_whitespace (). collect ::< Vec < _ >> (). join ( " " )
}
2026-09-23 22:36:13 +02:00
/// 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 ::< Vec < _ >> ()
. join ( " \n " )
}
2026-09-22 17:25:52 +02:00
/// 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 < Task > {
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 ];
2026-09-23 22:36:13 +02:00
let open : Vec <& Alternative > = page . alternatives . iter (). filter ( | a | ! a . disabled && leads_somewhere ( a )). collect ();
2026-09-22 17:25:52 +02:00
if open . len () < 2 {
continue ;
}
2026-09-23 22:36:13 +02:00
let context = page_context ( page );
2026-09-22 17:25:52 +02:00
let mut trail : Vec < String > = path [ ..= i ]. iter (). map ( | s | s . page . clone ()). collect ();
let expect : Vec < String > = 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 (),
2026-09-23 22:36:13 +02:00
state : if context . is_empty () {
BTreeMap ::from ([( "body" , plain ( & persona . text ))])
} else {
BTreeMap ::from ([( "body" , plain ( & persona . text )), ( "page" , context )])
},
2026-09-22 17:25:52 +02:00
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 < f64 > ,
}
#[derive(Debug, Default)]
pub struct Score {
pub total : usize ,
pub correct : usize ,
/// (task id, picked, expected, confidence)
pub misroutes : Vec < ( String , String , Vec < String > , Option < f64 > ) > ,
/// 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 < String > ,
}
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 ::* ;
2026-09-30 15:29:30 +02:00
use crate ::aggregates ::parse_aggregates_yaml ;
use crate ::content ::build_questions ;
2026-09-22 17:25:52 +02:00
fn site ( desk_moves : & str ) -> ( Questions , HashMap < String , AggregateSchema > ) {
let files = vec! [
(
"index.yaml" . to_string (),
"name: What are you here for? \n responsible: { name: A, contact: a@b.c } \n alternatives: \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 \n followup: true \n " . to_string ()),
(
"desk/index.yaml" . to_string (),
format! ( "name: Desk \n qualifies: owners \n alternatives: \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" ]);
2026-09-30 08:57:03 +02:00
// 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:?}" );
2026-09-22 17:25:52 +02:00
}
#[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! [
2026-10-02 11:25:49 +02:00
" \" 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" ,
2026-09-22 17:25:52 +02:00
]
);
}
#[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 \n qualifies: site_managers \n alternatives: \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@/" ]);
}
2026-10-02 11:25:49 +02:00
/// 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 < String , AggregateSchema > ) {
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 ),
2026-10-03 10:48:09 +02:00
vec! [ "the move \" open \" -> \" lost \" is declared but no desk, link or clock offers it - fix: add `{ from: open, to: lost, label: ... }` to the transitions on /desk, or drop the edge from aggregates.yaml" ]
2026-10-02 11:25:49 +02:00
);
}
#[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 ),
2026-10-03 10:48:09 +02:00
vec! [ "stage \" quoted \" comes after \" surveyed \" for the business, but no move leads on from \" surveyed \" to it - fix: add an edge from \" surveyed \" toward \" quoted \" in aggregates.yaml under \" projects \" " ]
2026-10-02 11:25:49 +02:00
);
}
#[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 ),
2026-10-03 10:48:09 +02:00
vec! [ "the mail on entering \" open \" uses {refnr}, which no form recording into \" projects \" collects, so it would go out blank there - fix: use one of the fields the forms collect (brief, budget, email), or ask for it on the form" ]
2026-10-02 11:25:49 +02:00
);
}
#[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! (
2026-10-03 10:48:09 +02:00
warns ( & findings ). contains ( & "the desk on /desk is gated twice: the page qualifies \" staff \" , its list requires \" owners \" , so only members of both see the buttons - fix: make them the same group" ),
2026-10-02 11:25:49 +02:00
"{findings:?}"
);
}
2026-10-03 10:48:09 +02:00
#[test]
fn a_finding_keeps_its_id_when_reworded_and_says_its_fix () {
let ( questions , aggregates ) = site ( " - { to: quoted, label: Quote } \n - { to: lost, label: Lose } \n " );
let findings = check ( & needs ( "" ), & questions , & aggregates );
let strand = findings . iter (). find ( | f | f . code == "strand" ). unwrap ();
assert_eq! ( strand . subject , "quoted" );
assert_eq! ( strand . fix (), Some ( "add `{ from: quoted, to: won, label: ... }` to the transitions on /desk" ));
let reworded = Finding { message : "something else entirely" . into (), .. clone_of ( strand ) };
assert_eq! ( reworded . id (), strand . id ());
let elsewhere = Finding { subject : "open" . into (), .. clone_of ( strand ) };
assert_ne! ( elsewhere . id (), strand . id ());
let json = strand . to_json ();
assert_eq! ( json [ "code" ], "strand" );
assert_eq! ( json [ "level" ], "fail" );
assert_eq! ( json [ "id" ], strand . id ());
}
fn clone_of ( f : & Finding ) -> Finding {
Finding { need : f . need . clone (), level : f . level , message : f . message . clone (), scope : f . scope , code : f . code , subject : f . subject . clone () }
}
2026-09-22 17:25:52 +02:00
}