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
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.
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.
A throwaway projection was written against the corpus and diffed against the shipped vigilance-bank.json.
| projected | matched by id | byte-identical | unreproducible | |
|---|---|---|---|---|
| The Wager | 34/34 | 34 | 27 | ruleId ×7 |
| The Lineup | 35/35 | 35 | 31 | ruleId ×4 |
stem, passage, choices, answer, traps, section, conId and breakIdx reproduce exactly. Three findings shape the work.
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.
pick and breakIdx are derived on clean rounds, not lost18 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.
ruleId is the one real gap, and it is 11 itemsOne 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.
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.
| File | Owner |
|---|---|
vigilance-bank.json | Press Raven corpus this plan |
generated-vigilance-bank.json | Python pipeline |
generated-questions.json | Python pipeline |
sat-question-corpus-pipeline.md line 19 ("needs a re-copy to Resources") is stale — the script does the copy itself. That is why the two copies are byte-identical.authored-vigilance.json is misleadingly named. Despite "authored", it is gap-fill folded into the generated bank. Not a competing claim.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.
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.
rule_id in the corpusAdd 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.
| Entry | Rule | Entry | Rule |
|---|---|---|---|
l-reading-extreme-1 | KR-06 | w-reading-herring-0 | KR-08 |
l-reading-herring-1 | KR-08 | w-reading-kr02 | KR-02 |
l-reading-scope-1 | KR-01 | w-reading-kr03 | KR-03 |
l-writing-dangle-1 | KR-09 | w-reading-kr07 | KR-07 |
w-math-kr10 | KR-10 | w-writing-kr04 | KR-04 |
w-writing-kr05 | KR-05 |
build() for a multi-family outputvigilance-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.
project_vigilancePort from the validated dry run. Rules it must carry:
cid[0].upper() + cid[1:]math-nocalc / math-calc → mathkey, emit text in order; answer is the index of the correct one. The corpus stores these booleans as the strings "true"/"false" — accept both.{str(index): con_id} per distractor carrying one; untagged stay silent.con_id → pick = answer, breakIdx = -1.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.
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.
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.
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.
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.
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.
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 corpus | app can hold today | |
|---|---|---|
| Wager distractors | 102, 100% carry trap_codes, mechanism, drill_rx, error_mode | traps: {index: conID} — 75 nicknames |
| Lineup distractors | 17 carry the same (16%) | conId on the round |
| Target Practice | 45 items; fault_id ×9, skill_code ×37, strategy_tags ×12, plausible_clean ×14 | 16 items, {id, text, isCon}, hardcoded |
| Con roster | 19 × 8 fields incl. traps (16/19) | name, glyph, family + prose, hardcoded |
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.ConTaxonomy.swift — see the open question below first.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.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.
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.
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 neglectNull on 96% of items, absent as a key on all 281 generated ones. Worth recording so it is not "fixed" by someone backfilling noise.
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.
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.
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.
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.
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.tools/ before the scratchpad expires — it is the projection's test, and it will be wanted again at phase two.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.