From baf36319b7586e2c36594d6173eb5cd6fea57c8a Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 21 Sep 2026 21:46:12 +0200 Subject: [PATCH] Overlapping roles on one aggregate, relay routes, and a dry run plan.yaml: `moves_by` says who presses the button into a stage when it is not `handled_by` - other groups, several groups, or `submitter`, the person who sent the form, through their own link (the form must then collect a required email). `watched_by` puts a bucket on a group's desk with no buttons. Each group's desk carries its own buttons only; a submitter's moves become a followup page of self_transitions; needs.yaml gains `also_moved_by` for portal's verifier. simulate: a route is now a relay across every role that holds a legal button, the submitter included, and each step is judged as the role that presses it. `--dry` asks no model anything and walks every simple route of every aggregate, for needs_replay to put through the real engine. plans/contractor-full.yaml: 13 needs, 6 roles, 10 aggregates. Verifier green by construction; 40 routes, 78 transitions accepted by the real engine, none refused. What it exposed is in its run README. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH --- plans/contractor-full.yaml | 267 +++++++++++++++++++ runs/contractor-full-2026-09-21/README.md | 44 +++ runs/contractor-full-2026-09-21/routes.jsonl | 40 +++ src/build.rs | 173 +++++++++++- src/simulate.rs | 186 ++++++++++--- 5 files changed, 669 insertions(+), 41 deletions(-) create mode 100644 plans/contractor-full.yaml create mode 100644 runs/contractor-full-2026-09-21/README.md create mode 100644 runs/contractor-full-2026-09-21/routes.jsonl diff --git a/plans/contractor-full.yaml b/plans/contractor-full.yaml new file mode 100644 index 0000000..c2cd558 --- /dev/null +++ b/plans/contractor-full.yaml @@ -0,0 +1,267 @@ +# A contractor's whole operation, to stress the builder and the +# harness: 13 needs, 6 roles, 9 buckets, needs sharing a bucket, +# skippable stages, and a front door carrying more than a page should. +business: Nordvik Bygg +language: en +contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example } +about: > + Twelve people who survey old buildings, draw up rehabilitation plans, + handle the permit application, and do the building work with their + own carpenters plus subcontractors for electrical, plumbing and + scaffolding. Project managers win and run the jobs, site managers run + each building site day to day, and one safety officer owns everything + reported from site. + +needs: + # --- the public --------------------------------------------------- + - id: survey-enquiry + who: a housing board or private owner with a problem in a building + wants: someone qualified to come and look at it and say what it takes + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + # Whoever went to the building marks it surveyed; site managers + # see what is coming their way. + moves_by: { surveyed: [project_managers, site_managers] } + watched_by: [site_managers] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + - { name: access, optional: true } + + - id: build-enquiry + who: an owner who already knows what they want built or changed + wants: drawings, a permit application and a price + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + # Whoever went to the building marks it surveyed; site managers + # see what is coming their way. + moves_by: { surveyed: [project_managers, site_managers] } + watched_by: [site_managers] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: project, type: textarea } + - { name: timeframe, type: select, options: [This year, Next year, No fixed date] } + + - id: warranty-claim + who: a past customer who has found a fault in work we handed over + wants: us to come back, look at it, and put it right + lands_in: claims + handled_by: project_managers + stages: [open, inspected, accepted, fixed, rejected] + done_when: [fixed, rejected] + success: [fixed] + # The site manager inspects and puts it right; whether it is ours + # to fix is the project manager's call. + moves_by: { inspected: [site_managers], fixed: [site_managers] } + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: fault, type: textarea } + - { name: handed_over, type: date, optional: true } + + - id: neighbour-complaint + who: someone living or working next to one of our building sites + wants: the noise, dust or blocked access dealt with, and an answer + lands_in: complaints + handled_by: site_managers + stages: [open, answered] + done_when: [answered] + success: [answered] + watched_by: [project_managers] + collects: + - { name: site_address } + - { name: what_is_wrong, type: textarea } + - { name: reach_me_on } + + - id: subcontractor-application + who: a trade firm doing electrical, plumbing or scaffolding work + wants: to be approved once, and then be called when a job needs them + lands_in: subcontractors + handled_by: project_managers + stages: [open, awaiting_papers, approved, declined] + done_when: [approved, declined] + success: [approved] + watched_by: [site_managers] + skippable: [awaiting_papers] + collects: + - { name: company } + - { name: registration_number } + - { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] } + - { name: contact_email, type: email } + - { name: insurance_confirmed, type: select, options: ["Yes", Not yet] } + - { name: trade_approval, optional: true } + + - id: job-application + who: a carpenter, bricklayer or apprentice looking for work + wants: to be considered, without writing a letter + lands_in: applicants + handled_by: project_managers + stages: [open, interviewing, offered, hired, declined] + done_when: [hired, declined] + success: [hired] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: trade, type: select, options: [Carpenter, Bricklayer, Apprentice, Other] } + - { name: experience, type: textarea, optional: true } + + # --- clients with a job under way --------------------------------- + - id: change-request + who: a client whose job is under way and who wants something changed + wants: a price for the change, and the change done if they accept it + enters_as: clients + lands_in: change_orders + handled_by: project_managers + stages: [open, priced, approved, completed, declined] + done_when: [completed, declined] + success: [completed] + # The project manager prices it, the client says yes or no through + # their own link, the site manager does the work. + moves_by: { approved: [submitter], declined: [submitter, project_managers], completed: [site_managers] } + watched_by: [site_managers] + collects: + - { name: email, type: email } + - { name: job_address } + - { name: change, type: textarea } + - { name: needed_by, type: date, optional: true } + + # --- project managers --------------------------------------------- + - id: log-phone-enquiry + who: a project manager who just took a call or a text about a building + wants: the enquiry written down before it gets lost + enters_as: project_managers + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + # Whoever went to the building marks it surveyed; site managers + # see what is coming their way. + moves_by: { surveyed: [project_managers, site_managers] } + watched_by: [site_managers] + collects: + - { name: caller } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + + - id: post-job-request + who: a project manager with work to hand to a subcontractor + wants: approved subcontractors to see the job and answer with a price + enters_as: project_managers + lands_in: job_requests + handled_by: project_managers + stages: [open, awarded, withdrawn] + done_when: [awarded, withdrawn] + success: [awarded] + # This is how subcontractors see what there is to bid on. + watched_by: [subcontractors, site_managers] + collects: + - { name: site_address } + - { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] } + - { name: work, type: textarea } + - { name: start, type: date } + + # --- approved subcontractors -------------------------------------- + - id: submit-bid + who: an approved subcontractor who has seen a job they can take + wants: to say yes with a price and a start date + enters_as: subcontractors + lands_in: bids + handled_by: project_managers + stages: [open, shortlisted, accepted, declined] + done_when: [accepted, declined] + success: [accepted] + watched_by: [site_managers] + skippable: [shortlisted] + collects: + - { name: job } + - { name: price } + - { name: can_start, type: date } + + # --- on site -------------------------------------------------------- + - id: report-site-issue + who: anyone on site, ours or a subcontractor's, on a phone with gloves on + wants: to report a hazard, a near miss or wrong materials before they forget + enters_as: crew + lands_in: site_issues + handled_by: safety_officer + stages: [open, in_hand, closed] + done_when: [closed] + success: [closed] + # A site manager can take an issue in hand on the spot; only the + # safety officer closes it. + moves_by: { in_hand: [safety_officer, site_managers] } + watched_by: [project_managers] + collects: + - { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] } + - { name: where } + - { name: what_happened, type: textarea } + + - id: order-materials + who: a carpenter or bricklayer who is about to run out of something + wants: it ordered and on site before work stops + enters_as: crew + lands_in: material_orders + handled_by: site_managers + stages: [open, ordered, delivered, refused] + done_when: [delivered, refused] + success: [delivered] + # The crew confirms it arrived. + moves_by: { delivered: [crew, site_managers] } + collects: + - { name: site_address } + - { name: what, type: textarea } + - { name: needed_by, type: date } + + - id: safety-round-finding + who: the safety officer walking a site on the weekly round + wants: each thing found written down where the reported issues already are + enters_as: safety_officer + lands_in: site_issues + handled_by: safety_officer + stages: [open, in_hand, closed] + done_when: [closed] + success: [closed] + # A site manager can take an issue in hand on the spot; only the + # safety officer closes it. + moves_by: { in_hand: [safety_officer, site_managers] } + watched_by: [project_managers] + collects: + - { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] } + - { name: where } + - { name: what_happened, type: textarea } + +# No model that writes words ever sees these. +personas: + - { id: housing-board-damp, need: survey-enquiry, text: "I chair the board of a housing cooperative from the seventies. Two ground-floor flats have damp in the walls and we don't know if it's drainage or the facade. We need someone to tell us." } + - { id: buyer-before-purchase, need: survey-enquiry, text: "We are about to buy an old timber house. Before we sign we want a professional to go through it and tell us what is wrong with it." } + - { id: attic-conversion, need: build-enquiry, text: "We want to turn our attic into two bedrooms and a bathroom. We need drawings and someone who handles the application to the municipality." } + - { id: shop-to-flat, need: build-enquiry, text: "I own a former shop on the ground floor and want to make it a flat. I have no idea what is allowed. Can you sort that out and give me a price?" } + - { id: leaking-bathroom, need: warranty-claim, text: "You redid our bathroom two years ago. Now water is coming through the ceiling of the flat below. I want you to come and fix it." } + - { id: cracked-render, need: warranty-claim, text: "The render you put on our gable wall last summer has cracked right across. That can't be right after one winter." } + - { id: noisy-mornings, need: neighbour-complaint, text: "I live next door to the block you are working on. You start drilling at half past six every morning and I have a baby. Who do I talk to?" } + - { id: blocked-driveway, need: neighbour-complaint, text: "Your skip and a van have been parked across the entrance to our shop's loading bay for three days. We can't get deliveries." } + - { id: electrician-firm, need: subcontractor-application, text: "We are a four-person electrical firm, fully certified. We would like to get on your list so you call us when you need electricians." } + - { id: scaffolder, need: subcontractor-application, text: "I run a scaffolding company. How do I get to do jobs for you?" } + - { id: carpenter-jobseeker, need: job-application, text: "I'm a certified carpenter with eight years on rehabilitation jobs. Are you hiring?" } + - { id: apprentice, need: job-application, text: "I finish vocational school this spring and need an apprenticeship as a bricklayer." } + - { id: extra-window, need: change-request, text: "You are in the middle of our attic conversion. We have decided we want a roof window over the stairs as well. What would that cost?" } + - { id: pm-took-a-call, need: log-phone-enquiry, text: "A woman from a housing board in Storgata just rang about water in their basement. I'm in the van. I need to note it before I forget her number." } + - { id: pm-needs-plumber, need: post-job-request, text: "The bathroom job in Kirkeveien needs a plumber for two weeks from the first of next month. I want prices from the ones we have approved." } + - { id: plumber-bidding, need: submit-bid, text: "We are an approved plumbing firm. I saw you need someone in Kirkeveien next month. We can do it, I have a price." } + - { id: loose-railing, need: report-site-issue, text: "The railing on the third level of the scaffold on the north side is loose. Nobody got hurt but someone will." } + - { id: wrong-membrane, need: report-site-issue, text: "The bathroom membrane delivered today is not the one in the specification. We stopped work on it." } + - { id: out-of-battens, need: order-materials, text: "We have enough roof battens for today and maybe half of tomorrow. Need another two hundred metres on site by Thursday." } + - { id: weekly-round, need: safety-round-finding, text: "On my Friday round I found the first-aid box on level two empty and an extension lead run through a puddle." } diff --git a/runs/contractor-full-2026-09-21/README.md b/runs/contractor-full-2026-09-21/README.md new file mode 100644 index 0000000..1562115 --- /dev/null +++ b/runs/contractor-full-2026-09-21/README.md @@ -0,0 +1,44 @@ +# The whole operation: 13 needs, 6 roles, 10 aggregates, overlapping + +Plan: `plans/contractor-full.yaml`. Built with no model, walked dry +(every route of every aggregate), and replayed through portal's real +aggregate engine on a throwaway NATS. + +| | | +|---|---| +| Verifier | 13 of 13 needs met, 28 pages, by construction | +| Routes walked | 40, of which 6 are relays between roles | +| Real engine | 40 aggregates created, 78 transitions accepted, 0 refused, 118 events | + +Who holds buttons on what, as built: + +- `enquiries`: project managers (6), site managers (mark surveyed; otherwise watch) +- `claims`: site managers inspect and fix, project managers accept or reject +- `change_orders`: project managers price, **the client approves or + declines through their own link**, site managers complete +- `material_orders`: site managers order, the crew confirms delivery +- `site_issues`: site managers or the safety officer take in hand, only + the safety officer closes; project managers watch +- `job_requests`: project managers award; **subcontractors watch**, + which is how they see what there is to bid on + +What this plan exposed, none of it fixed yet: + +1. **The project managers' desk is one page with 9 aggregates and 32 + buttons.** The builder puts every aggregate a role touches on one + desk page. Past a handful it has to tier: a hub that lists the + aggregates with what is waiting in each, and a page per aggregate. +2. **The front door offers 6 alternatives**, one over the page-load + limit. It wants a first question ("a building of yours", "work with + us", "a site of ours next door") and a second page under each. +3. **Aggregates do not know about each other.** A bid names its job in + a free-text field; a change request names its job by address. + Nothing says "a bid belongs to a job request" or "a change order + exists only while an enquiry is won". That is the tiering between + state machines still to be designed: a need that arises when a + record elsewhere reaches a state, and carries its id. +4. **A shared button is walked by one role only.** Where two roles both + hold `open -> surveyed`, the dry run presses it as the first of them. +5. **Anyone in `clients` opens the change-request form**, and portal + has no per-record ownership beyond the submitter's link, so a + client group sees nothing of its own cases afterwards. diff --git a/runs/contractor-full-2026-09-21/routes.jsonl b/runs/contractor-full-2026-09-21/routes.jsonl new file mode 100644 index 0000000..dc0e404 --- /dev/null +++ b/runs/contractor-full-2026-09-21/routes.jsonl @@ -0,0 +1,40 @@ +{"persona":"route","need":"survey-enquiry","run":0,"target":"won","record":{"note":"dry run"},"states":["open","surveyed","quoted","won"],"pressed":["open -> Surveyed","surveyed -> Quoted","quoted -> Won"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"won"}} +{"persona":"route","need":"survey-enquiry","run":1,"target":"lost","record":{"note":"dry run"},"states":["open","surveyed","quoted","lost"],"pressed":["open -> Surveyed","surveyed -> Quoted","quoted -> Lost"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"survey-enquiry","run":2,"target":"lost","record":{"note":"dry run"},"states":["open","surveyed","lost"],"pressed":["open -> Surveyed","surveyed -> Lost"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"survey-enquiry","run":3,"target":"lost","record":{"note":"dry run"},"states":["open","lost"],"pressed":["open -> Lost"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"build-enquiry","run":0,"target":"won","record":{"note":"dry run"},"states":["open","surveyed","quoted","won"],"pressed":["open -> Surveyed","surveyed -> Quoted","quoted -> Won"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"won"}} +{"persona":"route","need":"build-enquiry","run":1,"target":"lost","record":{"note":"dry run"},"states":["open","surveyed","quoted","lost"],"pressed":["open -> Surveyed","surveyed -> Quoted","quoted -> Lost"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"build-enquiry","run":2,"target":"lost","record":{"note":"dry run"},"states":["open","surveyed","lost"],"pressed":["open -> Surveyed","surveyed -> Lost"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"build-enquiry","run":3,"target":"lost","record":{"note":"dry run"},"states":["open","lost"],"pressed":["open -> Lost"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"warranty-claim","run":0,"target":"rejected","record":{"note":"dry run"},"states":["open","rejected"],"pressed":["open -> Rejected"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"rejected"}} +{"persona":"route","need":"warranty-claim","run":1,"target":"rejected","record":{"note":"dry run"},"states":["open","inspected","accepted","rejected"],"pressed":["open -> Inspected","inspected -> Accepted","accepted -> Rejected"],"moved_by":["site_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"rejected"}} +{"persona":"route","need":"warranty-claim","run":2,"target":"fixed","record":{"note":"dry run"},"states":["open","inspected","accepted","fixed"],"pressed":["open -> Inspected","inspected -> Accepted","accepted -> Fixed"],"moved_by":["site_managers","project_managers","site_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"fixed"}} +{"persona":"route","need":"warranty-claim","run":3,"target":"rejected","record":{"note":"dry run"},"states":["open","inspected","rejected"],"pressed":["open -> Inspected","inspected -> Rejected"],"moved_by":["site_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"rejected"}} +{"persona":"route","need":"neighbour-complaint","run":0,"target":"answered","record":{"note":"dry run"},"states":["open","answered"],"pressed":["open -> Answered"],"moved_by":["site_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"answered"}} +{"persona":"route","need":"subcontractor-application","run":0,"target":"approved","record":{"note":"dry run"},"states":["open","awaiting_papers","approved"],"pressed":["open -> Awaiting papers","awaiting_papers -> Approved"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"approved"}} +{"persona":"route","need":"subcontractor-application","run":1,"target":"declined","record":{"note":"dry run"},"states":["open","awaiting_papers","declined"],"pressed":["open -> Awaiting papers","awaiting_papers -> Declined"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"subcontractor-application","run":2,"target":"approved","record":{"note":"dry run"},"states":["open","approved"],"pressed":["open -> Approved"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"approved"}} +{"persona":"route","need":"subcontractor-application","run":3,"target":"declined","record":{"note":"dry run"},"states":["open","declined"],"pressed":["open -> Declined"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"job-application","run":0,"target":"hired","record":{"note":"dry run"},"states":["open","interviewing","offered","hired"],"pressed":["open -> Interviewing","interviewing -> Offered","offered -> Hired"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"hired"}} +{"persona":"route","need":"job-application","run":1,"target":"declined","record":{"note":"dry run"},"states":["open","interviewing","offered","declined"],"pressed":["open -> Interviewing","interviewing -> Offered","offered -> Declined"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"job-application","run":2,"target":"declined","record":{"note":"dry run"},"states":["open","interviewing","declined"],"pressed":["open -> Interviewing","interviewing -> Declined"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"job-application","run":3,"target":"declined","record":{"note":"dry run"},"states":["open","declined"],"pressed":["open -> Declined"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"change-request","run":0,"target":"declined","record":{"note":"dry run"},"states":["open","priced","declined"],"pressed":["open -> Priced","priced -> Declined"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"change-request","run":1,"target":"declined","record":{"note":"dry run"},"states":["open","priced","approved","declined"],"pressed":["open -> Priced","priced -> their own link","approved -> Declined"],"moved_by":["project_managers","submitter","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"change-request","run":2,"target":"completed","record":{"note":"dry run"},"states":["open","priced","approved","completed"],"pressed":["open -> Priced","priced -> their own link","approved -> Completed"],"moved_by":["project_managers","submitter","site_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"completed"}} +{"persona":"route","need":"change-request","run":3,"target":"declined","record":{"note":"dry run"},"states":["open","declined"],"pressed":["open -> Declined"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"log-phone-enquiry","run":0,"target":"won","record":{"note":"dry run"},"states":["open","surveyed","quoted","won"],"pressed":["open -> Surveyed","surveyed -> Quoted","quoted -> Won"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"won"}} +{"persona":"route","need":"log-phone-enquiry","run":1,"target":"lost","record":{"note":"dry run"},"states":["open","surveyed","quoted","lost"],"pressed":["open -> Surveyed","surveyed -> Quoted","quoted -> Lost"],"moved_by":["project_managers","project_managers","project_managers"],"touches":3,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"log-phone-enquiry","run":2,"target":"lost","record":{"note":"dry run"},"states":["open","surveyed","lost"],"pressed":["open -> Surveyed","surveyed -> Lost"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"log-phone-enquiry","run":3,"target":"lost","record":{"note":"dry run"},"states":["open","lost"],"pressed":["open -> Lost"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"lost"}} +{"persona":"route","need":"post-job-request","run":0,"target":"awarded","record":{"note":"dry run"},"states":["open","awarded"],"pressed":["open -> Awarded"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"awarded"}} +{"persona":"route","need":"post-job-request","run":1,"target":"withdrawn","record":{"note":"dry run"},"states":["open","withdrawn"],"pressed":["open -> Withdrawn"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"withdrawn"}} +{"persona":"route","need":"submit-bid","run":0,"target":"accepted","record":{"note":"dry run"},"states":["open","shortlisted","accepted"],"pressed":["open -> Shortlisted","shortlisted -> Accepted"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"accepted"}} +{"persona":"route","need":"submit-bid","run":1,"target":"declined","record":{"note":"dry run"},"states":["open","shortlisted","declined"],"pressed":["open -> Shortlisted","shortlisted -> Declined"],"moved_by":["project_managers","project_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"submit-bid","run":2,"target":"accepted","record":{"note":"dry run"},"states":["open","accepted"],"pressed":["open -> Accepted"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"accepted"}} +{"persona":"route","need":"submit-bid","run":3,"target":"declined","record":{"note":"dry run"},"states":["open","declined"],"pressed":["open -> Declined"],"moved_by":["project_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"declined"}} +{"persona":"route","need":"report-site-issue","run":0,"target":"closed","record":{"note":"dry run"},"states":["open","in_hand","closed"],"pressed":["open -> In hand","in_hand -> Closed"],"moved_by":["safety_officer","safety_officer"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"closed"}} +{"persona":"route","need":"order-materials","run":0,"target":"delivered","record":{"note":"dry run"},"states":["open","ordered","delivered"],"pressed":["open -> Ordered","ordered -> Delivered"],"moved_by":["site_managers","crew"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"delivered"}} +{"persona":"route","need":"order-materials","run":1,"target":"refused","record":{"note":"dry run"},"states":["open","ordered","refused"],"pressed":["open -> Ordered","ordered -> Refused"],"moved_by":["site_managers","site_managers"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"refused"}} +{"persona":"route","need":"order-materials","run":2,"target":"refused","record":{"note":"dry run"},"states":["open","refused"],"pressed":["open -> Refused"],"moved_by":["site_managers"],"touches":1,"ask_backs":[],"outcome":{"outcome":"finished","detail":"refused"}} +{"persona":"route","need":"safety-round-finding","run":0,"target":"closed","record":{"note":"dry run"},"states":["open","in_hand","closed"],"pressed":["open -> In hand","in_hand -> Closed"],"moved_by":["safety_officer","safety_officer"],"touches":2,"ask_backs":[],"outcome":{"outcome":"finished","detail":"closed"}} diff --git a/src/build.rs b/src/build.rs index 0af89e1..1f140ce 100644 --- a/src/build.rs +++ b/src/build.rs @@ -70,6 +70,17 @@ pub struct PlanNeed { /// were complete). #[serde(default)] pub skippable: Vec, + /// Who presses the button that moves a case INTO a stage, where it + /// is not `handled_by`: another group, several groups, or + /// `submitter` - the person who sent the form, through their own + /// link (the form must then collect a required `email`). This is + /// how roles overlap on one aggregate: a project manager prices a + /// change, the client approves it, a site manager completes it. + #[serde(default)] + pub moves_by: BTreeMap>, + /// Groups that see these cases on their desk and move nothing. + #[serde(default)] + pub watched_by: Vec, pub collects: Vec, #[serde(default)] pub max_fields: Option, @@ -113,6 +124,22 @@ impl Plan { if need.stages.len() < 2 { bail!(at("stages needs a first stage and at least one finished one")); } + for (state, movers) in &need.moves_by { + if !need.stages[1..].contains(state) { + bail!(at(&format!("moves_by names {state:?}, which is not a stage a case can move into"))); + } + if movers.is_empty() || movers.iter().any(|m| !is_id(m)) { + bail!(at(&format!("moves_by {state:?} must list groups or `submitter`"))); + } + if movers.iter().any(|m| m == SUBMITTER) + && !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) + { + bail!(at("a move by the submitter is authorized by their email, so the form must collect a required `email` of type email")); + } + } + if need.watched_by.iter().any(|g| !is_id(g)) { + bail!(at("watched_by must list group identifiers")); + } for state in need.done_when.iter().chain(&need.skippable) { if !need.stages.contains(state) { bail!(at(&format!("{state:?} is not one of its stages"))); @@ -139,8 +166,14 @@ impl Plan { for (bucket, needs) in self.by_bucket() { let first = needs[0]; for other in &needs[1..] { - if other.stages != first.stages || other.done_when != first.done_when || other.skippable != first.skippable { - bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when and skippable", first.id, other.id); + if other.stages != first.stages + || other.done_when != first.done_when + || other.skippable != first.skippable + || other.moves_by != first.moves_by + || other.watched_by != first.watched_by + || other.handled_by != first.handled_by + { + bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable and who moves and watches it", first.id, other.id); } } } @@ -256,11 +289,12 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { let buckets: Map = plan .by_bucket() .into_iter() - .filter(|(_, needs)| needs.iter().any(|n| n.handled_by == group)) + .filter(|(_, needs)| on_desk(needs[0], &group)) .map(|(bucket, needs)| { - let moves: Map = machine(needs[0]) + // Only the buttons this group presses; a watcher gets none. + let moves: Map = moves_of(needs[0], &group) .into_iter() - .flat_map(|(from, tos)| tos.into_iter().map(move |to| (format!("{from}->{to}"), Value::String(humanize(&to))))) + .map(|(from, to)| (format!("{from}->{to}"), Value::String(humanize(&to)))) .collect(); (bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves })) }) @@ -276,11 +310,68 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }), ); } + // The submitter's own page: reached by their private link, it + // offers the moves that are theirs to make. + for need in &plan.needs { + let theirs = moves_of(need, SUBMITTER); + if theirs.is_empty() { + continue; + } + let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect(); + words.insert( + decide_path(need), + json!({ + "question": "What would you like to do?", + "description": "", + "moves": targets.iter().map(|to| ((*to).clone(), json!({ + "heading": humanize(to), "description": "", "button": humanize(to), + }))).collect::>(), + }), + ); + } words } +fn decide_path(need: &PlanNeed) -> String { + format!("questions/decide/{}.yaml", need.id) +} + +const SUBMITTER: &str = "submitter"; + +/// Who presses the button into `to`. +fn movers<'a>(need: &'a PlanNeed, to: &str) -> Vec<&'a str> { + match need.moves_by.get(to) { + Some(list) => list.iter().map(String::as_str).collect(), + None => vec![need.handled_by.as_str()], + } +} + +/// The (from, to) moves of a bucket that `who` may make. +fn moves_of(need: &PlanNeed, who: &str) -> Vec<(String, String)> { + machine(need) + .into_iter() + .flat_map(|(from, tos)| tos.into_iter().map(move |to| (from.clone(), to))) + .filter(|(_, to)| movers(need, to).contains(&who)) + .collect() +} + +/// A group's desk shows a bucket when it moves something in it, is +/// answerable for it, or watches it. +fn on_desk(need: &PlanNeed, group: &str) -> bool { + need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty() +} + fn groups(plan: &Plan) -> BTreeSet { - plan.needs.iter().flat_map(|n| [Some(n.handled_by.clone()), n.enters_as.clone()]).flatten().collect() + plan.needs + .iter() + .flat_map(|n| { + [Some(n.handled_by.clone()), n.enters_as.clone()] + .into_iter() + .flatten() + .chain(n.watched_by.iter().cloned()) + .chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned()) + }) + .collect() } /// Takes the model's strings where it gave them, key by key, and keeps @@ -422,7 +513,7 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result{to}")]) })).collect::>() + // This group's buttons only. The same bucket sits on + // other groups' desks with theirs; a watcher's list + // is empty, which portal renders read-only. + "transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| { + json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) }) }).collect::>(), }}], })); @@ -456,6 +550,31 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result = theirs.iter().map(|(_, to)| to).collect(); + files.insert( + path, + json!({ + "name": s(&w["question"]), + "description": s(&w["description"]), + "alternatives": targets.iter().map(|to| json!({ + "name": s(&w["moves"][to.as_str()]["heading"]), + "description": s(&w["moves"][to.as_str()]["description"]), + "self_transition": { "bucket": need.lands_in, "to": to, "label": s(&w["moves"][to.as_str()]["button"]) }, + })).collect::>(), + }), + ); + } + // The spec the verifier holds the site to. `via` is the heading the // words gave that need, filled in mechanically. files.insert( @@ -474,6 +593,8 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect(); + if !others.is_empty() { need["also_moved_by"] = json!(others); } need }).collect::>(), "personas": plan.personas, @@ -484,7 +605,9 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result String { - let purpose = if path == "questions/index.yaml" { + let purpose = if path.contains("/decide/") { + "a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide." + } else if path == "questions/index.yaml" { "the front door: the first page any visitor sees. `question` is the page's one question; under `needs`, each entry is one alternative the visitor can pick, with the form behind it." } else if path.ends_with("/index.yaml") { "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open." @@ -572,6 +695,7 @@ mod tests { id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, lands_in: "b".into(), handled_by: "g".into(), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), + moves_by: BTreeMap::new(), watched_by: vec![], collects: vec![], max_fields: None, } } @@ -634,6 +758,35 @@ mod tests { assert!(files["needs.yaml"].contains("enters: /crew/report")); } + #[test] + fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + + // The project manager prices and may decline; approving and completing are not theirs. + let pm = &files["questions/project_managers/index.yaml"]; + assert!(pm.contains("to: priced") && pm.contains("to: declined")); + assert!(!pm.contains("to: approved") && !pm.contains("to: completed")); + // The site manager completes, and nothing else. + let site = &files["questions/site_managers/index.yaml"]; + assert!(site.contains("to: completed") && !site.contains("to: priced")); + // Accounts see the bucket and move nothing. + let accounts = &files["questions/accounts/index.yaml"]; + assert!(accounts.contains("bucket: change_orders") && accounts.contains("transitions: []")); + // The client decides through their own link. + let decide = &files["questions/decide/change.yaml"]; + assert!(decide.contains("self_transition") && decide.contains("to: approved") && decide.contains("to: declined")); + // The spec tells the verifier whose desks count. + assert!(files["needs.yaml"].contains("also_moved_by:\n - site_managers")); + + // A submitter's move without an email to authorize it is refused. + let no_email = yaml.replace(" - { name: email, type: email }\n", ""); + let plan: Plan = serde_yaml::from_str(&no_email).unwrap(); + assert!(plan.validate().unwrap_err().to_string().contains("required `email`")); + } + #[test] fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() { let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap(); diff --git a/src/simulate.rs b/src/simulate.rs index cdec135..ffc7050 100644 --- a/src/simulate.rs +++ b/src/simulate.rs @@ -70,6 +70,8 @@ struct Case { states: Vec, /// "state -> button", in the order pressed. pressed: Vec, + /// Who pressed each of them: a group, or `submitter`. + moved_by: Vec, touches: usize, ask_backs: Vec, outcome: Outcome, @@ -115,6 +117,43 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { let mut cases_file = std::fs::File::create(out.join("cases.jsonl"))?; let mut scores: BTreeMap = BTreeMap::new(); + // A dry run asks no model anything: it walks every route of every + // aggregate, so each button of each role is pressed once. Its cases + // are what `needs_replay` puts through the real engine. + if argv.iter().any(|a| a == "--dry") { + for need in model["needs"].as_array().into_iter().flatten() { + let id = need["id"].as_str().unwrap_or_default(); + if only.as_deref().is_some_and(|o| o != id) { + continue; + } + let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()]; + let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + for (run, route) in all_routes(bucket, &done).into_iter().enumerate() { + let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()]; + states.extend(route.iter().map(|m| m.to.clone())); + let case = Case { + persona: "route".to_string(), + need: id.to_string(), + run, + target: states.last().cloned().unwrap_or_default(), + record: BTreeMap::from([("note".to_string(), json!("dry run"))]), + pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(), + moved_by: route.iter().map(|m| m.by.clone()).collect(), + touches: route.len(), + ask_backs: Vec::new(), + outcome: Outcome::Finished(states.last().cloned().unwrap_or_default()), + states, + }; + tally(scores.entry(id.to_string()).or_default(), &case); + writeln!(cases_file, "{}", serde_json::to_string(&case)?)?; + } + } + std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?; + let relays: usize = scores.values().map(|s| s.cases).sum(); + println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display()); + return Ok(()); + } + for persona in model["personas"].as_array().into_iter().flatten() { let need_id = persona["need"].as_str().unwrap_or_default(); if only.as_deref().is_some_and(|o| o != need_id) { @@ -165,6 +204,7 @@ fn play( record: BTreeMap::new(), states: Vec::new(), pressed: Vec::new(), + moved_by: Vec::new(), touches: 0, ask_backs: Vec::new(), outcome: Outcome::Stuck("never started".to_string()), @@ -237,17 +277,26 @@ fn play( // collected, could this role really do what the button claims? let bucket_name = need["lands_in"].as_str().unwrap_or_default(); let bucket = &model["buckets"][bucket_name]; - let group = need["handled_by"].as_str().unwrap_or_default(); let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); let initial = bucket["initial"].as_str().unwrap_or("open").to_string(); let target = done.get(run % done.len().max(1)).copied().unwrap_or_default().to_string(); case.target = target.clone(); case.states.push(initial.clone()); - let Some(route) = route_to(bucket, group, &initial, &target) else { - case.outcome = Outcome::Stuck(format!("no route of desk buttons from {initial} to {target}")); + let Some(route) = route_to(bucket, &initial, &target) else { + case.outcome = Outcome::Stuck(format!("no route of buttons from {initial} to {target}")); return Ok(case); }; - for (step, (from, label, to)) in route.iter().enumerate() { + for (step, mv) in route.iter().enumerate() { + let (from, label, to, group) = (&mv.from, &mv.label, &mv.to, &mv.by); + case.moved_by.push(group.clone()); + case.touches += 1; + case.pressed.push(format!("{from} -> {label}")); + case.states.push(to.clone()); + // The submitter's own decision is theirs to make; there is no + // role whose information could be short. + if group == SUBMITTER { + continue; + } let prompt = format!( "You work at a business as one of its {group}. A case in `{bucket_name}` is in state `{from}`. It came from {}, who wants {}.\n\nThis is everything you have about it - what the form collected, plus any follow-up:\n{}\n\nThe next thing that happens to this case is the desk button \"{label}\", which moves it to `{to}`. Pressing it claims that this has really been done.\n\nUsing only what is in front of you, could you really do that now? Answer false only when a fact that ONLY THE PERSON can give is missing and you could not proceed without it. Facts you find out yourself while doing the work do not count: you learn a building's condition by visiting it, a firm's registration by looking it up.\nReply as JSON: {{\"can_act\": true or false, \"need_to_know\": \"\" or null}}", need["who"].as_str().unwrap_or_default(), @@ -255,7 +304,6 @@ fn play( serde_json::to_string_pretty(&case.record)?, ); let reply = ask(handler_cmd, &prompt, &seed(100 + step))?; - case.touches += 1; let question = reply["need_to_know"].as_str().unwrap_or_default().trim().to_string(); if reply["can_act"] == false && !question.is_empty() { let answer = ask( @@ -275,42 +323,90 @@ fn play( }); case.touches += 1; } - case.pressed.push(format!("{from} -> {label}")); - case.states.push(to.clone()); } case.outcome = Outcome::Finished(target); Ok(case) } -/// The shortest chain of this group's desk buttons from one state to -/// another, as (from, button label, to). -fn route_to(bucket: &Value, group: &str, from: &str, to: &str) -> Option> { - let moves: Vec<(String, String, String)> = bucket["desks"] - .as_array() - .into_iter() - .flatten() - .filter(|d| d["group"] == group) - .flat_map(|d| d["moves"].as_array().cloned().unwrap_or_default()) - .filter_map(|m| Some((m["from"].as_str()?.to_string(), m["label"].as_str()?.to_string(), m["to"].as_str()?.to_string()))) - .filter(|(f, _, t)| bucket["transitions"][f].as_array().is_some_and(|a| a.iter().any(|x| x == t.as_str()))) - .collect(); +pub(crate) const SUBMITTER: &str = "submitter"; + +/// One button: who holds it, and where it takes a case. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Move { + pub from: String, + pub label: String, + pub to: String, + pub by: String, +} + +/// Every legal button on an aggregate, whoever holds it: each group's +/// desk, and the submitter's own link. Roles overlap on one aggregate, +/// so a route is a relay, not one desk's work. +pub(crate) fn all_moves(bucket: &Value) -> Vec { + let legal = |from: &str, to: &str| bucket["transitions"][from].as_array().is_some_and(|a| a.iter().any(|x| x == to)); + let mut moves: Vec = Vec::new(); + for desk in bucket["desks"].as_array().into_iter().flatten() { + for m in desk["moves"].as_array().into_iter().flatten() { + let (Some(from), Some(label), Some(to)) = (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) else { continue }; + if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) { + moves.push(Move { from: from.into(), label: label.into(), to: to.into(), by: desk["group"].as_str().unwrap_or("ungated").into() }); + } + } + } + let states: Vec = bucket["transitions"].as_object().map(|t| t.keys().cloned().collect()).unwrap_or_default(); + for to in bucket["self_moves"].as_array().into_iter().flatten().filter_map(Value::as_str) { + for from in &states { + if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) { + moves.push(Move { from: from.clone(), label: "their own link".into(), to: to.into(), by: SUBMITTER.into() }); + } + } + } + moves +} + +/// The shortest relay of buttons from one state to another. +fn route_to(bucket: &Value, from: &str, to: &str) -> Option> { + let moves = all_moves(bucket); let mut queue = std::collections::VecDeque::from([(from.to_string(), Vec::new())]); let mut seen = std::collections::HashSet::from([from.to_string()]); while let Some((state, path)) = queue.pop_front() { if state == to { return Some(path); } - for (f, label, t) in moves.iter().filter(|(f, _, _)| *f == state) { - if seen.insert(t.clone()) { + for mv in moves.iter().filter(|m| m.from == state) { + if seen.insert(mv.to.clone()) { let mut next = path.clone(); - next.push((f.clone(), label.clone(), t.clone())); - queue.push_back((t.clone(), next)); + next.push(mv.clone()); + queue.push_back((mv.to.clone(), next)); } } } None } +/// Every simple route from the first state to any finished one: what a +/// dry run walks, so that each button of each role is pressed at least +/// once against the real engine. +pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec> { + fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec, out: &mut Vec>) { + if done.contains(&state) { + out.push(path.clone()); + return; + } + for mv in moves.iter().filter(|m| m.from == state) { + if path.iter().any(|p| p.from == mv.to) { + continue; + } + path.push(mv.clone()); + walk(&mv.to, done, moves, path, out); + path.pop(); + } + } + let mut out = Vec::new(); + walk(bucket["initial"].as_str().unwrap_or("open"), done, &all_moves(bucket), &mut Vec::new(), &mut out); + out +} + /// Models often answer "Heading: its description" or change the case. /// The pick is the longest option heading the answer starts with; /// anything else is kept verbatim and counts as a misroute. @@ -512,19 +608,46 @@ mod tests { // Offered by the desk, refused by the machine. {"from": "open", "to": "won", "label": "Shortcut"} ]}, - {"group": "other", "moves": [{"from": "open", "to": "quoted", "label": "Theirs"}]} + {"group": "other", "moves": [{"from": "quoted", "to": "lost", "label": "Theirs"}]} ] }) } #[test] - fn routes_follow_this_groups_legal_buttons_only() { - let route = route_to(&bucket(), "pm", "open", "won").unwrap(); - let labels: Vec<&str> = route.iter().map(|(_, l, _)| l.as_str()).collect(); - assert_eq!(labels, ["Looked", "Quote sent", "Won"]); - assert_eq!(route_to(&bucket(), "pm", "open", "lost").unwrap().len(), 1); - assert!(route_to(&bucket(), "nobody", "open", "won").is_none()); - assert_eq!(route_to(&bucket(), "pm", "open", "open").unwrap().len(), 0); + fn a_route_is_a_relay_of_legal_buttons_across_roles() { + // "Shortcut" is offered by a desk and refused by the machine. + assert!(!all_moves(&bucket()).iter().any(|m| m.label == "Shortcut")); + // Losing a quoted job is the other group's button, so the way + // from open to that ending is a relay: two roles, one case. + let route = route_to(&bucket(), "surveyed", "lost").unwrap(); + let relay: Vec<(&str, &str)> = route.iter().map(|m| (m.label.as_str(), m.by.as_str())).collect(); + assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]); + assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0); + assert!(route_to(&bucket(), "won", "open").is_none()); + } + + #[test] + fn the_submitters_own_link_is_a_move_like_any_other() { + let mut b = bucket(); + b["self_moves"] = json!(["lost"]); + let theirs: Vec = all_moves(&b).into_iter().filter(|m| m.by == SUBMITTER).collect(); + // Only where the machine allows it and no desk already holds it: + // open->lost is the project manager's and quoted->lost the other + // group's, which leaves surveyed->lost to the link. + let froms: Vec<&str> = theirs.iter().map(|m| m.from.as_str()).collect(); + assert_eq!(froms, ["surveyed"]); + } + + #[test] + fn a_dry_run_walks_every_route_to_every_ending() { + let routes = all_routes(&bucket(), &["won", "lost"]); + let ends: Vec = routes.iter().map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)).collect(); + // open->lost; open->surveyed->quoted->won; and the relay + // open->surveyed->quoted->lost, closed by the other group. + assert_eq!(ends.len(), 3, "{ends:?}"); + assert_eq!(ends.iter().filter(|e| *e == "3:lost").count(), 1); + assert!(ends.contains(&"1:lost".to_string()) && ends.contains(&"3:won".to_string())); + assert!(routes.iter().any(|r| r.iter().any(|m| m.by == "other"))); } #[test] @@ -538,6 +661,7 @@ mod tests { record: BTreeMap::new(), states: vec![], pressed: vec!["open -> Looked".into(), "surveyed -> Quote sent".into()], + moved_by: vec!["pm".into(), "pm".into()], touches: 3, ask_backs: vec![ AskBack { state: "open".into(), button: "Looked".into(), question: "Which floor?".into(), answerable: true },