Puppet Strings: Technical Outline¶
Status: approved 2026-09-15, with the Puppet Master's answers folded in (see §10).
The Skedge language was redesigned on 2026-09-17. Where this outline shows request
syntax (TASK, FORBID, AVOID, ACROSS, PER … BEYOND), the
Skedge specification is now the definition and the reference
the readable version; the pipeline, sheets and solver structure below still hold.
This outline was written after reading the four existing sheets (Clinic_Data, Clinic_Schedule, Skills, Staff Categories). Where the sheets differ from the proposal, this document follows the sheets and says so. Every assumption is collected in §9; open questions are in §10.
1. Architecture¶
1.1 Overview¶
Puppet Strings is one Python package, puppet_strings, exposed two ways: a command line tool and a desktop window. Both call the same library functions. Google Sheets holds everything but the requests, which are one SQLite file on the Puppet Master's computer (requests.sqlite; see §2.7). Beside it in the config folder are credentials, a config file, a cache of Google Sheets tabs kept until Drive says they changed (sheets-cache.sqlite), and the Skedge parser's tables (parser.cache, rebuilt whenever the grammar changes).
Google Sheets Puppet Master's laptop
───────────────────────── ──────────────────────────────────────────────
Clinic_Data ─┐ ┌──────────────┐ ┌──────────────────────┐
Skills │ gspread (read) │ sheets/ │ │ app/ (PySide6 GUI) │
Staff Categories ├───────────────────►│ load_dataset │◄────┤ cli/ (puppet-strings)│
Offerings tab │ └──────┬───────┘ └──────────┬───────────┘
Blocks, Calendar │ │ Dataset │
Mappings ┘ ▼ │
Published (past) ─────────────────────► skedge/ parse+validate │
│ Declarations │
▼ │
solver/ (CP-SAT) │
│ Result │
Published (target) ◄──── gspread (write) ─ publish/ ◄──────────────────┘
- The solver runs locally, on whichever machine runs the tool. OR-Tools cannot run inside Google Apps Script, and a server would add hosting, secrets, and uptime to the Puppet Master's job. A solve for one day is expected to take seconds.
- Google Sheets is read when it changed. Each spreadsheet's Drive
versioncomes back with the folder listings a load already makes, and a tab read at that version is read off disk (sheets/cache.py). The requests are a local file, written on every save (requests_db.py). - One code path for data. Sheet readers return plain tables (
list[list[str]]). The same parsers turn tables from Google Sheets or from CSV files into domain objects, so tests and offline runs use CSV fixtures with no other changes.
1.2 Authentication¶
OAuth as the Puppet Master. Revised 2026-09-19; it was a service account until then.
- A one-time setup creates a Google Cloud project, enables the Sheets and Drive APIs, and downloads a Desktop app OAuth client JSON to
~/.config/puppet_strings/oauth_client.json. - The first run of the app opens a browser for consent and writes the token to
~/.config/puppet_strings/token.json, mode 600. Every run afterwards refreshes it silently; the Configure pane signs in again as a different account, or signs out. gspread.authorize(credentials)reads the sheets, and the same credentials authorize a plainAuthorizedSessionagainst Drive v3files.listfor browsing (puppet_strings/drive.py).
Scopes: spreadsheets, drive.metadata.readonly, userinfo.email, openid.
The service account is why this changed: it has no My Drive, nothing shared with it and no shared drives, so it could never back a Drive browser, and every new sheet had to be shared with an address nobody recognises. A service account key at [auth] credentials is still honoured when nobody is signed in, so an existing install keeps working.
1.3 Configuration¶
~/.config/puppet_strings/config.toml (path overridable with PUPPET_STRINGS_CONFIG):
[sheets]
clinic_data = "1bcCFIBqL77HbPiY1nOM2cqzZ0YC9fhRcdBi4TwZS0-E"
clinic_schedule = "1h_iOC7oqe43QFkpgoS-D-oFzP_r8G1EtDmrxvc83-o8" # Offerings tab only
skills = "1SAjIEMtNwdpDWcKkBp8wrEt9BjQJ6kaeHW00zJcBQPs"
staff_categories = "1Z92mJG-AbXKBX_jq5DKztNLVDXPUyL-licZWGvkVPXs"
config = "<new spreadsheet: Blocks, Calendar, Mappings>"
published = "<new spreadsheet: one tab per published date>"
# Chosen in the Configure pane and kept in settings.json instead, which wins over these.
[folders]
cabin_acts = "<folder: one cabin act sheet per session and week>"
[auth]
client_secrets = "~/.config/puppet_strings/oauth_client.json"
token = "~/.config/puppet_strings/token.json"
credentials = "~/.config/puppet_strings/service_account.json" # legacy fallback
[storage]
requests = "~/.config/puppet_strings/requests.sqlite"
cache = "~/.config/puppet_strings/sheets-cache.sqlite" # "" for no cache
[solver]
tier_seconds_limit = 15 # each pass, not the whole solve
workers = 8
random_seed = 0
1.4 Command line¶
| Command | Effect |
|---|---|
puppet-strings validate |
Parse and validate every request the target date loads against current names. Prints errors with request id, line, column. |
puppet-strings names |
Print every valid name per namespace. |
puppet-strings solve --date 2026-09-16 [--publish] |
Load data, solve, print the schedule and report. With --publish, write the day's own spreadsheet. |
puppet-strings export-fixtures DIR |
Download every sheet tab as CSV, and copy the requests to DIR/requests.sqlite. Used to refresh test fixtures and to work offline. |
puppet-strings export-requests FILE |
Copy the requests to a file for another Puppet Master. |
puppet-strings import-requests FILE |
Replace the requests with that file's, keeping the old ones in requests.before-import.sqlite. |
puppet-strings self-check |
Import every module the app can reach, including what only signing in to Google loads. The release build runs it on each packaged executable first. |
puppet-strings app |
Open the desktop request manager. |
Every command accepts --fixtures DIR to read CSVs instead of Google Sheets, and the requests in DIR/requests.sqlite instead of this computer's; --no-cache reads every sheet from Google.
1.5 Desktop application¶
PySide6 (Qt for Python). Reasons: mature, documented, native windows, and it ships the widgets this app needs without extra libraries: a table with sorting and filtering (QTableView + QSortFilterProxyModel), a plain-text editor with syntax highlighting (QPlainTextEdit + QSyntaxHighlighter), and a calendar (QCalendarWidget). The GUI is a thin layer: it calls skedge.validate, sheets.requests.save, and solver.solve, and holds no logic of its own. Replacing it with a web UI later would not touch the library.
Alternatives considered: Tkinter (no usable code editor widget), Textual (terminal only, less intuitive for a non-programmer), NiceGUI/Streamlit (browser-based, awkward for live validation while typing).
2. Data Schemas¶
Names are normalized as follows (this extends the proposal's rule because clinic names contain &, /, :, (, ) and '): trim, lowercase, replace every run of characters outside a–z 0–9 with one underscore, strip leading and trailing underscores, and prefix _ if the result starts with a digit.
| Sheet value | Identifier |
|---|---|
Mary Kate |
staff.mary_kate |
Cam VL |
staff.cam_vl |
Archery 1 & 2 |
activities.clinics.archery_1_2 |
Blacksmithing (DBL) |
activities.clinics.blacksmithing_dbl |
Crow's Nest (DBL) |
activities.clinics.crow_s_nest_dbl |
Village HERO |
staff.village_hero |
The validator rejects collisions within a namespace. The desktop app and puppet-strings names show the identifier next to each sheet value.
2.1 Clinic_Data (existing, read)¶
The spreadsheet has one tab per category (Arts, Outdoor, Rolling, …) and one combined tab with a Category column. The loader reads only the combined tab (the first tab whose header contains Category). The per-category tabs are for people. LG_Required moves to the combined tab as an optional column.
| Column | Type | Notes |
|---|---|---|
Clinic_Name |
text | Activity name. Trailing spaces are trimmed (Archery 3). |
Slots |
int | Camper slots. Displayed only; not used by the solver. |
Staff_Required or Staff_Requested |
int | Number of positions. Both header spellings exist and are accepted. |
RAL_Required |
digit string | One digit per position, in position order: 53 means 1st needs RAL 5, 2nd needs RAL 3; 543 covers three positions. Exactly Staff_Required digits; any other length is a load error naming the row (the current sheet has four such rows to fix). |
LG_Required |
int, optional | Lifeguards in addition to Staff_Required, each a position needing the LIFEGUARD skill at RAL 5. Blank means 0. |
Category |
text, optional | Present on the combined tab; otherwise the tab name is used. Becomes activities.clinics.<category> (activities.clinics.ropes, activities.clinics.arts). |
Built-in: activities.clinics = every row. Positions are roles.first, roles.second, roles.third for Staff_Required = 1, 2, 3, followed by roles.lifeguard, roles.lifeguard_2 for LG_Required = 1, 2.
2.2 Skills (existing, read)¶
Main tab. Three header rows and one row per staff member.
| Row | Content |
|---|---|
| 1 | Skill group (Ropes, Circus, …). Merged cells. Ignored by the loader. |
| 2 | Skill name (Canopy Tour, Ground Belay, Candle making). A blank cell marks a date column, which is skipped. |
| 3 | Rank (1st, 2nd, 1st CW, 3rd) or blank. |
Column A is the staff name, column B is RAL (+ date of incident) and column C is a count that the loader ignores. A skill's full name is "{row 2} {row 3}".strip(), which matches the sheet's own Skill_Name column on its third tab: Canopy Tour 1st, Ground Belay 1st CW, Leap of Faith, LIFEGUARD, Any.
The RAL cell is parsed as its leading integer, so 4 (6/12) reads as 4.
Cell values map to a status:
| Cell value | Status | Can fill a position | roles.trainee resolves to |
|---|---|---|---|
✓, WCF |
checked off | yes | scaffolded |
Trainer |
checked off, trainer for this skill, can supervise a scaffold | yes | scaffolded |
w/ scaf, w/scaf, Brief scaf |
needs scaffold | no | scaffolded |
w/ shadow |
needs shadow | no | shadow |
blank, Past Ex, Interested, . |
not checked off | no | shadow |
anything else (Yes, a date) |
unknown, treated as not checked off, listed in the load warnings | no | shadow |
The mapping is one table at the top of sheets/skills.py. A planned "competent facilitator" status, which also supervises scaffolds, is one added row there: the status carries a can_scaffold flag, and the scaffold constraint reads that flag rather than the word Trainer.
The staff roster (staff) is the set of rows on this tab.
Position skills tab (Clinic_Name | 1st | 2nd | 3rd). Maps each clinic position to the skill it requires. A blank cell, or the value Any, means the position needs no checkoff (every staff member holds Any). A clinic absent from this tab has no eligible staff and is reported as unstaffable when offered.
Derived tab (Skill | Rank | Skill_Name | staff…). Not read; the loader derives the same information from the main tab.
2.3 Staff Categories (existing, read)¶
One column per category. Row 1 is the category name; the cells below list members. Every member must be a Skills row. Each column becomes staff.<category> (staff.counselor, staff.director, staff.ropes_level_2, staff.village_hero). A column headed etc. or left blank is skipped.
Built-in: staff. Derived: staff.clinic_trainers = everyone with at least one Trainer cell on the Skills tab, unless a Staff Categories column of that name exists, which then wins.
2.4 Offerings (existing, in Clinic_Schedule, read)¶
Layout as it exists today:
| Row | Content |
|---|---|
| 1 | Weekday name, merged (THURSDAY). |
| 2 | Clinic 1 | | | Clinic 2 | slots | staff | Clinic 3 | … | Clinic 4 | … |
| 3… | Category headings in capitals (WEAPONS), clinic names, blank rows. Columns B/C, E/F, … are lookups and are ignored. |
| totals | A row of numbers. Ignored. |
Cancelled |
Merged row. Everything below it is ignored. |
The loader finds the columns whose row-2 header normalizes to a block id (Clinic 1 → blocks.clinic_1) and reads each non-blank cell that is a known clinic name. Cells that match a category name or are numeric are skipped; any other unknown text is a load error naming the cell.
The tab carries no date. The target date comes from --date (default: tomorrow). The loader warns if the weekday in row 1 does not match the target date.
A clinic whose name ends in (DBL) and that appears in two adjacent clinic blocks is one instance: the same staff hold both blocks. See §10.
2.5 Blocks (new, read)¶
One row per time block, as in the proposal.
| Column | Type | Example |
|---|---|---|
block_id |
identifier | clinic_1 |
start |
HH:MM | 09:15 |
end |
HH:MM | 10:30 |
day_types |
comma list | regular |
categories |
comma list | any, all_clinics |
display_group |
text, optional | break_then_work_projects |
blocks.any is added automatically. A block exists on a date only if the date's day type is in day_types. Overlap is computed from start/end.
2.6 Calendar (new, read)¶
One row per span: a run of days from start date to end date running one programme.
This is the "session calendar" the proposal names as the source of the dates namespace
but does not define.
| Column | Type | Example |
|---|---|---|
name |
text, unique | Session 1 |
start date |
ISO date | 2026-06-14 |
end date |
ISO date, on or after the start | 2026-06-27 |
program type |
main season or other |
main season |
Main season rows are numbered in sheet order, and that number is the name: session 3 is
dates.session_3 and its second week dates.session_3.week_2. Anything
else is dates.<name>. Weeks are not written down — a span's week is its days
seven at a time from the start — and nor are day types: a date is weekday or weekend by
the calendar and first_day or last_day at the ends of its span, which is what the
Blocks sheet's day_types and program_type columns are matched against.
See Dates; this table was rewritten on 2026-09-18 with the date redesign (§11) and again on 2026-09-19 to spans.
2.7 Requests (local file, read and written)¶
One SQLite file, requests.sqlite, with a requests table and a meta table holding the
format version. Each request has a scope — day, week, session or season — kept as the
dates it covers, and a load of a date reads every request whose scope covers it, through an
index on those dates. Export and import copy the whole file.
| Column | Type | Rules |
|---|---|---|
id |
text | The primary key: unique in the file, stable, kebab-case. |
scope |
day week session season |
|
first, last |
ISO dates | The days the scope covers, both included. The season is its calendar year. |
description |
text | |
skedge |
multi-line text | |
priority |
MUST_HAPPEN CLINIC HIGH MEDIUM LOW |
STABILITY is refused. |
weight |
real | Positive; always 1 with MUST_HAPPEN. |
tags, group, requester |
text | Tags comma-separated. |
created |
ISO date or null | Set by the app on creation. |
2.8 Mappings (new, read)¶
An index tab Mappings plus one data tab per mapping. (These were metrics until
2026-09-22, when a mapping that gives a name rather than a number was needed for buddy
HEROs; a metric is now a numeric mapping.)
Mappings:
| Column | Example | Example |
|---|---|---|
mapping |
enjoyment |
buddy |
keys |
staff, activities.clinics |
staff.counselor |
value |
numeric |
{staff - staff.counselor} |
scale_min |
1 |
|
scale_max |
5 |
|
default |
3 |
ANY 1 {staff - staff.counselor - staff.director} |
keys and value are Skedge sets (or a bare namespace), so what a call may take and
give is checked like any other name. mapping_enjoyment:
| key1 | key2 | value |
|---|---|---|
| Dylan | Archery 1 & 2 | 5 |
| Dylan | Candle Making | 3 |
Keys and values are sheet names, not identifiers, so the Puppet Master can paste from other sheets. Normalized score = (value − scale_min) / (scale_max − scale_min). A key with no row takes the default. Values outside the scale, and cells outside their sets, are load errors. The sheets page is the full reference.
2.9 The schedules tree (new, read and written)¶
Revised 2026-09-19; it was one spreadsheet of dated tabs until then. One Drive folder tree
under the schedules root chosen in the Configure pane: <year>/<Program Type>/<span
name>/, holding one Staff Categories spreadsheet and one spreadsheet per day named
<Weekday>_<week of the span>. Every part of the path is read off the Calendar sheet.
A day's spreadsheet holds Offerings (started from the Clinic Schedule template when the
day is made), Assignments, Staff View, Clinic View, Report, and Changes after a
same-day re-solve. Assignments is the canonical record and what the solver reads back as
fixed past assignments; an empty one means the day is set up but not solved.
| Column | Example |
|---|---|
staff |
Dylan |
activity |
Archery 1 & 2 or 'counselor hour' |
role |
first, shadow, scaffolded, or blank for ad hoc tasks |
block |
clinic_2 |
source |
offering, or the request id that required it |
Three further tabs are overwritten on every publish and hold the latest date only: Staff View, Clinic View, Report. Older views can be regenerated from the date tabs with puppet-strings solve --date … --publish only if the date is not yet published; a --views-only flag re-renders views from an existing tab without solving.
- Staff View: one row per staff member, one column per block (blocks sharing a
display_groupmerge into one column). A staff member with no assignment inblocks.playstationshowsAvailable. - Clinic View: one row per clinic somebody is on, one column per clinic block, position holders in order (1st above 2nd), trainees after them marked
(shadow)or(scaffolded). Coloured one colour per clinic block column and one per Clinic_Data category (publish/palette.py);Styledcarries the fills andSheetsSource.stylesends them in onebatch_format. - Report: one row per unsatisfied soft request (
id,priority,description) and, for an infeasible run, the conflictingMUST_HAPPENids.
Republishing a date overwrites its tab after a confirmation prompt.
3. Package Layout¶
puppet_strings/
├── __init__.py
├── config.py # config.toml loading, paths
├── names.py # normalize(), Namespace lookup, collision check
├── model.py # dataclasses: Staff, Activity, Position, Block, CalendarDay,
│ # Request, Assignment, MappingTable, Dataset
├── sheets/
│ ├── source.py # Table = list[list[str]]; SheetsSource (gspread), CsvSource
│ ├── clinic_data.py # one parse function per sheet, table -> model objects
│ ├── skills.py
│ ├── categories.py
│ ├── offerings.py
│ ├── blocks.py
│ ├── calendar.py
│ ├── requests.py # read and write
│ ├── mappings.py
│ ├── published.py # read past dates; write date tab and views
│ └── load.py # load_dataset(source, target_date) -> Dataset
├── skedge/
│ ├── grammar.lark # the grammar from the proposal
│ ├── ast.py # Declaration, Line, Clause, Verb, Selector nodes
│ ├── parser.py # text -> AST (Lark transformer)
│ ├── scope.py # clause scoping, defaults, EACH expansion
│ ├── resolve.py # selectors -> item sets and alternatives, using a Dataset
│ └── validate.py # every rule in proposal §8; SkedgeError(line, column, message)
├── solver/
│ ├── variables.py # eligible assignment tuples -> BoolVar; FREE literals
│ ├── structural.py # implicit constraints (proposal rules 1–8, plus §4.2 here)
│ ├── compile.py # one function per verb: TASK, FORBID, PREFER, AVOID, GAP
│ ├── tiers.py # lexicographic solve loop, integer scaling
│ ├── solve.py # solve(dataset) -> Result
│ └── result.py # Result, Schedule, UnsatisfiedRequest, Conflict
├── publish/
│ └── views.py # staff view and clinic view as tables
├── cli.py # argparse entry points
└── app/ # PySide6
├── main.py # window, menu, solve/publish actions
├── request_table.py # model + proxy filters (date, priority, staff, activity, scope)
├── editor.py # Skedge editor, highlighter, live validation
└── namespaces_panel.py # browsable tree of valid names
tests/
├── fixtures/ # CSV export of each tab, trimmed to ~15 staff and ~25 clinics
├── test_names.py
├── test_sheets_*.py # one per parser
├── test_parser.py # every proposal example parses
├── test_validate.py # every rule in proposal §8
├── test_solver_*.py # one per verb, plus test_scenarios.py for the required scenarios
└── test_views.py
docs/ # MkDocs Material
mkdocs.yml
pyproject.toml # package, dependencies, ruff, pytest config
.github/workflows/ci.yml # ruff + pytest on push and PR
.github/workflows/docs.yml # mkdocs gh-deploy on push to main
Dependencies: ortools, lark, gspread, PySide6. Development: pytest, ruff, mkdocs-material. Python 3.12.
4. Skedge to CP-SAT¶
4.1 Pipeline¶
- Parse each request's
.skedgewith Lark into an AST of lines and clauses. - Scope: attach clauses to verbs (same line, or verb-less line), apply defaults (
ON EACH dates.session,ACROSS staff), reject duplicates. - Expand
EACHinto independent declaration copies (Cartesian product when several clauses use it). Each copy gets its own satisfaction literal and reports underid[copy-key], for examplecounselor-hours[dylan]. - Resolve every selector against the
Datasetinto item sets; normalizeOR/ANDto alternatives; apply quantifiers. - Apply the time horizon (proposal §7): past dates become constants from Published Schedules, future dates are dropped, deferrable tasks are marked.
- Compile to CP-SAT constraints and tier objective terms.
- Solve tiers lexicographically; extract the schedule and report.
4.2 Variables and structural constraints¶
x[s, a, r, d, b]:BoolVarfor each staffs, activitya, roler, dated(target only), blockbwhere the Skills status allowssinrforaand RAL is met. Ad hoc tasks ('counselor hour') andFREEhaver = Noneand no eligibility filter. Trainee variables exist only for offered instances.free[s, d, b]: true iff noxforsoverlaps blockbond. Encoded asfree + sum(x overlapping b) == 1when blocks in the overlap set are pairwise exclusive (always true after the no-double-booking constraint).- No double booking: for each staff and block,
sum(x[s, *, *, d, b]) <= 1; for each pair of overlapping blocks,sum over both <= 1. - One holder per position, one instance per block:
sum_s x[s, a, p, d, b] <= 1for every positionp. (Implied by the proposal, stated here.) - Offered clinics are staffed: a standing
CLINICrequest,REQUEST EACH activities.clinics.offerings, resolves each offering(a, b)of the target date toREQUEST activities.clinics.a DURING blocks.b, one copy per offering. - Lifeguards are ordinary positions with skill
LIFEGUARDand RAL 5, so eligibility (rule 2) covers them. - Trainees:
x[s, a, shadow, d, b] → filled[a, d, b], wherefilledis the AND of the position-filled literals.x[s, a, scaffolded, d, b] → OR(x[t, a, p, d, b] for trainers t of p's skill). At most one trainee per instance. - Past dates: no variables. Lookups return Python
True/False; compile functions fold constants.
4.3 Verb compilation¶
Every request q gets sat[q]. MUST_HAPPEN: sat[q] is added with model.AddAssumption. Soft: + weight × 1000 × sat[q] in the request's tier.
Selectors → choice literals. For each clause of a TASK, the resolver yields items and alternatives. The compiler creates chosen[item] per item and, when OR/AND produced several alternatives, alt[i] per alternative with sum(alt) == sat and chosen[item] ⇔ OR(alt[i] for i containing item). Quantifiers on plain sets need no alternative enumeration:
| Quantifier | Constraint |
|---|---|
ANY |
sum(chosen) == sat |
ALL |
chosen[item] == sat for every item |
n OF |
sum(chosen) == n × sat |
TASK <target>. For every combination of staff s, date d, block b, role r across the four clauses: x[s, a, r, d, b] is enforced by [chosen_staff[s], chosen_date[d], chosen_block[b], chosen_role[r]]. For a positioned activity without ROLE: sum(x[s, a, p, d, b] for s in pool) == 1 per position p, enforced by [chosen_date[d], chosen_block[b], sat]. TASK FREE enforces free[s, d, b] instead of x.
FOR <duration> [CONTINUOUS]. y[b] per block in the union of DURING blocks; sum(minutes[b] × y[b]) >= remaining enforced by sat, where remaining = duration − minutes already published in the window. y[b] enforces the assignments. CONTINUOUS: every run of adjacent blocks long enough becomes an alternative literal; sum(run) == sat; a run enforces its blocks' y.
Deferrable TASK (window includes future dates): sat is free with +1 scaled unit (one thousandth of a weight-1 request) as incentive; on the window's last date it is enforced at its priority. With FOR, non-final dates instead bound sum(minutes × y) <= remaining and reward +1 unit per scheduled minute, so partial progress counts.
FORBID <target>. Every matched x gets x == 0 enforced by sat.
PREFER / AVOID. Each matched x contributes ± round(1000 × weight × score) × x to the tier, where score is 1 or the normalized metric value. PREFER FREE uses free literals.
AVOID … PER <fields> BEYOND n. Matched x are grouped by the listed fields (past published assignments in the window enter as constants). Per group: excess = IntVar(0, len(group)), excess >= fixed_count + sum(group) − n, contribution − round(1000 × weight) × excess.
GAP a b <cmp> duration. Both labels must be TASK verbs with a single-block DURING (no FOR, no ALL, no n OF; validation error otherwise). For every pair of blocks (b1, b2) where the gap from end(b1) to start(b2) fails the comparison, or b2 does not start after b1 ends: AddBoolOr([chosen_a[b1].Not(), chosen_b[b2].Not()]).
4.4 Tiers¶
Tier expressions are integer linear sums. Solve order: CLINIC, HIGH, MEDIUM, LOW. After each tier: read the objective value v, add tier_expr >= v, clear the objective, set the next. MUST_HAPPEN requests never appear in an objective. Each pass gets its own clock (see §11): the feasibility check and every tier are given tier_seconds_limit, and the placement pass tidy_seconds. If a tier ends at the limit without proving optimality, its best found value becomes the bound and the report says so, with the gap it could not close. num_workers and random_seed are fixed for reproducible output.
On INFEASIBLE, solver.SufficientAssumptionsForInfeasibility() returns the assumption indices; the report lists their request ids. An empty set means the structural constraints alone conflict (which the report says).
4.5 Worked example: counselor hours¶
Request counselor-hours, MUST_HAPPEN:
ACROSS EACH staff.counselor
TASK 'counselor hour' DURING {blocks.clinic_1 OR blocks.clinic_2} AS morning
TASK 'counselor hour' DURING {blocks.clinic_3 OR blocks.clinic_4} AS afternoon
GAP morning afternoon <= 5h
Blocks (from the Blocks sheet): clinic_1 09:15–10:30, clinic_2 10:45–12:00, clinic_3 14:00–15:15, clinic_4 15:45–17:00. Target date 2026-09-16. Counselors: Dylan, James, Paul.
Step 3 expands EACH into three copies. For Dylan's copy (counselor-hours[dylan]), with ON defaulted to EACH dates.session and then narrowed by the time horizon to the target date:
sat = model.NewBoolVar("sat:counselor-hours[dylan]")
model.AddAssumption(sat) # MUST_HAPPEN
# morning: {clinic_1 OR clinic_2} -> two singleton alternatives, so chosen == alt
m1 = model.NewBoolVar("morning:clinic_1")
m2 = model.NewBoolVar("morning:clinic_2")
model.Add(m1 + m2 == sat) # ANY
model.AddImplication(m1, x["dylan", "counselor hour", None, d, "clinic_1"])
model.AddImplication(m2, x["dylan", "counselor hour", None, d, "clinic_2"])
# afternoon: same shape over clinic_3, clinic_4
a3 = model.NewBoolVar("afternoon:clinic_3")
a4 = model.NewBoolVar("afternoon:clinic_4")
model.Add(a3 + a4 == sat)
model.AddImplication(a3, x["dylan", "counselor hour", None, d, "clinic_3"])
model.AddImplication(a4, x["dylan", "counselor hour", None, d, "clinic_4"])
# GAP morning afternoon <= 5h: end(morning) -> start(afternoon)
# clinic_1 -> clinic_3: 10:30 -> 14:00 = 3h30 ok
# clinic_1 -> clinic_4: 10:30 -> 15:45 = 5h15 violates
# clinic_2 -> clinic_3: 12:00 -> 14:00 = 2h00 ok
# clinic_2 -> clinic_4: 12:00 -> 15:45 = 3h45 ok
model.AddBoolOr([m1.Not(), a4.Not()])
The x variables for 'counselor hour' share the no-double-booking constraints with clinic assignments, so choosing m1 keeps Dylan off every clinic in clinic_1. If Dylan is also pinned to Archery in both clinic_1 and clinic_2 by a MUST_HAPPEN request, the model is infeasible and the report names counselor-hours[dylan] and the pin's id.
4.6 Worked example: scoring¶
clinic-enjoyment (MEDIUM, weight 1, ~ metrics.enjoyment on a 1–5 scale) and clinic-variety (MEDIUM, weight 0.5, PER staff activity BEYOND 1 over the rolling week). Dylan ran archery yesterday (a constant from Published Schedules).
| Term | Coefficient (scaled by 1000) |
|---|---|
x[dylan, archery, first, today, clinic_2] enjoyment 5 → normalized 1.0 |
+1000 |
x[dylan, candle_making, first, today, clinic_2] enjoyment 3 → 0.5 |
+500 |
excess[dylan, archery] with excess >= 1 + x[archery] − 1 |
−500 |
Archery: 1000 − 500 = 500. Candle making: 500. A tie, as in the proposal's table.
5. Milestones¶
Each milestone ends with passing ruff check, ruff format --check, and pytest.
| # | Milestone | Deliverable | Testable outcome |
|---|---|---|---|
| 0 | Scaffold | pyproject.toml, package skeleton, CI workflow, export-fixtures command, trimmed CSV fixtures committed. |
CI green on an empty test suite. Fixtures load as tables. |
| 1 | Data layer | model.py, names.py, every sheets/* parser, load_dataset. |
Each parser has a test against its fixture. RAL digit strings, Skills statuses, (DBL) merging, identifier collisions, and unknown Offerings cells are covered. puppet-strings names --fixtures prints every namespace. |
| 2 | Skedge | grammar, parser, scope, resolve, validate. | Every proposal example parses to the expected AST and resolves to the expected alternatives. Every rule in proposal §8 has a failing-input test with line and column asserted. puppet-strings validate --fixtures works. |
| 3 | Solver core | variables, structural constraints, TASK (with FREE, ROLE, positions), FORBID, tiers, assumptions, report. |
Scenarios: infeasible MUST_HAPPEN pair reports both ids; unstaffable offered clinic is reported and the rest is scheduled; trainee added without filling a position; scaffold only with a trainer; lifeguard minimum. puppet-strings solve --fixtures prints a schedule. |
| 4 | Objectives and time | PREFER, AVOID, metrics, PER … BEYOND, GAP, FOR/CONTINUOUS, past constants, deferral. |
Scenarios: tradeoff outcomes at weights 0.25, 0.5, 1; GAP rejects the 5h15 pair; deferrable task optional then enforced; past assignments count toward BEYOND; FOR EXACTLY 2h sums across non-adjacent blocks; CONTINUOUS does not. |
| 5 | Sheets integration | SheetsSource, Requests read/write, Published read/write, views, --publish. |
View rendering tested on fixtures (display groups merge, Available, position order, trainee labels). Manual test against a copy of the real sheets; a written checklist for it is part of the docs. |
| 6 | Desktop app | request table with filters, editor with live validation, names panel, solve and publish buttons. | Manual checklist. Unit tests for the filter proxy and the highlighter's token rules. |
| 7 | Docs and README | MkDocs Material site: install, setup (Google Cloud steps), user guide, Skedge reference with every proposal example, this outline, developer guide. README with overview and install steps. | mkdocs build --strict passes in CI; site deploys to GitHub Pages on push to main. |
Milestones 3 and 4 are where the proposal's required scenario tests live; both are testable entirely offline.
6. Testing Approach¶
- Fixtures are real exports, trimmed by hand to a small staff and clinic set that still exercises every case (multi-position ropes clinics, a water clinic with
LG_Required, a trainer, aw/ shadowand aw/ scafstaff member, a(DBL)clinic, an unstaffable clinic). - Solver tests build a
Datasetin Python from small literal tables rather than from the full fixtures, so each test states the exact situation it checks. - Parser tests compare ASTs with dataclass equality. Validation tests assert
(line, column)of the error. - The scoring tests read the objective value back from CP-SAT and compare against the hand-computed table in §4.6.
7. Documentation Plan¶
MkDocs Material with the following navigation:
- Home: what Puppet Strings does, one screenshot, one diagram.
- Install: Python,
pipx install, config file, Google Cloud setup with screenshots. - Sheets: every schema from §2, with what each column means for the Puppet Master.
- Requests app: creating, editing, filtering, validating, solving, publishing.
- Skedge reference: language rules, every construct, every example from the proposal with its priority and weight, the validation error list.
- How the solver decides: priorities, weights, the worked tradeoff.
- Design: this outline, kept current.
- Development: layout, running tests, refreshing fixtures, releasing.
Deployment: docs.yml runs mkdocs gh-deploy --force on push to main.
8. Code Conventions¶
Per the proposal: PEP 8 via ruff, docstrings on public functions, return-early, no duplication. Additions:
- Dataclasses with
frozen=Truefor model objects. - Parsers are pure functions
parse_<sheet>(table) -> objects; they never call gspread. - Errors that a Puppet Master must act on (
LoadError,SkedgeError) carry sheet, cell or line/column, and a plain-language message. Internal errors are plain exceptions. - No global state. The
Datasetis passed explicitly.
9. Assumptions¶
Defaults from "Unresolved Decisions":
- Scaffolder: the supervising trainer is one of the staff filling a required position, not an extra person.
- Training instances:
roles.traineeattaches only to an offered clinic instance; the solver never creates instances for training. - Counselors' third break: not modeled. Counselor hours give two breaks; no third break is generated. The Puppet Master can add a
TASK 'break'request for counselors later without code changes. GAPmeasurement: end of the first task to start of the second.- Variety across sessions: repetition windows ignore dates outside the active session.
Assumptions added by this outline:
- Name normalization replaces every non-alphanumeric run with one underscore (§2). The proposal's rule covered spaces only.
- RAL_Required is a digit string with exactly one digit per position. Confirmed; rows with the wrong length are load errors.
- Skills statuses map as in §2.2.
WCFis a checkoff,Brief scafneeds a scaffold. Unknown values are treated as not checked off and reported. Confirmed. - Trainers are per skill: a scaffold requires a position holder whose status for that position's skill can scaffold (
Trainertoday).staff.clinic_trainersis derived from the Skills tab. Confirmed. roles.traineeresolution uses the staff member's status for the clinic's first position skill.- A blank or
Anyposition skill means no checkoff required. LG_Requiredcounts extra lifeguard positions beyondStaff_Required, each requiringLIFEGUARDat RAL 5. Confirmed by the Puppet Master on 2026-09-16.- Trainee capacity is one per instance; Clinic_Data has no override column, so none is read.
(DBL)clinics span two adjacent clinic blocks: one instance, same staff in both blocks. Confirmed.- Offerings date is the
--dateargument; the tab's weekday header is only checked, not parsed into a date. - The staff roster is the Skills main tab. Staff Categories members must appear there.
- Category names are used as-is:
staff.counselor,activities.clinics.ropes. The proposal'sstaff.counselorsandactivities.clinics.any_ropesbecome whatever the sheets say; documentation examples will use the real names. - Activities are clinics only. Clinic_Data lists no playstations or evening programs.
blocks.playstationis just a block. - New sheets: Blocks, Calendar, Requests, Metrics live in one new config spreadsheet; Published Schedules is a second new spreadsheet.
Calendaris an addition beyond the proposal. - Metric misses score the metric's
defaultcolumn, orscale_minwhen that column is blank, which is the original score-nothing behavior. Values and defaults outside the declared scale are load errors. - Deferral incentive is one scaled unit (0.001 of a weight-1 request) per satisfied deferrable task, or per minute for
FORtasks. GAPlabels must be single-blockTASKs on the same date.EACHcopies each count as one satisfied request in their tier, and report asid[item].- Ad hoc task names are shared across requests: two requests using
'break'refer to the same activity, so double-booking applies between them. - Authentication was a service account only. Superseded 2026-09-19: OAuth as the Puppet Master, because a service account cannot browse Drive and the Configure pane needs to.
- The desktop app is PySide6. Confirmed.
- Views are overwritten per publish; per-date tabs are the durable record.
- Solver determinism: fixed seed and worker count; each pass's time limit defaults to 15 seconds.
10. Questions for the Puppet Master¶
Answered 2026-09-15. Kept for the record.
(DBL): a clinic spanning two adjacent clinic blocks. Confirmed.- Skills vocabulary:
WCF= "with competent facilitator", treated as a checkoff for now;Brief scaf=w/ scaf. A "competent facilitator" status that can also scaffold is planned. - Trainers: scaffolding requires a
Trainermark on the specific skill. Confirmed. - RAL digit strings:
53means 1st needs 5 and 2nd needs 3. A single digit on a two-position clinic is a data error. - Lifeguards: answered 2026-09-16. A lifeguard is an additional person: one facilitator plus one lifeguard is
Staff_Required1,LG_Required1. All lifeguards need RAL 5. - Zero-slot rows (
Craft Fairy,Lvl. 2 on Ground,Battle Royale Setup/Breakdown) are staffed like any clinic. Confirmed. - Hand-written
CLINICpriority: unanswered; default kept (reserved for generated offerings). - Scope: derived from the
ONclause. Confirmed. - GUI toolkit: PySide6. Confirmed.
- Credentials: service account. Superseded 2026-09-19 by signing in as the Puppet Master; see 1.2.
Standing direction from the Puppet Master: the Google Sheets formats are not fixed. When the current layout would force convoluted parsing, change the sheet instead, and document the change. The code must be clear enough for the Puppet Master to update without an LLM.
11. Changes Made During the Build¶
Recorded so the outline matches the code.
- Tidiness pass. After the last tier, the solver minimizes the number of assignments so that nothing is scheduled that no request asked for (otherwise ad hoc tasks such as counselor hours could appear in extra blocks at no cost).
PREFER/AVOIDnever score their ownsat. OnlyTASKandFORBIDcopies addweight × satto a tier; scoring verbs add their matched assignments only.- Deferral incentive is one scaled unit per satisfied deferrable copy, or per block
scheduled for a deferrable
FORtask, in the request's own tier (CLINICfor hard requests). (DBL)assignments count once inPREFER,AVOIDandPER … BEYOND, both for today's variables and for published rows.- The default
ON EACH dates.sessionis shared by every verb in a declaration, so multi-verb requests expand to one copy per date rather than one per date pair. EACHcopy keys are the chosen items in source order, dates omitted:counselor-hours[dylan].GAPcompares block times only; both tasks are assumed to be on the same date.- Instances for hand-written
REQUEST activities.clinics.xare created for the target date if no offering exists in that block, so a request can run an unlisted clinic. - Filter verbs (
FORBID, plainPREFER/AVOID) look at the target date only.PER … BEYONDwindows include published past dates. - Ad hoc task names are shared across requests: two requests using
'break'refer to the same activity. - Request scope is gone (Puppet Master, 2026-09-18). The app used to label each
request season / session / week / day / pin, derived from the dates it resolved to. It
was never defined anywhere the Puppet Master could read, and a request naming a date the
Calendar sheet does not have resolved to no dates at all and so was labelled
season— the widest possible reach for a request that reaches nothing. The dates themselves are still a facet (app/facets.py), and the date filter asks the same question honestly. - Tab names are configurable under
[tabs]inconfig.toml, with defaults inconfig.py. - Lifeguards are positions, not a count over facilitators:
LG_Requiredaddsroles.lifeguard(androles.lifeguard_2) positions requiringLIFEGUARDat RAL 5. - Recurring dates and staff pairing (Puppet Master, 2026-09-16), both inside the
existing grammar. A weekday name now holds every such date of the session rather than
only the target's week, so
ON EACH dates.mondayis a weekly request; ordinal names (dates.second_thursday,dates.last_friday) were added to thedatesnamespace and existed only when the session reaches them. ForFORBID,PREFERandAVOID, anACROSSalternative holding several staff ({staff.a AND staff.b}) matches them as a group: one match per instance, whose literal is the AND of each member's presence there, reified withAddMinEquality.ANDelsewhere on those verbs is a validation error, and a staff-keyed metric cannot score a group.POSITION_ROLESis now an alias ofORDINALS. EACHis allowed onFORBID,PREFERandAVOID(Puppet Master, 2026-09-16); onlyALLandn OFare refused, since those choose rather than filter.EACHis not redundant there: it splits the declaration, soPERcounts per person rather than over the pool.STABILITYis refused on the Requests sheet and left out of the app's dropdowns, viaWRITABLE_PRIORITIES.- A quoted task happens only where a TASK selected it (Puppet Master, 2026-09-16).
Compilerrecords, per ad hoc slot, the conjunction of clause literals that selects it, andclose_adhoc_tasksthen addsx -> OR(selectors), or pinsxto zero when nothing selects it. Without itDURINGon aTASKwas a floor rather than a total, so aPREFERcovering more blocks than the task needed bought extra occurrences andPREFERover a set stopped matchingAVOIDover its complement. Clinics keep the old rule, because two offerings of one clinic in different blocks must coexist. A filter verb naming a quoted task that noTASKasks for is now aRequestError, since it would otherwise be a silent no-op. (Since 2026-09-25 that request, like any that does not validate, is left out of the solve with a note in the report instead.) - Same-day changes (Puppet Master, 2026-09-16). Rests and RAL penalties are per-date
data on a new optional
Adjustmentstab, applied toStaffat load time asresting_blocksand a reducedral;restingisall day,morningorafternoon, with a block belonging to the half it starts in, split atconfig.midday. Someone resting all day also drops out of every staff category, so mandatory requests overstaffstop demanding anything of them, andadd_structural_constraintspins a resting block's variables to zero in case a request names them directly.load_datasetreads the target's own published tab asDataset.baseline.solve(..., same_day=True)rewards each kept baseline assignment in a newSTABILITYtier betweenCLINICandHIGH, hints those variables, and returns aChangeper staff member and block that differs. - One name listing (
resolve.name_listing) feeds thenamescommand, the app's names panel and the editor's completer, built from the same table the resolver validates against so the three cannot drift apart. The completer is aQCompleteron the Skedge box, opened by anamespace.prefix. Its model is created once and refilled, because a replaced Qt object can be collected inside the solver thread and destroyed off the main thread, which crashes the process. - No pass can lose a working schedule.
solve_tierssnapshots the solution after every successful pass (ResponseProto().solution, indexed by variable) and returns aTierOutcome.value(var)lookup instead of the live solver. A tier or cosmetic pass that returns anything but OPTIMAL or FEASIBLE keeps the previous snapshot, bounds its tier at the score that snapshot achieves, and adds a note. The cosmetic pass also hints the assignment booleans from the snapshot so it starts from the known schedule. Only the first pass can fail the solve, and it namestier_seconds_limit. The cosmetic pass gets its own short budget,tidy_seconds(default 2), because the hint makes the better schedule appear at once and the rest of the time goes on proving optimality. There was once a second cosmetic pass, dropping assignments nobody asked for; that is now a constraint built into the model, so it holds however long the solve takes. - Requests have tags (comma-separated column). Offerings are no longer generated
inside the solver: Load offerings (app button or
load-offeringscommand) writes oneCLINICrequest per offering to the Requests sheet, taggedgenerated, with idsoffering:<date>:<activity>:<block>. The request names every position of the clinic,AS_ROLE EACH {roles.first + roles.second + …}(facilitators then lifeguards), so the positions the clinic wants are written down rather than implied by the structural constraints alone.EACHgives each position its own choice of person and so its own copy; the copies cannot diverge, because filling one position of an instance fills them all. AResultlists an outcome per copy (<request id>[<key>]);publish.views.reportcollapses them to one row per request, keeping the failed copies' keys in the description, so an unstaffable clinic is one row rather than one per position. Loading first removes the date's generated rows. The solver warns when no generated requests exist for the target date. Clinic instances come only fromTASKstatements; a(DBL)clinic asked for withDURING ALLof two blocks is one instance. - Partial blocks replace display groups (Puppet Master, 2026-09-16). Blocks are the
real periods of the day. Every assignment is a CP-SAT interval; a clinic fills its
block, an ad hoc
FORtask has a movable start and a variable length inside its block, and oneAddNoOverlapper person replaces the per-block double-booking constraints. Several partial tasks may share a block.n OFwithFORmeansnblocks each holding the full duration.GAPconstrains real start and end times. The tidiness pass also minimizes task minutes and offsets from block starts. The Staff View joins a block's tasks with ", then " and labels unused timeDYOW/WPs(configurable). The published tab gainedstartandminutescolumns; the Blocks sheet lostdisplay_group. - The
datenamespace is built from session and week numbers (2026-09-18). The Calendar sheet'ssessioncolumn holds a number rather than a name, and a newweekcolumn numbers the days within each session. Every date name is then a span or a name inside one:dates.season,dates.session_4,dates.session_4.week_2, anddates.session_4.week_2.monday.dates.session_1is the session holding the target date anddates.session_1.week_1the week; the olddates.session_all,dates.seasonanddate.<session name>.allare gone. A span carriesfirst,last, the weekday sets and the counted occurrences; a week, reaching each weekday once, carries the weekday as a single date. An unknown name now suggests the nearest real one. The request manager's calendar labels each rowS<session>/W<week>in place of the ISO week of the year, and the names panel nests on the dots. - Requests belong to groups (Puppet Master, 2026-09-18). A
groupscolumn on the Requests sheet holds the groups a request is in, comma-separated, and the request manager switches between them in a pane of its own:All requests,Ungrouped, the two default groups (Special daily requests, Special weekly requests) and whatever the Puppet Master makes. Generated clinic requests are in no group, being too many and already marked by their tag. A group lives on its requests, so making one declares nothing; the filters narrow within the group rather than replacing it. Arequestercolumn records who asked, as a staff name, checked like any other name. Saving a request that says nothing about the date being scheduled asks first. - A request block holds any number of statements (Puppet Master, 2026-09-18).
PREFERno longer has to stand alone: a declaration may mixREQUESTandPREFERstatements, so one piece of plain English that needs several statements stays one request, with one description, priority and weight, and one binding, condition and set of gaps. The compiler splits them: the requirements share the declaration'ssatliteral exactly as before and are what the report names, and each preference adds its own objective terms in the declaration's tier afterwards, where_collapsecannot see them.PREFERin aMUST_HAPPENdeclaration is still refused — there is no tier above the hard one to weigh it in. - Conflicts are found without solving (Puppet Master, 2026-09-18).
app/conflicts.pyreads the resolved copies the store already keeps and turns every forced statement into a claim on a slot — one staff member, one date, one block. A statement is forced when it leaves the solver no choice:who,ONandDURINGare allALL(or a single item, or anEACHcopy) and aDOnames one activity or task. Claims on a slot are then compared: free against do, do against not-do, free against not-free, and do against do by minutes, since partial tasks share a block. The pane groups them by slot. Nothing that leaves the solver room —ANY n,PREFER, an undatedDURING— makes a claim, which is what keeps the pane quiet enough to be worth reading. - A publish is six write requests (Puppet Master, 2026-09-22). Google counts write
requests against sixty a minute per person, and a publish sent a clear and an update per
tab, a format call per bold row, and one each for the merge, the unmerge, the freeze and
the widths — around forty on the sample day and past sixty on a real one, which is a 429
in the middle of writing a schedule.
Source.write_manyclears and refills every tab of a spreadsheet in two requests (values_batch_clear,values_batch_update), andstylesends onebatch_updatefor the shape of the sheet and onebatch_formatfor the painting, however many rows and fills it holds. A publish is six requests and a save of a request is two. Anything Google refuses for coming too fast (429, 503) waits 5, 15 then 30 seconds and asks again, which is what its own answer asks for; the publish runs on a worker with a panel up, so the waiting is visible and the window keeps painting. - A load reads the day; a solve reads the days behind it (Puppet Master, 2026-09-22).
What was published on past days is read by the solver alone —
variables.was_free,was_member, and patterns counting back over a session — and there is a spreadsheet of them per day of the season so far, so a reload in August was spending most of its time fetching what the window never looks at.load_dataset(..., history=False)reads only the target's own day, which is whatbaselineand the Same-day toggle need;read_historyfills in the rest and is called on the way into a solve (RequestStore.for_solving). The request manager then prefetches it on a worker as soon as a load finishes, so Solve is usually already holding what it needs. A prefetch installs its answer only if the dataset it was asked about is still the one loaded, since a Dataset is frozen and swapping one for another is a single assignment. A reload is then flat in the age of the season: about a dozen API calls in August as in June. - A read is one request (Puppet Master, 2026-09-22).
read_manyasked Google what tabs a spreadsheet had before asking for the values, which is a second round trip per spreadsheet — and a load opens one spreadsheet per published day and per cabin act sheet, so by late August that question was half the traffic of a reload. The values are now asked for outright and the tab list is fetched only when that fails, which is the one time the answer says anything: it is what names the missing tab._walkalso remembers where each folder of the tree is, prefixes included, so looking back over four spans walks2027/Main Seasononce rather than four times, anddiscoverruns once per root and year. Measured over a modelled season, a reload 70 published days in goes from 143 API calls to 73. - ANY n chooses, and a count measures (Puppet Master, 2026-09-24).
ANY 1andAT_LEAST 1had come to mean the same thing in a requirement and different things elsewhere, andEXACTLYmeant "pick n and forbid the rest" in a requirement but "exactly n" in a test. NowANY npicks: once for the statement, shared by everything in it, forbidding nothing. It is what aREQUEST, a binding line, a group and a mapping's default choose with, andANYwith no number stays a pool.AT_LEAST,AT_MOSTandEXACTLYmeasure, in tests,PREFERs,FORandWITH; aREQUESTtakesAT_MOSTas a cap and refuses the other two,EXACTLYbecause the forbidding it did is written withNOT DO: a bound name may now be taken from a whole set,{s - x}(Choice.minus,Compiler._less). A pick in a requirement compiles as before; one beside a cap is named (_pick_…) and shared the way a binding line is, and a pick of blocks over pooled dates is counted as occurrences, as a count of them was. There is noPREFERfor "as many as possible": that isEACHat a soft priority. Saved requests are rewritten on their next load (syntax 8), and only by the passes after the version the file records, since the role pass of syntax 6 is not safe to make twice; an imported file keeps its own version. A version 5 binding line written for a shared choice goes back inline. A choice after a choice of several, which was each one's own, and anEXACTLYin a requirement are left for somebody to rewrite. - FOR says how it is bounded (Puppet Master, 2026-09-24). A bare
FOR 30mmeant exactly, the one quantity in the language that went without its bound, and over pooled blocksFOR 2hread as "at least" to most people while meaning "exactly".FORnow takesEXACTLY,AT_LEASTorAT_MOST; a bare length is refused with the new spelling, and a request saved in syntax 6 gains itsEXACTLYon its next load (syntax 7). - WITH says in which role (Puppet Master, 2026-09-24).
WITH staff.alan AS_ROLE roles.firstis Alan first on the same instance: anAS_ROLEstraight after aWITH's orWITHOUT's set is theirs, which the grammar takes as part of the clause, and the company check counts partners only in those roles (Variables.members). Before, such anAS_ROLEwas the subject's, so a request saved in syntax 5 has it moved in front of theWITHon its next load (syntax 6). - A count goes on the set it counts (Puppet Master, 2026-09-23). An activity has no
quantity of its own; the blocks, the dates and the people come in numbers. So a count is
written directly in front of the set it counts —
DURING EXACTLY 3 blocks,AT_MOST 2 staff.counselor— rather than afterREQUEST, far from what it measures, or afterDO, where it read as a quantity of the activity. A length goes onFOR, which measures within one unit of the blocks: each block when they are taken one at a time, their total when they are pooled withANY.ANY nis gone: a number is always a count,ANYis always a pool, and a binding line names exactly which,EXACTLY n x IN.NOT FREEisBUSY, which was what it meant; with it gone, a count right ofNOTis refused, since what it could say reads better as a count of what does happen.ALLis one unit inside every count, which is what letsALL {…} DURING EXACTLY 1 blocksmean one block together.DURINGandONstill go anywhere;AS_ROLE,FOR,WITHandWITHOUTgo after the verb, and the counts nest by role — who, what, dates, blocks — rather than by where they were written. A statement with no count but oneAT_LEAST, or with only one-item counts outside it, still compiles as the requirement it always was (resolve._chosen_once); anything else is aTally, compiled byCompiler._enforce_levels,_reify_levelsand_tally_miss, which choose forAT_LEAST, count reified holds forAT_MOST, and do both forEXACTLY. AFORover a pool lets one of its pieces be cut short (_allow_partial). A block is a block on a date, so over dates pooled withANYa count of blocks counts each block on each of them (Compiler._counted): "at most eight clinics a session" isDURING AT_MOST 8 blocks ON ANY <session>, and runs of blocks stay within a day. - Every set says how it is taken, and CONSECUTIVE is on the blocks (Puppet Master,
2026-09-23). A set right of
NOTor in a pattern is matched rather than chosen, and was the one kind of set written with no word in front of it; it now takesANY, with no number, and a bare set there is an error. Right ofNOT,ALLmeans "not all of these together", sinceNOTturns round the positive request to its right:_forbid_togetherforbids, per person, the conjunction over every combination of theALLitems, past dates counting as facts.ANY nstays an error there, "not in two of them" being a count.CONSECUTIVEmoved again, onto the blocks, which are what is in a row:DURING ANY 2 CONSECUTIVE blockschooses, and a count measured in runs saysDURING ANY CONSECUTIVE blocks, whereAT_LEAST 3 CONSECUTIVE s DO …read as three consecutive people. An amount may also followDOwhen the subject is one person per copy,s DO AT_LEAST 3 …. Saved requests are rewritten on their next load byskedge.upgrade, which works on the parse tree, where the old spellings still parse; the requests file records the syntax version it is written in, so this happens once. - CONSECUTIVE comes right after what it is about (Puppet Master, 2026-09-22). "Two
blocks" and "two blocks in a row" differ by one word, and were written two different
ways: a requirement, and a counted pattern.
DURING ANY n <blocks> CONSECUTIVEnow chooses n adjacent blocks in the requirement itself, and the count'sCONSECUTIVEmoves from after the pattern, whereON {…} CONSECUTIVEread as consecutive dates, to after the amount. The old place is still parsed, only to say where it goes now. The compiler keeps the block choice's literals and picks exactly one run of adjacent blocks (_adjacent), each block chosen when the run holding it is, so everything else a requirement does with a choice (partners, deferral,ALLstaff together) is unchanged;_looseleaves such a choice alone, since which blocks it picks matters. - EXCLUDE takes somebody out of the day (Puppet Master, 2026-09-22). A day off is not
something to ask the solver for, so
EXCLUDE <who> DO '<label>' [DURING] [ON]is applied rather than compiled.puppet_strings/exclude.pyresolves everyEXCLUDEon the sheet and returns the Dataset those days leave behind: the blocks go intoresting, soDataset.holdsis false and no variable is ever made for them; somebody out for a whole day drops out of every staff category, which is what keeps aMUST_HAPPENrule written aboutstafffrom asking anything of them and is why the day still solves; andDataset.excludedcarries the label for the published views. The pass is idempotent and runs at the end ofload_dataset, insolve()and inRequestStore.current, so a Dataset is never seen with somebody in a day they are not in. The compiler dropsExclusionstatements, and a copy holding nothing else is done rather than inactive, so the report has nothing to say about it. - An update can be an archive (Puppet Master, 2026-09-21). The Linux release is a
tar.gz carrying the executable, the icon and an
install.shthat writes the desktop entry.install()unpacks an archive (tarfile,filter="data"), puts the executable inside it where the running one is, and refreshes the icon beside it if one is already there. The desktop entry is left alone: it names that folder, which is where the new executable went. A bare executable, which is what Windows and macOS release, is moved into place as it is. - The staff view is formatted too (Puppet Master, 2026-09-21). It is the sheet
everybody at camp opens, most of them looking for one row of it, so
staff_viewreturns aStyledlikeclinic_view: title, bold headings, a frozen corner (two rows and the name column), the clinic view's block colours on the headings, banding on every other row, wrapped cells and column widths.Styledcarriesfreeze_columns,wrapandcolumn_widthsfor it, the last going out as oneupdateDimensionPropertiesbatch. - The errors pane holds more than conflicts (Puppet Master, 2026-09-21). Two requests
disagreeing is not the only thing that is wrong on paper and invisible until a solve:
REQUEST staff.henry DO activities.clinics.aerial_silks DURING blocks.clinic_3validates -- every name in it exists -- and is still impossible if Henry has no checkoff, or if the day's Offerings tab does not run aerial silks in clinic 3.app/errors.pyreads the same resolved copies the conflict finder does and asks those two questions of every settledREQUEST … DO;app/errors_panel.pyshows conflicts and errors in one tree, since both name a slot and the requests to go and look at. Both checks are skipped where the solver has a choice, and the offering check is skipped entirely when the day offers nothing at all, which is one thing to see to rather than a pane full of the same sentence. - Only what must happen can conflict (Puppet Master, 2026-09-21).
find_conflictsreads theMUST_HAPPENrequests and no others. A conflict is a day that cannot be built, which takes two promises that cannot both be kept; a softer request crossing another is settled by dropping the cheaper one, and the day comes out regardless. Everything belowMUST_HAPPENis the solver's to weigh and the report's to explain. - A drag is welcomed at the door and judged at the table (Puppet Master, 2026-09-21).
GroupList.dragEnterEventasks only whether the mime type is a set of requests; which row the pointer is over is the move's and the drop's question. A widget that ignores a drag enter is told nothing more about that drag, so judging the enter by the row under it would leave every group undroppable whenever the pointer crossed into the list overAll requests-- the row at the top, and so the one most drags come in over.dragMoveEventcalls Qt's own first, which is what starts the scrolling when a drag is held at the edge of a list too long to show at once. - Keywords read in either case (Puppet Master, 2026-09-21). Every keyword is its own
terminal at priority 5, written
/WORD\b/i, rather than an anonymous string. TheimakesrequestandREQUESTone word; the priority puts it aboveNAME, which lower case would otherwise be lexed as; and the\bkeeps it to whole words, soformatis a variable and notFORfollowed bymat. The parser upper-cases what a quantifier or a bound token says before comparing it, so the tree holds one spelling however it was typed. The highlighter matches case-insensitively for the same reason. - A gap reaches back into the published days (Puppet Master, 2026-09-21).
Madetimes are counted from midnight on the target date, which puts yesterday at a negative time, and a labeled requirement satisfied by a published day contributes that day's real start and end (_was_made). AGAPis therefore a distance between two assignments wherever they sit, which is what makes a duration in days worth writing:GAP first TO second AT_LEAST 40his the forty hours it says. Dates after the target hold nothing and cannot be one end of a gap; they are checked on the day they land on, when the other end is published. - A clock per pass, not one for the whole solve (Puppet Master, 2026-09-24).
time_limit_secondswas one budget for everything, shared out by aDeadlinethat started before the model was built (2026-09-18), which capped a solve at the setting but meant a slow early tier left the late ones a zero-second pass and a note saying so. The late tiers are the ones a Puppet Master reads the notes about, so the cap was being paid for in the wrong place.tier_seconds_limit(default 15) is now handed to each pass whole: the feasibility check and every tier start with the same amount, the placement pass getstidy_secondsrather than a hold-back, and theDeadlineis gone. A day using all five tiers can therefore run about five times the setting. The note for a tier that ran out of time says what it scored, the best it could not rule out, and the gap between them in requests, because the schedule is kept either way and the only question worth answering is how much might have been missed. The old key is not read as an alias, since its number meant a total and would multiply by tier if it were. - Requests are their own spreadsheet, a tab per session (Puppet Master, 2026-09-19).
One tab of the config spreadsheet held every request the season had ever made, and every
load read all of it: the 2026 season reached 2,024 rows, of which a day in session 4
needs 552. Requests are now a
Requestsspreadsheet beside Config and Skills, found by name like the rest, holdingSeason Requestsfor what crosses sessions and two tabs per span —S4 Clinicsfor what Load offerings writes,S4 Specialfor what was asked of that session. A load reads three of them. Which tab a request is on is itshome, read off the tab it came from and written back to it rather than kept in a column, so moving a request between sessions is a cut and paste; the editor'son tabbox is how a new one goes to the season's tab instead of the session's.puppet-strings split-requestsmakes the spreadsheet out of the old tab, reading each id for the span it names and leaving anything it cannot place on the season's tab, where every load goes on reading it. - A request sits on one shelf, and its id is not its description (Puppet Master,
2026-09-19). A request belonged to any number of groups, ticked off in the editor, which
made the editor and the pane two places to decide the same thing and made "which group is
this in" a list rather than an answer. A request now has one
group, or none. It joins the group the pane is showing when it is made, and it is moved by dragging its row onto another group's label, so the pane is the only place a group is decided; the editor's checklist is gone and agroupline reports where it sits. Right-clicking a group sets the Requests tab its new requests are written to, kept in settings.json because a group is a label its requests carry and there is no row anywhere to hang a default on. The sheet'sgroupscolumn becomesgroup; an old one holding several is read as the first. Ids are the next free number on the request's tab —s4-1,season-2— rather than a slug of the description, because a description is for people, is allowed to be empty, and gets rewritten the moment somebody words it better, none of which an id may do. - A statement may run over several lines, and a bound name may be added into a set
(Puppet Master, 2026-09-19). Both came from one request that read perfectly and would not
compile. A newline is no longer the end of a statement when the next line begins with a
word that continues one —
DO,DURING,ON,FOR, a set operator, a closing brace — which keeps the grammar LALR by deciding it in the lexer:_CONTINUESmatches those newlines and is ignored, and_NLgets the rest.EACH,ANY nand bare names are deliberately not continuations, since each of them can begin a line of its own.ANY 1 v IN {…}could only be used as a whole selector, so "Charlton with either Dylan or Donny" had no natural wording; a bound name can now be added into a set with+, which resolves to aChoicecarryingparts— the items named here, plus whatever each part chose. The solver already spoke in a dict of item to literal, so joining them is merging two dicts, and the binding's own literals are what keep it the same person throughout. Only+, and onlyALL:-and&ask what a chosen name is not, and takingnof such a set is choosing out of something still being chosen. ANY n, groups, clauses in any order, and named sets (2026-09-22).ANY_n_OFread like nothing else in a declarative language and not like its ownAT_LEAST n, so it is nowANY n: anANYkeyword and anINT. The old spelling is still lexed, only to be toldwrite ANY 1, not ANY_1_OF. A quantifier may now also go on a part of a set, in parentheses: anast.Group. Taken whole, a set's groups are flattened (ALL) or becomeparts(ANY n), soALL {x + (ANY 1 s)}compiles to exactly what the binding line did. UnderANY neach group is one unit of the choice, held inChoice.units: the solver gives each a literal alongside the plain items' and chooses the group's own members under it. UnderEACHeach group is a copy. Items reached twice are merged with an OR, wherepartsused to overwrite. Clauses may now sit anywhere around the subject, verb and object, which meant oneclausesrule used at every gap and one clause rule for requirements and patterns alike; the pool-only quantifier rule moved from the grammar to the builder, which says why rather than what it expected. The line-continuation rule was turned round to match: rather than listing the words that continue a statement,_CONTINUESlists the few that can begin a line — a statement keyword, a binding, a name and a colon — and every other newline is nothing.name: <set>is anast.Definition, written into the lines that use it by the parser, so nothing downstream knows it was there;name: ANY n <set>andname: EACH <set>are binding lines.- Requests live on the Puppet Master's computer; Google Sheets are cached by version
(Puppet Master, 2026-09-23). Nobody edits requests outside Puppet Strings and only one
Puppet Master schedules at a time, so requests are one SQLite file, handed over with
Export and Import in the Configure pane or
export-requests/import-requests. An import is checked whole before it replaces anything, and keeps what it replaced beside the file. A fixture folder carries its ownrequests.sqlite. The sheets themselves are kept in a cache keyed by spreadsheet id and Driveversion, which the folder listings a load already makes return for free. A listed version is trusted for a minute, about one load, so every load lists again without anything having to say when a load begins; anything Puppet Strings writes drops its entry at once. Drive can be a moment late counting an edit, which is what Clear in the Configure pane and--no-cacheare for. - Requests are scoped, not filed in lists (Puppet Master, 2026-09-23). A request was
filed in a named list — the season's, a session's Special, a day's Clinics — and a load
read three of them, which meant a list for every session and every day the season had.
A request now has a scope, a day, a week, a session or the season, kept as the dates it
covers; a load reads whatever covers its date, one indexed query however long the season
runs. A new request is scoped to its session and a generated one to its day. Ids are
unique across the whole file, numbered per scope (
s4-3,s4w2-1,jun08-1,season-2), and a save writes the one request it changed rather than rewriting lists. The file's format went from 1 to 2 with no conversion: it was a beta. ALLandEACH, and dates numbered in digits (Puppet Master, 2026-09-23). OnceANY_n_OFbecameANY n,ALL_OFandEACH_OFwere the only keywords left with an_OF, so they areALLandEACH. The lexer still reads the old spellings as the same tokens, so the builder can saywrite ALL, not ALL_OFandskedge/upgrade.pycan find and rewrite them; saved requests are rewritten on their next load (syntax 4).allandeachare now keywords and so cannot be a name on their own, thoughblocksandall_staffare untouched: a dotted name is one token, and a keyword ends at\b. With thedatesnamespace flat, sessions and weeks are named by digit,dates.session_4.week_2rather thandates.session_four.week_two, and a file written the old way is rewritten when it is opened.- The days of the week are the season's, and spans name no days (Puppet Master,
2026-09-24). Every span and week carried its own weekday names,
firstandlast, so the same day had several names and the tree was too busy to offer in the completer.dates.mondays…dates.sundays,dates.weekdaysanddates.weekendsare now sets over the season, and a span's days are written with&:{dates.session_2 & dates.thursdays}, or{dates.session_2.week_1 & dates.sundays}for the day it starts. The saved requests were rewritten to match, and the completer offers date names like any others. - Offerings are names, not requests (Puppet Master, 2026-09-25). Every load made one
CLINICrequest per row of the Offerings tab, taggedclinic_import, with its own rules: never saved, not deletable, an edited one read in place of the one made. They filled the request manager with requests nobody changed. The Offerings tab is now names, underactivities.clinics.offeringsbeside the clinics themselves, as the cabin act board is underactivities.cabin_acts:offerings.clinic_2.archery_1_2andofferings.clinic_2. An offering asked for with no subject is its clinic in its block: one standingREQUEST EACH activities.clinics.offeringsdoes what the generated requests did, andgenerate.pyis gone. An offering says when it runs, so it takes no clauses and goes nowhere but there. No saved request had been an edited clinic, so nothing was rewritten; the standing request was added to the Puppet Master's requests file. - Cabin acts are named by the day's board (Puppet Master, 2026-09-25). A cabin act was
named by its cabin alone,
activities.cabin_acts.p4, standing for its act on whichever day a request was about. With the offerings the target date's own names, the cabin acts are too:activities.cabin_actsand its two halves are that day's acts, and each act is under its half by its cabin,activities.cabin_acts.at_cabin_act.p4. A cabin has a name only on a day it has an act. The saved requests naming a cabin were all for one day's act in the cabin act block, and were rewritten toat_cabin_act. - ACROSS says a FOR is a total (Puppet Master, 2026-09-25). Whether
FORwas the length of each piece or what the pieces added up to hung on how the blocks were taken:DURING ANY 3 blocksmade it each piece andDURING ANY blocksthe total, one digit apart, and noDURINGat all or anON ANYmade it a total too. A total over a chosen number of blocks could not be written. NowDURINGalways makesFOReach piece andACROSS, in its place, makes it the total (During.across,resolve._phrase).ACROSStakes the blocks asDURINGdoes, and a pick, count orALLof them is aTally.pieces: how many of the pooled blocks hold a piece, counted beside the total (Compiler._pieces_hold), with any piece free to be cut short. WithDURING, a length no block is long enough for is refused, since it was meant as a total. The training problems and docs that pooled aFORwere rewritten; no saved request did. - ACROSS takes dates too, and its set whole (Puppet Master, 2026-09-25).
ON ANYread as "one of these dates, whichever", but where the statement counted or measured it meant all of them together:DURING AT_MOST 3 blocks ON ANY dates.session_1was a cap over the session.ACROSS <dates>in place ofONnow says so, andON ANYwith a count, aFORor a pick of several blocks over more than one date is refused (resolve._adds_up). Without them the two are the same pool.ACROSSis one clause for blocks or dates, told apart by the names in its set once the definitions are in (parser._place_across); on the first line it is dates, for every statement. Its set is taken whole, soACROSS ANY blocksis nowACROSS blocksandACROSS ANY CONSECUTIVEisACROSS CONSECUTIVE; anACROSSof dates takes no quantifier at all, a pick or count of dates beingON's. The training problems, docs, the test fixtures and one saved request (a copy of the file before isrequests.before-across-dates.sqlite) were rewritten. - No THEN (Puppet Master, 2026-09-25). An
IForUNLESSis always followed by the braces of its block, soTHENbetween them said nothing the brace did not. It is gone:IF <test> { … }. The brace cannot be read as part of the test, since a test's sets are whole before it, and one on the next line carries on theIFas any other line does. The training problems, docs, tests and the three saved requests with anIF(a copy of the file before isrequests.before-no-then.sqlite) were rewritten.