Peregrine's machine target

A no-op export first, then the widen. Phase one lands the entire pipeline while provably changing zero bytes in the app.

2026-08-15 · corpus guides/peregrine/ · app ~/Developer/Peregrine (main, clean) · Press Raven master, in sync

What this plan is

Peregrine declares project_path but no machine_target, deliberately: its vessel_notes say outputs are print-only "until the banks are migrated and reviewed." The banks are migrated — 133 entries, 114 of them playable — so what is actually missing is the projection, not the content.

The split this plan turns on

Phase one is decided by a fact established by a dry run rather than by argument: it changes no bytes in the app. The proof is an empty git diff.

What the dry run established

A throwaway projection was written against the corpus and diffed against the shipped vigilance-bank.json.

projectedmatched by idbyte-identicalunreproducible
The Wager34/343427ruleId ×7
The Lineup35/353531ruleId ×4

stem, passage, choices, answer, traps, section, conId and breakIdx reproduce exactly. Three findings shape the work.

1 · IDs need a case transform, and it is load-bearing

Corpus ids are filename slugs (w-math-000); the app prefixes uppercase (W-math-000). Exact overlap is 0 of 69; case-insensitive overlap is 69 of 69. VigilanceResult.itemID stores the bank id, so an export that skipped this would orphan every accumulated play record.

2 · pick and breakIdx are derived on clean rounds, not lost

18 lineup rounds carry no pick in the corpus ("absent by design" — a clean round has no con). On all 18 the app's pick equals its answer and breakIdx is -1. The projection derives both; storing a copy would only create something that can disagree with itself.

3 · ruleId is the one real gap, and it is 11 items

One exemplar per knowledge rule, KR-01 through KR-10 (KR-08 twice), all resolving against knowledge-rules.json. BankRuleTaggingTests already asserts that a rule-tagged item sets a matching con trap. With rule_id backfilled, the projection reproduces the shipped bank exactly.

The ownership line

pipeline/scripts/to_app_bank.py writes exactly two files, into both pipeline/generated/app-bank/ and the bundled Resources directory (it auto-syncs at lines 202–205). It never touches vigilance-bank.json.

FileOwner
vigilance-bank.jsonPress Raven corpus this plan
generated-vigilance-bank.jsonPython pipeline
generated-questions.jsonPython pipeline

The app concatenates both banks via VigilanceBank.withGenerated() with no dedup and no id-collision check. Id spaces are disjoint by convention only. Phase one does not change this, but it is the failure mode to watch if either side ever renames.

Phase one — the no-op export

Success criterion

python3 tools/export-machine.py peregrine reports identical, no change. If it reports a diff, something in this phase is wrong and nothing should be written.

STEP 1Backfill rule_id in the corpus

Add a rule_id field to schema-extensions.json under applies_when for both the-lineup and the-wager, then set it on 11 entries. Stamp updated on each; the confidence.note should say this recovers a value the app already holds rather than claim fresh authorship.

EntryRuleEntryRule
l-reading-extreme-1KR-06w-reading-herring-0KR-08
l-reading-herring-1KR-08w-reading-kr02KR-02
l-reading-scope-1KR-01w-reading-kr03KR-03
l-writing-dangle-1KR-09w-reading-kr07KR-07
w-math-kr10KR-10w-writing-kr04KR-04
w-writing-kr05KR-05

STEP 2Extend build() for a multi-family output

vigilance-bank.json is one file holding two families, which the current spec form cannot express — every existing output selects on a single mini_game. Add a families selector to build(). Additive: it fires only when present, so Stellar and Drive Corps are untouched.

STEP 3Write project_vigilance

Port from the validated dry run. Rules it must carry:

  • ID transform. cid[0].upper() + cid[1:]
  • Section collapse. math-nocalc / math-calc → math
  • Options → choices. Sort by key, emit text in order; answer is the index of the correct one. The corpus stores these booleans as the strings "true"/"false" — accept both.
  • Traps. {str(index): con_id} per distractor carrying one; untagged stay silent.
  • Derived clean-round fields. No con_id → pick = answer, breakIdx = -1.
  • Key order must match the shipped file, or the diff is noise.

Invariants that exit non-zero: four options and exactly one correct; no duplicate ids after the transform; a con round names a con_id in the taxonomy; break_idx points inside reasoning; a clean round carries none of the three; reasoning never names a choice letter.

Report what is discarded — every run

discarded: 102 distractors carry mechanism / drill_rx /
           error_mode — 0 transmitted (see phase two)

The export script already prints held-back List Two entries; same idiom. The whole risk of phase one is that "temporary" becomes permanent, and a line you read on every export is the cheapest guard against it.

STEP 4Declare the machine target

One output, targeting PeregrineCore/Sources/PeregrineCore/Vigilance/Resources/vigilance-bank.json — into the SPM package's Resources, not a Data/ directory. Peregrine has none; content reaches the bundle through .copy(...) in Package.swift. Because this overwrites an existing file, Package.swift needs no change.

STEP 5Verify, then write

python3 tools/export-machine.py peregrine          # expect: identical, no change
python3 tools/export-machine.py peregrine --write
git -C ~/Developer/Peregrine diff --stat           # expect: empty

An empty diff is the proof. Anything else is a defect in steps 1–3.

STEP 6Install the drift guard

Copy Stellar's pre-commit hook, changing the guide id and narrowing the path filter to vigilance-bank.json only — not the whole Resources directory, since the generated banks belong to the Python pipeline and a hook that fought it would block legitimate commits.

STEP 7Declare the output

Add "machine" to Peregrine's outputs, enabling Export to App… and the toolbar's drift arrow. Rewrite vessel_notes: the sentence about banks not being migrated is no longer true, and what replaces it is the ownership line.

Phase two — the widen

App-side Swift, and therefore a session in ~/Developer/Peregrine, not a Press Raven change. Everything here is re-exportable: the corpus already holds the content, so this is transmission, not authoring.

authored in corpusapp can hold today
Wager distractors102, 100% carry trap_codes, mechanism, drill_rx, error_modetraps: {index: conID} — 75 nicknames
Lineup distractors17 carry the same (16%)conId on the round
Target Practice45 items; fault_id ×9, skill_code ×37, strategy_tags ×12, plausible_clean ×1416 items, {id, text, isCon}, hardcoded
Con roster19 × 8 fields incl. traps (16/19)name, glyph, family + prose, hardcoded
  1. Extract Target Practice. 16 claims live in a private static let at TargetPracticeView.swift:27-42. This also fixes the ratio defect the corpus records: the app ships 7 clean : 9 con against a contract requiring 4:1, and below one in five the dominant strategy is to fire at everything. The corpus bank is 36 : 9. Once extracted, the ratio becomes script-enforceable.
  2. Extract the con roster from ConTaxonomy.swift — see the open question below first.
  3. Widen WagerQuestion. Small and additive: .traps is read in three test files plus the memberwise init, and con(forChoice:) has two production call sites, both in WagerGameView.
  4. Re-export. Same pipe, now carrying more.

The sequencing rule, from export-machine.py:115

"Shapes below match the Decodable structs … They were written after those structs existed, not before: a projection that guesses at the app's model is a second source of truth waiting to drift." So each extraction lands app-side and then gets a projection. Never the reverse.

Open questions

The roster is declared LOCKED

Corpus entries say "Roster is locked in the app; this mirrors it." Moving it out of Swift so it can be projected contradicts that — unless "locked" means "the corpus must not change it," in which case the answer is a verify-only output: the export checks and fails on mismatch but never writes. The script has no per-output flag for this today. Decide before phase two step 2.

Go Undercover has no corpus entries at all

Six rounds hardcoded in GoUndercoverView.swift, bank_target unset — precisely the ambiguity the Stellar notes warn about ("an unset target and an empty bank are indistinguishable in the roster"). Either the corpus owns these six, or bank_target: 0 should say so explicitly.

ruleId coverage is thin by design but reads as neglect

Null on 96% of items, absent as a key on all 281 generated ones. Worth recording so it is not "fixed" by someone backfilling noise.

Invariants may end up asserted twice

PeregrineCoreTests already holds four relevant lint suites. Where a rule belongs — projection or test suite — should be decided per rule, not by default. Duplicated invariants drift apart.

Final review

What this gets right

The riskiest part is the plumbing — machine target, projection, id transform, ownership split, drift guard — and phase one lands all of it while provably changing zero bytes. Neither "flatten everything now" nor "widen first" offers that: widening first couples plumbing risk to schema-design risk in one step, on a schema that cannot be validated because its consumer does not exist yet.

What it gives up

Phase one transmits none of the per-distractor analysis authored on 2026-08-08 — 408 values on the Wager alone. Nothing is destroyed; the corpus keeps all of it, and phase two is a re-export rather than a recovery. But the honest risk is that phase two never happens and the authoring pass stays unread. The discard line is the guard, and it is a weak one — it informs, it does not force.

Where this plan is inadequate

Phase two is sketched, not specified. The widened WagerQuestion shape is undesigned because the consumer does not exist: TrapAtlasView is 79 lines and reads only VigilanceResult rows — it never touches the bank. A join via itemID is described in VigilanceResult's own comments as the intended mechanism but is not built. Widening the model is necessary but not sufficient; without UI work it is storage, not feature. Anyone reading phase two as costed is reading it wrong.

Adjustments worth considering

  1. If phase one's export is not byte-identical on the first try, stop and diff rather than adjusting the projection until it matches — a surprise diff is information about the corpus, not a formatting problem.
  2. The families selector is small, but it is the first spec form spanning mini games; if a second case appears it wants to be the normal form rather than a branch.
  3. Move the dry-run script into tools/ before the scratchpad expires — it is the projection's test, and it will be wanted again at phase two.

Confidence: 88

High on phase one, which is tested rather than argued: 58 of 69 rows already reproduce byte-identically and the remaining 11 have a known one-field fix. Lower on phase two, whose two extraction targets are Swift literals with nothing to diff against, and whose widened schema has no consumer to validate it. The number was 74 before the dry run; what moved it was finding the ID case defect and the derived-pick explanation, neither of which reading the schemas would have surfaced.