SuperAPI AI Directory

seeded-determinism-and-golden-vectors

Coding & development

View repository

Updated 10-18 18:02

About this skill

Seeded determinism and golden vectors

Some output must be a pure function of a key: the daily content every user compares, a generated layout, a shuffle a share code reproduces, a replay. This skill makes that output identical on every device, in every time zone and on next year's build, with no server: one seeded PRNG the app owns, civil-date keys, content regenerated from (key, version) instead of stored, shipped generators frozen behind versioned cutovers, and golden vectors computed by an independent oracle that two checkers and the test suite verify.

Rules that must hold

  1. The key is an argument; seeded code never reads a clock. A generator takes its DateKey, id or sequence number as a parameter; nothing it reaches calls Date.now, new Date, performance.now or imports the clock service. Why: a clock-reading generator cannot be pinned, replayed from a bug report or run for tomorrow in a test.
  2. A day is a civil-date key 'YYYY-MM-DD', computed with integers, from one day definition. Keys come from clock.today() at the edge and go through src/core/dates/date-key.ts; never an instant, never the UTC day next to a local-day comparison. Why: an instant makes every read zone-dependent, and two day definitions disagree for hours every day.
  3. One entropy source: src/core/rng/sfc32.ts, threaded as state. No Math.random, crypto, expo-crypto, uuid or PRNG package on a seeded path; functions take a state and return { value, state }. Why: a library PRNG promises no frozen sequence across its versions, and only exact integer operations (all sfc32 uses) give the same bits in V8 and in Hermes on iOS and Android.
  4. A seed is a hash plus a frozen salt, mixed by seedRng before any draw. seedRng(dailySeed(key, SALT)) or seedRng(hashSeed(text) ^ SALT); never dailySeed(...) % n. One salt per feature and mode, unique and recorded. Why: FNV seeds of neighbouring days differ by a small multiple of the FNV prime, so an unmixed seed walks the catalog in a visible step; shared salts make two features move together.
  5. Seeded paths, and every file they import, use only exactly specified operations. + - * / %, bitwise, Math.sqrt/imul/floor/round/abs/min/max/PI; no **, no other Math.*, no Intl, toLocale* or localeCompare; floats frozen to integers before they are stored or fingerprinted. Why: ECMA-262 leaves sin, exp, pow and ** implementation-approximated (Hermes uses each platform's libm) and Hermes takes locale data from iOS and Android separately.
  6. Draw order is part of the output. Each generator version documents its draws; inputs are sorted by a stable key (never localeCompare, never unordered SQL); the core modules never change. Why: one extra or reordered draw rewrites every output after it.
  7. Regenerate, never store. Persist the key, the generatorVersion that ran and the user's outcome; regenerate the content with generate(key, storedVersion). Why: a stored copy is a second source of truth that the next version contradicts, and history becomes un-migratable.
  8. A shipped generator is frozen; a change is a new version. A date-keyed generator serves it from a future cutover date; a text-keyed one (a share code) carries the version in the key, and callers pass it. Append the version to the code table and the contract, keep every old version callable, stamp the version in the output; a committed cutover, salt or generator never moves or disappears (removing a whole generator takes a migration, the developer's approval and a "retired" entry). Salt, catalog, count and draw-order changes are all versions. Why: once users saw a key's output, that output is what the key means.
  9. Every generator is pinned by golden vectors. A committed <feature>.vectors.json of (key, version) → fingerprint + summary rows, asserted exactly by a Jest test, covering every version; for date-keyed generators also both sides of each cutover, a year-end/new-year pair and a leap day. Why: a fingerprint catches any change and a summary line shows a reviewer what changed.
  10. Expected values come from an independent oracle. The oracle is written from the spec in another language or style (BigInt TypeScript, Python), imports nothing from src/, directly or through a helper, and carries its own copy of the data. Why: values generated by the implementation under test enshrine its bugs.
  11. Rows are filled by a reviewed hand-run script; committed rows are frozen; CI verifies and never blesses. The update script fills only missing rows and refuses to change a filled one; the checker fails any committed row that changed or vanished, however it was refilled; no CI job, hook or gate script runs the script. Why: a refilled row erases the record of what users saw, and a gate that rewrites what it checks verifies nothing.
  12. The core is a compatibility contract pinned by literals. sfc32.ts, seeded-draws.ts, fingerprint.ts, date-key.ts and daily-seed.ts keep the golden values their tests pin as literals computed by the oracle. Why: a change there changes every seeded output on every installed copy, and a golden computed by the module under test can never fail.
  13. Generators are pure and repeatable. A generator loads in plain Node (no React Native, Expo, stores or services), is synchronous, keeps no module state, and returns identical output for the same (key, version) whatever ran before. Why: purity is what lets tests, oracles and checkers run it headlessly, and hidden state makes the second call differ from the first.

Workflow

  1. Decide what is seeded. List every output a user may compare with another user or with their own past (daily content, shuffles, share codes, generated layouts). Read references/determinism-policy.md for what seeded code may use and why.
  2. Copy the core (once per app) from templates/src/core/: rng/sfc32.ts, rng/seeded-draws.ts, rng/fingerprint.ts, dates/date-key.ts, dates/daily-seed.ts, dates/generator-versions.ts, each with its test. Never edit them; read references/seeded-rng-and-seeds.md and references/civil-date-keys.md for the API, the seeding and the goldens. If an app already has a date-key.ts from dates-streaks-and-statistics, keep one copy.
  3. Write the generator from templates/src/features/__FEATURE__/__FEATURE__-generator.ts (replace __FEATURE__ kebab, __FEATURE_PASCAL__, __FEATURE_CONST__): pick a new random salt, freeze the catalog, document the draw order, export generate(key, version?) and summarize(output). Take "today" from clock.today() in the caller (dates-streaks-and-statistics owns today). The template salt 0x2f6b4c1d (795560989 in the contract), the item-01 … item-12 catalog in the generator and the oracle, the generator test's /^item-\d{2}$/ id check and the vectors rows are template values: replace them all before the first release (step 6 refills the rows). A generator keyed by text (a share code) imitates examples/starter-pack-share-code/: "keyKind": "text", every version from: null, and callers always pass the version the code carries.
  4. Register it in contracts/seeded-generators.json (template; keys in references/checkers.md): module, exports, salt in decimal, versions, vectors file, outcome file, regenerate command. An app with no generator keeps "generators": [].
  5. Write the oracle from templates/scripts/oracles/__FEATURE__-oracle.ts: reimplement the generator from its spec with BigInt or another language, no import from src/, its own copy of the catalog and salt. Read references/golden-vectors.md.
  6. Pin the vectors. The template rows belong to the template salt and catalog. Before the generator first ships, delete each row's fingerprint and summary (keep key, version and note) and set the contract's salt to the new salt in decimal. Add rows { "key", "version", "note" } (first key, a historical key, year end and new year, leap day), run node scripts/oracles/update-<feature>-vectors.ts from the repo root, and read the diff. A committed row is never refilled: check-golden-vectors.mjs fails it (rule vectors-frozen; the template's seven rows are the one exception). Keep the vectors test and the oracle test from the templates.
  7. Store outcomes, not content. Persist (key, generatorVersion, outcome) as examples/daily-focus-outcomes/ shows: the repository port and row mapping in src/data/<feature>/, the replay in src/features/<feature>/. persistence-and-migrations owns the table. When the table exists, point the contract's outcome.file at the file that declares the stored row. Read references/versioning-and-regeneration.md.
  8. To change a shipped generator, follow "Shipping a new version, step by step" in references/versioning-and-regeneration.md and imitate examples/daily-focus-cutover/: new version, future cutover, new oracle branch, new rows only. check-golden-vectors.mjs fails a new cutover that starts today or earlier, and any edit of what is committed (git HEAD): a moved or deleted cutover, a changed salt, a removed generator, a changed or deleted vectors row. In CI pass the base branch: --committed-ref origin/main.
  9. Test: npx jest src/core src/features/<feature> scripts/oracles --ci --selectProjects unit and npm run typecheck.
  10. Run the checks: node ${CLAUDE_SKILL_DIR}/scripts/check-determinism.mjs . and node ${CLAUDE_SKILL_DIR}/scripts/check-golden-vectors.mjs . (in a workspaces repo, once per app, with the app folder as the root); fix every FAIL line (file, rule, fix) and rerun until both print RESULT: PASS.
  11. Commit vectors and contract changes with a Gate-Change: trailer (git-commits-and-reporting) naming the generator, the version and why; a changed row is never committed.

Definition of done

  • Every seeded output has a generator that takes its key as an argument and reads no clock, ambient randomness or locale data; clock.today() is read once at the edge.
  • The core files are the templates, unedited, with their tests pinning the oracle's goldens.
  • No template value is left before the first release: own salt (in code, oracle and contract), own catalog, own id check in the generator test, rows refilled by the oracle (the checker's template values still in use note no longer prints).
  • Each generator is registered with a unique frozen salt, its versions table, vectors file, outcome file (or null) and regenerate command.
  • Each vectors file was filled by the oracle (not the generator) and covers every version (date keys: both sides of each cutover, a year-end/new-year pair, a leap day); no committed row changed or vanished (vectors-frozen; without git the checker prints a frozen checks: note instead).
  • Stored outcomes carry the key and generatorVersion, never the generated content.
  • No CI job, hook or gate script runs a regenerate command.
  • The core, generator, vectors and oracle tests pass; npm run typecheck is clean.
  • node ${CLAUDE_SKILL_DIR}/scripts/check-golden-vectors.mjs . prints RESULT: PASS (allowed while the outcome table does not exist yet: SKIP src/data/<feature>/<feature>-outcome-repository.ts [outcome-version] not yet created (persistence-and-migrations creates it); an app without generators prints NOT APPLICABLE: contracts/seeded-generators.json declares no seeded generators).
  • node ${CLAUDE_SKILL_DIR}/scripts/check-determinism.mjs . prints RESULT: PASS

Anti-patterns

  • new Date().toISOString().slice(0, 10) inside a generator. The UTC day, read from a clock: not reproducible and wrong for hours every day. Take the key from clock.today() as an argument.
  • CATALOG[dailySeed(key, SALT) % CATALOG.length]. Neighbouring days step through the catalog by a fixed amount. drawOne(CATALOG, seedRng(dailySeed(key, SALT))).
  • Math.random() for a harmless tie-break. One call and no two devices agree. Draw it from the threaded state.
  • Sorting with localeCompare before drawing. iOS, Android and Jest order differently. Compare ids with <.
  • Fixing a typo in a shipped catalog. Every past key that drew it changes. Ship version 2 with a future cutover.
  • Storing the generated list next to the outcome. Two sources of truth. Store the version and regenerate.
  • Pasting the generator's output into the vectors file, or const GOLDEN = hashSeed(...) in a test. The expected value then agrees with every bug. Compute it with the oracle.
  • Deleting a shipped row's values and re-running the update script to make a red test green. That rewrites history users saw. Restore the version that made the row. Refilling is only for rows no released build has served, such as the template rows after you set your own salt.
  • npm run vectors:update (or pnpm vectors:update, or a script that calls it) in verify, CI, an EAS Workflow or a hook (simple-git-hooks, lint-staged, husky). The gate passes by construction. Run it by hand and review the diff.
  • Moving a committed cutover to "fix" a date, or editing a committed salt in code and contract together. Both still agree, so only the committed contract shows the rewrite. Append a version with a future date instead.
  • Deleting or renaming a shipped generator's entry to get past a red check. Stored outcomes and share codes still point at it. A rename is compared with the committed entry; a removal needs a migration, the developer's approval and a "retired" entry.
  • A share-code generator that defaults to its newest version. A version 1 code then opens as a version 2 pack. Pass the version the code carries.
  • A helper in src/utils/ that a generator imports and that reads Date.now() or calls Math.exp. check-determinism.mjs follows the generator's imports into it and names the chain; a package from node_modules (a library shuffle) is caught only by the runtime trap. Pass the value in, and use integer tables instead of inexact maths.

Files in this skill

File What it is Read/run when
references/determinism-policy.md Seeded paths, allowed operations and the spec/Hermes evidence, clocks, randomness, locale, ordering, floats, iOS/Android/Jest table, who enforces what, pitfalls Workflow step 1, and when a lint or scan rule fires
references/seeded-rng-and-seeds.md sfc32 and its API, hash + salt + mix, the unmixed-seed trap in numbers, salts, threading, seeded draws, per-index hashing, the UI thread, the goldens and the Python oracle that computed them Workflow steps 2 and 3
references/civil-date-keys.md DateKey, its integer arithmetic, one day definition, routing by key, the daily seed, pinned facts and their oracle, device behaviour Workflow steps 2 and 3
references/golden-vectors.md The vectors file, canonical JSON v1, coverage, the independent oracle, the fill-only update script, replacing the template rows before the first release, CI verifies, reading a diff, labels over metrics, a Python generator oracle Workflow steps 5 and 6
references/versioning-and-regeneration.md What to store and where (the outcome layout), replay by version, what counts as a change, the cutover procedure, choosing the date under store releases and OTA, text keys and share codes, retiring a generator Workflow steps 7 and 8
references/checkers.md Both checkers' rules, the contract format, the committed baseline (--committed-ref in CI, renames, "retired"), layout options, multi-package use, expected SKIP/NOT APPLICABLE lines and notes, troubleshooting Workflow steps 4 and 10
templates/src/core/rng/ sfc32.ts, seeded-draws.ts, fingerprint.ts and their tests (synced, canonical) Workflow step 2
templates/src/core/dates/ date-key.ts, daily-seed.ts, generator-versions.ts and their tests (synced, canonical) Workflow step 2
templates/src/features/__FEATURE__/ The generator scaffold, its test, its vectors file and the vectors test Workflow steps 3 and 6
templates/scripts/oracles/ The BigInt oracle, its test (CI checks the committed rows) and the fill-only update script Workflow steps 5 and 6
templates/contracts/seeded-generators.json The generator register both checkers read Workflow step 4
examples/daily-focus-cutover/ The Tasks app's daily-focus generator after a version 2 cutover: generator, vectors, oracle, contract Workflow step 8
examples/daily-focus-outcomes/ Storing (key, generatorVersion, outcome): the repository port, the row schema and mapping (src/data/), and the replay that regenerates what the user saw (src/features/) Workflow step 7
examples/starter-pack-share-code/ A text-keyed generator (share codes v2:7KQ2): versions without cutover dates, explicit version on every call, a Python oracle with verify and --fill modes, its vectors Workflow step 3, when the key is not a date
examples/reminder-jitter.ts Stateless per-id randomness with hashU32 (reminder offsets) When each item needs its own stable random value
scripts/check-determinism.mjs Runs the core against an independent implementation and the goldens; scans seeded paths and every local file they import Workflow step 10, and at the end
scripts/check-golden-vectors.mjs Runs every registered generator against its vectors with ambient APIs and inexact maths trapped; checks the register, what is committed (generators, salts, cutovers, vectors rows), coverage, oracle, test, outcome, no auto-bless Workflow step 10, and at the end
scripts/lib/determinism-spec.mjs The checkers' BigInt/Date.UTC implementation of the core, the golden table and the Math allow-list When a golden, a core rule or the allow-list changes
scripts/lib/module-graph.mjs The checkers' import reader: what a file loads (static, import(), require(), type-only), every local file a set of files reaches, and the enclosing workspaces repo of an app folder When a checker follows imports
scripts/lib/app-modules.mjs Shared helper (synced; do not edit here): imports the app's TypeScript for the runtime checks Never by hand
scripts/lib/source-scan.mjs Shared helper (synced; do not edit here): the source scanner (masking, imports, worklet directive) Never by hand
scripts/check-lib.mjs Shared script library (synced; do not edit here) Never by hand
scripts/selftest.mjs Proves both checkers pass the templates and catch every planted bug After changing a checker, template or example
assets/shared.json Declares the synced files (check-lib, helpers, the canonical core) When adding a shared file
assets/template-placeholders.json The placeholder values the template verification uses When adding a placeholder
tests/bases/app/ The minimal app the fixtures lay the templates on When the fixture base changes
tests/fixtures/ Overlay suites: good/, one bad-* per rule, pass-* and error-* cases for both checkers When adding a checker rule

Related skills

  • dates-streaks-and-statistics - reading today, local midnight, DST, streaks and statistics over date keys.
  • architecture-and-boundaries - the ClockPort and where pure core code lives.
  • persistence-and-migrations - the outcome table and retiring a generator version.
  • golden-and-snapshot-tests - data snapshots and pixel goldens; run-goldens-rebaseline re-blesses those.
  • unit-and-component-tests - the Jest projects, fast-check and the pinned TZ=UTC.
  • typescript-and-lint-rules - the DETERMINISTIC and PURE lint blocks.
  • value-objects-money-and-units - integers instead of floats for stored values.
  • i18n-strings-and-catalogs - Intl formatting at the edge, never on a seeded path.
  • git-commits-and-reporting - the Gate-Change: trailer for vectors and contract changes.
  • ota-updates - runtime versions and rollouts that decide when a cutover is safe.
  • game-rules-engine, level-generation-and-solvers, realtime-game-loop - seeded games built on this core.