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:
co-authored by
Claude Fable 5.1
parent
aef0fe1396
commit
baf36319b7
@@ -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." }
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user