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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-21 21:46:12 +02:00
co-authored by Claude Fable 5.1
parent aef0fe1396
commit baf36319b7
5 changed files with 669 additions and 41 deletions
+267
View File
@@ -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." }
+44
View File
@@ -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.
@@ -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"}}
+163 -10
View File
@@ -70,6 +70,17 @@ pub struct PlanNeed {
/// were complete).
#[serde(default)]
pub skippable: Vec<String>,
/// 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<String, Vec<String>>,
/// Groups that see these cases on their desk and move nothing.
#[serde(default)]
pub watched_by: Vec<String>,
pub collects: Vec<Field>,
#[serde(default)]
pub max_fields: Option<usize>,
@@ -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<String, Value> {
let buckets: Map<String, Value> = 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<String, Value> = machine(needs[0])
// Only the buttons this group presses; a watcher gets none.
let moves: Map<String, Value> = 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<String, Value> {
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::<Map<_, _>>(),
}),
);
}
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<String> {
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<String, Value>) -> anyhow::Result<BT
let w = &words[&format!("questions/{group}/index.yaml")];
let mut alternatives = Vec::new();
for (bucket, needs) in plan.by_bucket() {
if !needs.iter().any(|n| n.handled_by == group) {
if !on_desk(needs[0], &group) {
continue;
}
let bw = &w["buckets"][bucket];
@@ -434,8 +525,11 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"features": [{ "name": "", "description": "", "resource": {
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
"transitions": machine(needs[0]).into_iter().flat_map(|(from, tos)| {
tos.into_iter().map(|to| json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })).collect::<Vec<_>>()
// 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::<Vec<_>>(),
}}],
}));
@@ -456,6 +550,31 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
);
}
// A submitter's own moves: a followup page (nested, not an index)
// whose alternatives are self_transitions, authorized at runtime by
// the record's link plus the email they gave.
for need in &plan.needs {
let theirs = moves_of(need, SUBMITTER);
if theirs.is_empty() {
continue;
}
let path = decide_path(need);
let w = &words[&path];
let targets: BTreeSet<&String> = 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::<Vec<_>>(),
}),
);
}
// 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<String, Value>) -> anyhow::Result<BT
"max_steps": 1, "max_fields": n.max_fields.unwrap_or(required),
});
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
let others: BTreeSet<&String> = 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::<Vec<_>>(),
"personas": plan.personas,
@@ -484,7 +605,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> 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();
+155 -31
View File
@@ -70,6 +70,8 @@ struct Case {
states: Vec<String>,
/// "state -> button", in the order pressed.
pressed: Vec<String>,
/// Who pressed each of them: a group, or `submitter`.
moved_by: Vec<String>,
touches: usize,
ask_backs: Vec<AskBack>,
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<String, NeedScore> = 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\": \"<the one question you would send them>\" 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<Vec<(String, String, String)>> {
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<Move> {
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<Move> = 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<String> = 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<Vec<Move>> {
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<Vec<Move>> {
fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec<Move>, out: &mut Vec<Vec<Move>>) {
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<Move> = 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<String> = 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 },