From 77aa3383c3adb52b05bd52b09f26c908bde35089 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Nov 2026 14:03:00 -0700 Subject: [PATCH 001/168] test(oracles): add oracleProperty, a gap report, and LocalStorage write-order and peer properties - packages/db/tests/oracle-property.ts: one constructor for an oracle's law, histories, model, production driver, and check. The driver only records observations; the check alone returns a violation, which fails the history with the law, checkpoint, difference, authority, limits, and a rerun command. - oracle/no-unit-test: an oracleProperty call from oracle-property with a literal check is its own declaration; any other call reports incompleteProperty. part-reach-oracle generates these calls and three lookalike modules (RED on the old rule; three mutants killed). - local-storage.write-order: every history of up to three updates and deletes, every handler completion order, and every decision vector (1,448). It replaces the two-write loops in local-storage-order-oracle and detects every mutant they detected, plus one. - local-storage.peers: same-tab peers, another tab, storage events, held writes, manual acceptance, handler and custom-parser writes, clearStorage from any Collection, number keys, and reload (19,289 histories). A write reads storage for its whole tab (maintainer decision D43). - scripts/oracle-gaps: a mutation-based gap report. Mutant schemata, warm Vitest contexts, per-site hits, typed kill classification, and differential observation that separates comparison gaps from generator gaps. Against src/local-storage.ts mutants the LocalStorage oracles now detect 201 of 320 (153 before), and 70 of 131 in the peer and event functions (36 before). Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/tests/local-storage-fixtures.ts | 5 +- .../tests/local-storage-order-oracle.test.ts | 185 +------ .../tests/local-storage-peers-oracle.test.ts | 519 ++++++++++++++++++ .../local-storage-write-order-oracle.test.ts | 330 +++++++++++ packages/db/tests/oracle-property.ts | 162 ++++++ .../part-reach-oracle.property.test.js | 156 +++++- .../rules/no-unit-test.js | 56 ++ scripts/oracle-gaps/capture-setup.mjs | 245 +++++++++ scripts/oracle-gaps/index.mjs | 488 ++++++++++++++++ scripts/oracle-gaps/schemata.mjs | 143 +++++ 11 files changed, 2103 insertions(+), 188 deletions(-) create mode 100644 packages/db/tests/local-storage-peers-oracle.test.ts create mode 100644 packages/db/tests/local-storage-write-order-oracle.test.ts create mode 100644 packages/db/tests/oracle-property.ts create mode 100644 scripts/oracle-gaps/capture-setup.mjs create mode 100644 scripts/oracle-gaps/index.mjs create mode 100644 scripts/oracle-gaps/schemata.mjs diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3b5412b255..8fdfa3b984 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1882,7 +1882,7 @@ generic visitor behavior outside the named collectors. | Live-query notification algebra | [real-query and checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/notification-laws-oracle.test.ts), [independent Map checker](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts) | One-key synchronous source histories check exact live-query callback reconstruction and unsubscribe silence. Synthetic multi-key histories calibrate the checker; direct source Collection events remain with the change-event history owner. | | Flat-row change tracking | [flat change tracking oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/flat-change-tracking-oracle.property.test.ts) | Generated callbacks over flat rows, frozen or not and with or without a non-enumerable field (assignments of `0`, `-0`, `NaN`, `undefined`, functions, and fresh objects; reverts; deletions; added fields; data and accessor `defineProperty`; stored drafts; throws), run through the flat tracker, the draft proxy, and an independent model, which must report the same change sets with `0` and `-0` equal, or throw the same error and leave the rows unchanged. Defining a field acts as assigning it, and a non-enumerable field is row data only once written; the proxy broke three of those laws on `main` and the flat tracker one. Diffs using `!==`, `Object.is` alone, or no deletions fail it. It found that the proxy dropped a field added as `undefined` when another field reverted; the pinned history and both campaigns fail without that fix. Non-flat rows must fall back. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | | Virtual props cache | [cache laws](https://github.com/TanStack/db/blob/main/packages/db/tests/virtual-props-cache.test.ts) | Focused laws for the per-key cache of rows read with virtual props: a change's published value is the row later `get` and `toArray` reads return, for every subscriber, after sync and local writes, and a key that is deleted or rolled back leaves the cache. Enriching the value before the previous value fails the first two; skipping the drop on delete events fails the third. Rows read with virtual props through other paths, such as joins, are outside these laws. | -| LocalStorage Collection | [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | Finite two-write histories compare authored same/disjoint-key updates, both handler completion orders, and all four application-decision pairs against public rows, durable rows, caller settlement, and fresh restore. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | +| LocalStorage Collection | [write order over every short history](../../packages/db/tests/local-storage-write-order-oracle.test.ts) (`local-storage.write-order`), [peers over every short history](../../packages/db/tests/local-storage-peers-oracle.test.ts) (`local-storage.peers`), [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | The `local-storage.write-order` property runs every history of one to three automatic updates and deletes of two stored rows (1,448 histories), every handler completion order, and every decision vector. After each handler finishes, no accepted write may have settled ahead of an undecided predecessor, and storage may hold only the author-order fold of part of the released prefix; a rejected write may settle as soon as its own handler fails. At settlement, outcomes, durable rows, public rows, and a fresh restore equal the fold of the accepted writes. Against `src/local-storage.ts` mutants it detects every mutant that the finite two-write loops it replaced detected (63), plus one. Inserts are excluded: an insert after a rejected delete of the same key is undecided behaviour. The `local-storage.peers` property runs every history of up to three actions (19,289 histories) across Collections A and B in one tab and C in another tab over the same bytes. Actions are direct, manual-acceptance, handler, and custom-parser writes of string keys and the number key 7, a held update by A, `clearStorage()` from any Collection, `reload()` of C, and delivery of pending storage events. After each action, stored rows equal the model's read-modify-write fold. Every Collection in the writing tab shows the stored rows, because a write reads storage for its whole tab (maintainer decision D43, 2026-11-02, raised by three comparison gaps in the gap report). The holder's own pending row overlays only the holder, and while it is held the holder may show its earlier view of other keys (glossary, accepted sync transaction). The other tab shows a changed row once its event arrives or it reloads. A fresh Collection restores the stored rows. Against `src/local-storage.ts` mutants the LocalStorage oracles detect 201 of 320 with this property and 153 without it; detections in the peer and event functions rise from 36 to 70 of 131. Excluded: write races between tabs on one key, value-normalizing parsers, startup errors, storage faults, and cleanup. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | | LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `when('settled')` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Pinned witnesses cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A pinned witness per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, pinned in `local-only.test.ts`. | | Local-only direct writes | [direct write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) | Focused witnesses pin when a local-only direct write skips the optimistic stage: a completed transaction and one publication per write, and the fallbacks for a pending, persisting, or ambient transaction and a user handler, for insert, update, and delete. Each history also runs on a local-only Collection whose handlers resolve, which must match its rows, change batches, errors, and transaction states, across mixed multi-key batches, a batch that fails midway, schema rejection, and handler rollback. Mutants that drop each guard, write only the first mutation, or never complete the transaction fail them. The change-event history oracle drives the direct path through generated histories (about two thousand writes per run); removing the guard, or guarding only persisting transactions, fails these witnesses. | | Reused sync row objects | [reused row witness](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-reused-row.test.ts) | Focused witnesses for a sync source that changes a stored row object in place: in development, writing it again as an update without `previousValue` throws, while the same write with `previousValue`, or with a new object, moves the row out of an `eq` live query. Production skips the check. The check compares a shallow snapshot taken when the object was written, so it misses changes to nested fields; a write that names `previousValue` and a row whose fields throw on read are not checked. | diff --git a/packages/db/tests/local-storage-fixtures.ts b/packages/db/tests/local-storage-fixtures.ts index 225bd169fc..9d6d4d82f3 100644 --- a/packages/db/tests/local-storage-fixtures.ts +++ b/packages/db/tests/local-storage-fixtures.ts @@ -1,5 +1,6 @@ -// Shared fixtures for the LocalStorage write-order tests. The law and its -// generated histories are owned by local-storage-order-oracle.test.ts. +// Shared fixtures for the LocalStorage write-order tests. The law is stated in +// local-storage-order-oracle.test.ts; its enumerated histories run in +// local-storage-write-order-oracle.test.ts. import { expect } from 'vitest' export type Row = { id: string; value: number } diff --git a/packages/db/tests/local-storage-order-oracle.test.ts b/packages/db/tests/local-storage-order-oracle.test.ts index 6b21328620..3c650da186 100644 --- a/packages/db/tests/local-storage-order-oracle.test.ts +++ b/packages/db/tests/local-storage-order-oracle.test.ts @@ -8,12 +8,11 @@ * * The independent model folds accepted whole-row updates in author order. It * does not model handlers, optimistic overlays, storage caches, or sync queues. - * The legal histories below start with two stored rows and submit two updates - * without waiting. They vary same/disjoint keys, handler completion order, - * and both application decisions for each update. - * At the final settlement checkpoint, public rows, durable rows, and a fresh - * Collection must equal that fold. The held-handler checkpoint verifies that - * no later mutation reports persistence ahead of its undecided predecessor. + * Every short history of automatic updates and deletes, with every handler + * completion order and decision, runs as the `local-storage.write-order` + * property in `local-storage-write-order-oracle.test.ts`. That property checks + * the held-handler law after each handler finishes, and the settled law at the + * end. The witnesses below cover the histories that it excludes. * A handler may admit a nested automatic mutation without awaiting it. Awaiting * that mutation's persistence or a later manual acceptance from the earlier * handler would form a cycle under the order law; the guide excludes both. @@ -33,7 +32,7 @@ * covers direct and queued success and Storage failure; it does not make * several distinct storage keys one atomic transaction. */ -import { describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' import { createCollection } from '../src/collection/index' import { DbClient, collectionOptions } from '../src/client' import { createDeferred } from '../src/deferred' @@ -277,175 +276,3 @@ it('persists a direct write at return after a synchronous handler failure', asyn () => [() => collection.cleanup()], ) }) - -// This finite grammar covers same-key and disjoint-key updates and both handler -// completion orders and all decision pairs. It excludes peer writes and -// manual transactions; those require different event and ownership boundaries. -for (const secondId of ['a', 'b']) { - for (const completion of ['first', 'second']) { - for (const accepted of [ - [true, true], - [true, false], - [false, true], - [false, false], - ]) { - describe(`two ${secondId === 'a' ? 'same-key' : 'disjoint-key'} writes, ${completion} handler first, decisions ${accepted.join('/')}`, () => { - it('persists and publishes the authored result', async () => { - const storage = makeSeededStorage() - const gate = [createDeferred(), createDeferred()] - const entered = [createDeferred(), createDeferred()] - const edits: Array = [ - { id: 'a', value: 1 }, - { id: secondId, value: 2 }, - ] - const makeCollection = (withHandler: boolean) => - createCollection( - localStorageCollectionOptions({ - storageKey: 'rows', - storage, - storageEventApi: { - addEventListener() {}, - removeEventListener() {}, - }, - getKey: (row) => row.id, - ...(withHandler - ? { - onUpdate: async ({ - transaction, - }: { - transaction: { mutations: Array<{ modified: Row }> } - }) => { - const index = - transaction.mutations[0]!.modified.value - 1 - entered[index]!.resolve() - await gate[index]!.promise - }, - } - : {}), - }), - ) - const collection = makeCollection(true) - let reopened: ReturnType | undefined - await withHistoryCleanup( - async () => { - await collection.preload() - const transactions = edits.map((edit) => - collection.update(edit.id, (draft) => { - draft.value = edit.value - }), - ) - const settled = [false, false] - const outcomes = transactions.map((tx, index) => - tx.when(`settled`).then( - () => { - settled[index] = true - return true - }, - () => { - settled[index] = true - return false - }, - ), - ) - await Promise.all(entered.map((item) => item.promise)) - const first = completion === 'first' ? 0 : 1 - if (accepted[first]) gate[first]!.resolve() - else gate[first]!.reject(new Error(`handler ${first} rejected`)) - await Promise.resolve() - await Promise.resolve() - if (first === 1) { - expect( - settled[1], - 'later persistence waits for the earlier decision', - ).toBe(false) - expect(storedRows(storage.getItem('rows'))).toEqual(initial) - } - if (accepted[1 - first]) gate[1 - first]!.resolve() - else - gate[1 - first]!.reject( - new Error(`handler ${1 - first} rejected`), - ) - expect(await Promise.all(outcomes)).toEqual(accepted) - const expected = expectedRows(edits, accepted) - expect(storedRows(storage.getItem('rows'))).toEqual(expected) - expect(sortedRows(collection.values())).toEqual(expected) - reopened = makeCollection(false) - await reopened.preload() - expect(sortedRows(reopened.values())).toEqual(expected) - }, - () => [ - () => gate.forEach((item) => item.resolve()), - () => reopened?.cleanup(), - () => collection.cleanup(), - ], - ) - }) - }) - } - } -} - -// An update followed by a delete distinguishes ordering from a simple -// last-value replacement. The same authored fold removes the row if the -// delete is accepted, even when its handler completes before the update. -for (const accepted of [ - [true, true], - [true, false], - [false, true], - [false, false], -]) { - it(`keeps an update followed by delete in author order with decisions ${accepted.join('/')}`, async () => { - const storage = makeSeededStorage() - const gate = [createDeferred(), createDeferred()] - const entered = [createDeferred(), createDeferred()] - const collection = createCollection( - localStorageCollectionOptions({ - storageKey: 'rows', - storage, - storageEventApi: { addEventListener() {}, removeEventListener() {} }, - getKey: (row) => row.id, - onUpdate: async () => { - entered[0]!.resolve() - await gate[0]!.promise - }, - onDelete: async () => { - entered[1]!.resolve() - await gate[1]!.promise - }, - }), - ) - await withHistoryCleanup( - async () => { - await collection.preload() - const update = collection.update('a', (draft) => { - draft.value = 1 - }) - const deletion = collection.delete('a') - const outcomes = [update, deletion].map((tx) => - tx.when(`settled`).then( - () => true, - () => false, - ), - ) - await Promise.all(entered.map((item) => item.promise)) - if (accepted[1]) gate[1]!.resolve() - else gate[1]!.reject(new Error('delete rejected')) - await Promise.resolve() - await Promise.resolve() - expect(storedRows(storage.getItem('rows'))).toEqual(initial) - if (accepted[0]) gate[0]!.resolve() - else gate[0]!.reject(new Error('update rejected')) - expect(await Promise.all(outcomes)).toEqual(accepted) - const expected = accepted[1] - ? [{ id: 'b', value: 0 }] - : expectedRows([{ id: 'a', value: 1 }], [accepted[0]!]) - expect(storedRows(storage.getItem('rows'))).toEqual(expected) - expect(sortedRows(collection.values())).toEqual(expected) - }, - () => [ - () => gate.forEach((item) => item.resolve()), - () => collection.cleanup(), - ], - ) - }) -} diff --git a/packages/db/tests/local-storage-peers-oracle.test.ts b/packages/db/tests/local-storage-peers-oracle.test.ts new file mode 100644 index 0000000000..069b0203b7 --- /dev/null +++ b/packages/db/tests/local-storage-peers-oracle.test.ts @@ -0,0 +1,519 @@ +/** + * LocalStorage Collections that share a storage key agree with storage: same- + * tab peers at once, other tabs when their storage events arrive. + * + * The LocalStorage guide ("Synchronization") promises: + * + * - Two active Collections in one tab that use the same `storage` object and + * `storageKey` sync without a browser event. A successful write or + * `clearStorage()` updates both public snapshots. + * - A Collection's pending optimistic rows stay visible until its own + * mutations settle. A pending mutation can stay visible through a + * `clearStorage()` and later persist. + * - Each write stores a complete snapshot and preserves rows that other tabs + * already wrote, even before their events arrive. + * - Other tabs see accepted changes when their storage events arrive. The + * same-tab mechanism does not connect distinct Storage objects. + * + * This property runs every history of up to three actions across three + * Collections: A and B in one tab, C in another tab over the same bytes. After + * each action it compares every Collection's public rows and the stored rows + * with an independent model. At the end, a fresh Collection must restore the + * stored rows. + * + * Keys are the strings a, b, and c and the number 7, so both key encodings + * cross between tabs ("New writes encode string and number keys with a type + * prefix"). C can also `reload()`, which must show the stored rows even when + * no storage event arrived (coverage map, LocalStorage reload witnesses). + * + * Write paths. A writes through automatic handlers and holds one update. B + * writes directly or through manual acceptance (`utils.acceptMutations`). C + * uses a custom parser and has insert, update, and delete handlers, so its + * writes take the handler path and the parser's read-back path. Any of the + * three can call `clearStorage()`. + * + * Limits. Values are numbers, so value normalization is not exercised. Only A + * has a held handler, and at most one write is held at a time. A Collection + * never writes a key whose presence it disagrees with storage about (a write + * race between tabs, which the guide says is not atomic). Only C reloads. + * Startup errors, storage faults, value-normalizing parsers, and cleanup are + * outside this property. + */ +import { createDeferred } from '../src/deferred' +import { createCollection } from '../src/collection/index' +import { localStorageCollectionOptions } from '../src/local-storage' +import { createTransaction } from '../src/transactions' +import { createHost } from './local-storage-peer-fixtures' +import { withHistoryCleanup } from './optimistic-history-oracle' +import { oracleProperty, pass, violation } from './oracle-property' + +// -- Histories ------------------------------------------------------------------ +// +// Tab 1 holds A and B; tab 2 holds C. Stored rows start as a=0 and b=0. An +// action is a direct write by one Collection (update or delete of a stored +// key, or insert of key c), a held update of key a by A that a later action +// accepts or rejects, `clearStorage()` from A, or the delivery of a tab's +// pending storage events. The grammar generates only actions that are legal +// in the model's state: a write needs its key's presence to agree between the +// writer's view and storage, A holds at most one write and writes nothing else +// while it is held, and a delivery needs a pending event. + +type Collection = `A` | `B` | `C` +type Key = `a` | `b` | `c` | `7` +type Action = + | { + kind: `write` + by: Collection + op: `update` | `delete` | `insert` + key: Key + manual?: true + } + | { kind: `hold` } + | { kind: `settle`; accept: boolean } + | { kind: `clear`; by: Collection } + | { kind: `deliver`; tab: 1 | 2 } + | { kind: `reload` } +type History = ReadonlyArray +type Rows = Record + +const MAX_ACTIONS = 3 +const tabOf = (collection: Collection) => (collection === `C` ? 2 : 1) +const valueOf = (step: number, by: Collection) => + (step + 1) * 10 + [`A`, `B`, `C`].indexOf(by) + +// -- Model ---------------------------------------------------------------------- +// +// The model keeps the stored rows, and for each tab the keys that the other +// tab changed since this tab last read storage. A tab reads storage when its +// storage event is delivered and when any of its Collections writes: a write +// picks up every row currently in storage, for every Collection in the +// writing tab (maintainer decision D43, 2026-11-02; the gap report's +// comparison gaps raised the question). A tab's public rows are storage, +// except for stale keys, which may show the value they had when the tab last +// read storage, or the stored value, until the tab reads again. +// +// A's held update overlays A's public rows only, until it settles. While it is +// held, sync transactions that reach A are held too, and they apply in the +// publication that drops A's optimistic state (glossary, "accepted sync +// transaction"). So during the hold, A may show either what it showed when the +// hold began or what it would show otherwise, for every other key. Once the +// held write settles, A agrees with storage again. The driver accepts a write +// that is still held when the history ends, so the model does too before the +// fresh restore. + +type Seen = Map> +type State = { + stored: Rows + stale: [undefined, Seen, Seen] + pendingEvents: [undefined, boolean, boolean] + held: { value: number; frozen: Allowed } | undefined +} + +const initialRows: Rows = { a: 0, b: 0 } + +function initialState(): State { + return { + stored: { ...initialRows }, + stale: [undefined, new Map(), new Map()], + pendingEvents: [undefined, false, false], + held: undefined, + } +} + +/** The values a Collection may show, per key. */ +type Allowed = Map> + +function syncedAllowed(state: State, collection: Collection): Allowed { + const stale = state.stale[tabOf(collection)] + const allowed: Allowed = new Map() + for (const key of [`a`, `b`, `c`, `7`]) { + const stored = state.stored[key] + allowed.set(key, stale.has(key) ? [...stale.get(key)!, stored] : [stored]) + } + return allowed +} + +function allowedFor(state: State, collection: Collection): Allowed { + const allowed = syncedAllowed(state, collection) + if (collection === `A` && state.held !== undefined) { + for (const [key, values] of allowed) + allowed.set(key, [...state.held.frozen.get(key)!, ...values]) + allowed.set(`a`, [state.held.value]) + } + return allowed +} + +/** Whether a Collection's tab may believe a key is present. */ +function believed(state: State, collection: Collection, key: string) { + const stale = state.stale[tabOf(collection)] + return stale.has(key) ? [...stale.get(key)!][0] : state.stored[key] +} + +/** + * Records that `tab` wrote `key`. The other tab's view of the key is now stale. + * The writing tab reads storage, so none of its keys is stale (D43). + */ +function changedBy( + state: State, + tab: 1 | 2, + key: string, + before: number | undefined, +) { + const other = tab === 1 ? 2 : 1 + const otherStale = state.stale[other] + if (!otherStale.has(key)) otherStale.set(key, new Set([before])) + state.stale[tab].clear() + state.pendingEvents[other] = true +} + +function apply(state: State, action: Action, step: number): void { + if (action.kind === `write`) { + const before = state.stored[action.key] + if (action.op === `delete`) delete state.stored[action.key] + else state.stored[action.key] = valueOf(step, action.by) + changedBy(state, tabOf(action.by), action.key, before) + } else if (action.kind === `hold`) { + state.held = { value: valueOf(step, `A`), frozen: allowedFor(state, `A`) } + } else if (action.kind === `settle`) { + const held = state.held! + state.held = undefined + if (action.accept) { + const before = state.stored.a + state.stored.a = held.value + changedBy(state, 1, `a`, before) + } + } else if (action.kind === `clear`) { + const tab = tabOf(action.by) + for (const key of Object.keys(state.stored)) + changedBy(state, tab, key, state.stored[key]) + state.stored = {} + state.stale[tab].clear() + } else if (action.kind === `reload`) { + // A reload reads storage; a pending event, once delivered, changes nothing. + state.stale[2].clear() + } else { + state.stale[action.tab].clear() + state.pendingEvents[action.tab] = false + } +} + +function legalActions(state: State): Array { + const actions: Array = [] + for (const by of [`A`, `B`, `C`] as const) { + if (by === `A` && state.held !== undefined) continue + for (const key of [`a`, `b`, `c`, `7`] as const) { + // The writer's view and storage must agree on the key's presence. + const seen = believed(state, by, key) !== undefined + const stored = state.stored[key] !== undefined + if (seen !== stored) continue + const ops = stored + ? ([`update`, `delete`] as const) + : key === `c` || key === `7` + ? ([`insert`] as const) + : [] + for (const op of ops) { + actions.push({ kind: `write`, by, op, key }) + if (by === `B`) + actions.push({ kind: `write`, by, op, key, manual: true }) + } + } + } + if ( + state.held === undefined && + state.stored.a !== undefined && + believed(state, `A`, `a`) !== undefined + ) + actions.push({ kind: `hold` }) + if (state.held !== undefined) { + actions.push({ kind: `settle`, accept: true }) + actions.push({ kind: `settle`, accept: false }) + } + for (const by of [`A`, `B`, `C`] as const) actions.push({ kind: `clear`, by }) + actions.push({ kind: `reload` }) + for (const tab of [1, 2] as const) + if (state.pendingEvents[tab]) actions.push({ kind: `deliver`, tab }) + return actions +} + +function enumerate(): Array { + const histories: Array = [] + const extend = (history: Array) => { + if (history.length > 0) histories.push(history) + if (history.length === MAX_ACTIONS) return + const state = initialState() + history.forEach((action, step) => apply(state, action, step)) + for (const action of legalActions(state)) extend([...history, action]) + } + extend([]) + return histories +} + +/** The allowed observations after each action, and the rows a restore must show. */ +function expectedResult(history: History) { + const state = initialState() + const steps = history.map((action, step) => { + apply(state, action, step) + return { + A: allowedFor(state, `A`), + B: allowedFor(state, `B`), + C: allowedFor(state, `C`), + stored: { ...state.stored }, + } + }) + if (state.held !== undefined) + apply(state, { kind: `settle`, accept: true }, history.length) + return { steps, restored: { ...state.stored } } +} + +// -- Production driver ------------------------------------------------------------ +// +// Two hosts over one byte map are two tabs of one origin (peer fixtures). A +// write by one tab queues a storage event for the other tab; the history +// decides when it is delivered. A's update handler holds only the held write. +// After each action the driver waits for that action's receipts to settle and +// for the event loop to drain, then records every Collection's public rows and +// the stored rows. It records; it does not judge. + +const rowsOf = ( + values: Iterable<{ id: string | number; value: number }>, +): Rows => + Object.fromEntries([...values].map((row) => [String(row.id), row.value])) + +async function drive( + history: History, + observe: (checkpoint: string, value: unknown) => void, +) { + const data = new Map() + data.set( + `rows`, + JSON.stringify( + Object.fromEntries( + Object.entries(initialRows).map(([id, value]) => [ + `s:${id}`, + { versionKey: `initial-${id}`, data: { id, value } }, + ]), + ), + ), + ) + const hosts = [createHost(data), createHost(data)] as const + const pending = [false, false] + // A write through one tab's Storage object is an event for the other tab. + hosts.forEach((host, index) => { + const { setItem, removeItem } = host.storage + host.storage.setItem = (key, value) => { + setItem(key, value) + pending[1 - index] = true + } + host.storage.removeItem = (key) => { + removeItem(key) + pending[1 - index] = true + } + }) + let gate: ReturnType> | undefined + let holdNext = false + // A holds its next update when asked. C's handlers finish at once, and C's + // parser is a custom object, so C takes the handler and read-back paths. + const holdingUpdate = async () => { + if (!holdNext) return + holdNext = false + await gate!.promise + } + const parser = { parse: JSON.parse, stringify: JSON.stringify } + const make = ( + host: (typeof hosts)[number], + id: string, + kind: `holding` | `plain` | `handled`, + ) => + createCollection( + localStorageCollectionOptions<{ id: string | number; value: number }>({ + id, + storageKey: `rows`, + storage: host.storage, + storageEventApi: host.events, + getKey: (row) => row.id, + ...(kind === `holding` ? { onUpdate: holdingUpdate } : {}), + ...(kind === `handled` + ? { + parser, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + } + : {}), + }), + ) + const collections = { + A: make(hosts[0], `A`, `holding`), + B: make(hosts[0], `B`, `plain`), + C: make(hosts[1], `C`, `handled`), + } + let restored: ReturnType | undefined + let heldReceipt: Promise | undefined + const drain = () => new Promise((resolve) => setTimeout(resolve, 0)) + await withHistoryCleanup( + async () => { + await Promise.all(Object.values(collections).map((c) => c.preload())) + for (const [step, action] of history.entries()) { + if (action.kind === `write`) { + const collection = collections[action.by] + const value = valueOf(step, action.by) + // Key 7 is the number 7, so it takes the numeric key encoding. + const id = action.key === `7` ? 7 : action.key + const write = () => + action.op === `insert` + ? collection.insert({ id, value }) + : action.op === `update` + ? collection.update(id, (draft) => { + draft.value = value + }) + : collection.delete(id) + if (action.manual) { + const transaction = createTransaction({ + autoCommit: false, + mutationFn: ({ transaction: accepted }) => + collection.utils.acceptMutations(accepted), + }) + transaction.mutate(write) + await transaction.commit().catch(() => undefined) + } else + await write() + .when(`settled`) + .catch(() => undefined) + } else if (action.kind === `hold`) { + gate = createDeferred() + holdNext = true + const transaction = collections.A.update(`a`, (draft) => { + draft.value = valueOf(step, `A`) + }) + heldReceipt = transaction.when(`settled`).catch(() => undefined) + } else if (action.kind === `settle`) { + if (action.accept) gate!.resolve() + else gate!.reject(new Error(`held write rejected`)) + await heldReceipt + } else if (action.kind === `clear`) { + collections[action.by].utils.clearStorage() + } else if (action.kind === `reload`) { + await collections.C.reload() + } else { + const index = action.tab - 1 + if (pending[index]) { + pending[index] = false + hosts[index]!.deliver(`rows`) + } + } + await drain() + const raw = data.get(`rows`) + observe(`after action ${step + 1}`, { + A: rowsOf(collections.A.values()), + B: rowsOf(collections.B.values()), + C: rowsOf(collections.C.values()), + stored: + raw === undefined + ? {} + : rowsOf( + Object.values( + JSON.parse(raw) as Record< + string, + { data: { id: string; value: number } } + >, + ).map(({ data: row }) => row), + ), + }) + } + gate?.resolve() + await heldReceipt + restored = make(createHost(data), `restored`, `plain`) + await restored.preload() + observe(`fresh restore`, rowsOf(restored.values())) + }, + () => [ + () => gate?.resolve(), + () => restored?.cleanup(), + ...Object.values(collections).map((c) => () => c.cleanup()), + ], + ) +} + +// -- Check ------------------------------------------------------------------------ +// +// After each action, stored rows must equal the model exactly, and each +// Collection's public value for each key must be one the model allows. At the +// end, a fresh Collection restores the stored rows. + +function checkHistory( + history: History, + observations: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +) { + const json = (value: unknown) => JSON.stringify(value) + for (const [step, allowed] of expected.steps.entries()) { + const checkpoint = `after action ${step + 1}` + const observed = observations.find( + (entry) => entry.checkpoint === checkpoint, + )?.value as Record<`A` | `B` | `C` | `stored`, Rows> | undefined + if (observed === undefined) + return violation(checkpoint, `the driver recorded no observation`) + if (json(sortKeys(observed.stored)) !== json(sortKeys(allowed.stored))) + return violation( + checkpoint, + `stored ${json(observed.stored)}, expected ${json(allowed.stored)}`, + ) + for (const collection of [`A`, `B`, `C`] as const) { + const rows = observed[collection] + for (const [key, values] of allowed[collection]) { + if (!values.includes(rows[key])) + return violation( + checkpoint, + `${collection} shows ${key}=${rows[key]}, allowed ${json(values.map((v) => v ?? `absent`))} (stored ${json(observed.stored)})`, + ) + } + for (const key of Object.keys(rows)) + if (!allowed[collection].has(key)) + return violation(checkpoint, `${collection} shows unknown key ${key}`) + } + } + const restored = observations.find( + (entry) => entry.checkpoint === `fresh restore`, + ) + if (restored === undefined) + return violation(`fresh restore`, `the driver recorded no restore`) + if ( + json(sortKeys(restored.value as Rows)) !== json(sortKeys(expected.restored)) + ) + return violation( + `fresh restore`, + `restored ${json(restored.value)}, expected ${json(expected.restored)}`, + ) + return pass +} + +function sortKeys(rows: Rows): Rows { + return Object.fromEntries( + Object.entries(rows).sort(([a], [b]) => a.localeCompare(b)), + ) +} + +const describeAction = (action: Action) => { + if (action.kind === `write`) + return `${action.by}:${action.manual ? `manual-` : ``}${action.op[0]}${action.key}` + if (action.kind === `clear`) return `${action.by}:clear` + if (action.kind === `reload`) return `C:reload` + if (action.kind === `settle`) return action.accept ? `accept` : `reject` + if (action.kind === `deliver`) return `deliver${action.tab}` + return action.kind +} + +oracleProperty({ + id: `local-storage.peers`, + law: { + statement: `Collections sharing a LocalStorage key agree with storage: stored rows follow each write applied to the current snapshot; same-tab peers show stored rows once a write or clearStorage() settles, except their own pending optimistic rows; a write makes every Collection in its tab read storage; another tab shows a row the first tab changed once its storage event arrives.`, + authority: `LocalStorage guide, "Synchronization" (same-tab peers, pending optimistic rows, clearStorage, preserved peer rows, storage events); glossary "accepted sync transaction" (held while an optimistic transaction persists); maintainer decision D43, 2026-11-02 (a write reads storage for its tab)`, + limits: `Up to three actions; numeric values; one held write by A; automatic, manual, handler, and custom-parser write paths and clearStorage() from any Collection; no write races, startup errors, storage faults, reload, or cleanup.`, + owner: `LocalStorage Collection`, + }, + histories: enumerate(), + label: (history) => history.map(describeAction).join(` `), + model: expectedResult, + driver: drive, + check: checkHistory, +}) diff --git a/packages/db/tests/local-storage-write-order-oracle.test.ts b/packages/db/tests/local-storage-write-order-oracle.test.ts new file mode 100644 index 0000000000..2749e86fb8 --- /dev/null +++ b/packages/db/tests/local-storage-write-order-oracle.test.ts @@ -0,0 +1,330 @@ +/** + * LocalStorage automatic writes persist in author order, over every short + * history. + * + * The law is the one that `local-storage-order-oracle.test.ts` states: within + * one Collection, a later authored write must not be replaced by an older + * handler that happens to finish later, and no later write reports persistence + * ahead of an undecided predecessor. A failed later handler still releases its + * own optimistic state promptly. That file checks two writes in a fixed set of + * schedules. This one checks every history of up to three writes, every order + * in which their handlers finish, and every accept/reject decision, and it + * checks the held-handler law after each handler finishes, not only once. + * + * Authority: the public LocalStorage guide's direct, persisted mutations; + * Collection settlement, where a fulfilled `when('settled')` reflects the + * accepted mutation; and the order law and prompt-failure rule in the header + * of `local-storage-order-oracle.test.ts`. + * + * Limits. Writes are whole-row updates and deletes of two stored rows, made + * by one Collection with automatic handlers. Inserts are excluded: an insert + * after a rejected delete of the same key is undecided behaviour. Manual + * acceptance, peer writes, storage faults, and histories longer than three + * writes are outside this property; the order oracle's pinned witnesses and + * the peer tests cover some of them. + */ +import { createCollection } from '../src/collection/index' +import { createDeferred } from '../src/deferred' +import { localStorageCollectionOptions } from '../src/local-storage' +import { + initial, + makeSeededStorage, + sortedRows, +} from './local-storage-fixtures' +import { withHistoryCleanup } from './optimistic-history-oracle' +import { oracleProperty, pass, violation } from './oracle-property' +import type { Row } from './local-storage-fixtures' + +// -- Histories ------------------------------------------------------------------ +// +// A history is a list of writes, the order in which their handlers finish, and +// each handler's decision. A write is legal when it is submitted: an update or +// a delete needs its row to exist in the Collection at that moment, which is +// the authored state with every earlier write applied optimistically. + +type Write = { kind: `update` | `delete`; id: `a` | `b` } +type History = { + writes: ReadonlyArray + release: ReadonlyArray + accepted: ReadonlyArray +} + +const MAX_WRITES = 3 + +function legalWriteLists(): Array> { + const lists: Array> = [] + const extend = (list: Array, present: Set) => { + if (list.length > 0) lists.push(list) + if (list.length === MAX_WRITES) return + for (const id of [`a`, `b`] as const) { + if (!present.has(id)) continue + extend([...list, { kind: `update`, id }], present) + const remaining = new Set(present) + remaining.delete(id) + extend([...list, { kind: `delete`, id }], remaining) + } + } + extend([], new Set(initial.map((row) => row.id))) + return lists +} + +function permutations(count: number): Array> { + if (count === 0) return [[]] + return permutations(count - 1).flatMap((rest) => + Array.from({ length: count }, (_, at) => [ + ...rest.slice(0, at), + count - 1, + ...rest.slice(at), + ]), + ) +} + +function decisionVectors(count: number): Array> { + if (count === 0) return [[]] + return decisionVectors(count - 1).flatMap((rest) => [ + [...rest, true], + [...rest, false], + ]) +} + +const histories: Array = legalWriteLists().flatMap((writes) => + permutations(writes.length).flatMap((release) => + decisionVectors(writes.length).map((accepted) => ({ + writes, + release, + accepted, + })), + ), +) + +// -- Model ---------------------------------------------------------------------- +// +// The documented effect of an accepted write, in author order: an update +// replaces the whole row with the value it authored (its position plus one), +// and a delete removes the row. A rejected write contributes nothing. The fold +// of the first m writes is what storage may hold once those m writes are +// decided, so the model returns the fold of every prefix as well as the final +// rows. + +function fold(history: History, count: number): Array { + const rows = new Map(initial.map((row) => [row.id, { ...row }])) + history.writes.slice(0, count).forEach((write, index) => { + if (!history.accepted[index]) return + if (write.kind === `update`) + rows.set(write.id, { id: write.id, value: index + 1 }) + else rows.delete(write.id) + }) + return sortedRows(rows.values()) +} + +function expectedResult(history: History) { + return { + outcomes: history.accepted, + rows: fold(history, history.writes.length), + // One fold per prefix length, from no writes to all of them. + prefixRows: Array.from({ length: history.writes.length + 1 }, (_, count) => + fold(history, count), + ), + } +} + +// -- Production driver ------------------------------------------------------------ +// +// One Collection with automatic update and delete handlers over seeded +// storage. The driver submits every write without waiting, holds each handler +// until the history releases it, and releases them in the history's order with +// the history's decision. After each release it lets two microtasks run, then +// records which receipts have settled and what storage holds. At the end it +// records each receipt's outcome, the durable and public rows, and the rows a +// fresh Collection restores. It records; it does not judge. + +function durableRows(storage: ReturnType) { + const raw = storage.getItem(`rows`) + if (raw === null) return null + const records = JSON.parse(raw) as Record + return sortedRows(Object.values(records).map(({ data }) => data)) +} + +async function drive( + history: History, + observe: (checkpoint: string, value: unknown) => void, +) { + const { writes, release, accepted } = history + const storage = makeSeededStorage() + const gates = writes.map(() => createDeferred()) + const entered = writes.map(() => createDeferred()) + // An update names itself by the value it authored. A delete names itself by + // its key: a legal history deletes a key at most once. + const deletes = new Map( + writes.flatMap((write, index) => + write.kind === `delete` ? [[write.id, index] as const] : [], + ), + ) + const hold = (index: number) => { + entered[index]!.resolve() + return gates[index]!.promise + } + type Mutations = { mutations: Array<{ modified: Row; original: Row }> } + const makeCollection = (withHandlers: boolean) => + createCollection( + localStorageCollectionOptions({ + storageKey: `rows`, + storage, + storageEventApi: { addEventListener() {}, removeEventListener() {} }, + getKey: (row) => row.id, + ...(withHandlers + ? { + onUpdate: ({ transaction }: { transaction: Mutations }) => + hold(transaction.mutations[0]!.modified.value - 1), + onDelete: ({ transaction }: { transaction: Mutations }) => + hold(deletes.get(transaction.mutations[0]!.original.id)!), + } + : {}), + }), + ) + const collection = makeCollection(true) + let reopened: ReturnType | undefined + await withHistoryCleanup( + async () => { + await collection.preload() + const transactions = writes.map((write, index) => + write.kind === `update` + ? collection.update(write.id, (draft) => { + draft.value = index + 1 + }) + : collection.delete(write.id), + ) + const settled = writes.map(() => false) + const outcomes = transactions.map((transaction, index) => + transaction.when(`settled`).then( + () => ((settled[index] = true), true), + () => ((settled[index] = true), false), + ), + ) + await Promise.all(entered.map((item) => item.promise)) + for (const [step, index] of release.entries()) { + if (accepted[index]) gates[index]!.resolve() + else gates[index]!.reject(new Error(`handler ${index} rejected`)) + await Promise.resolve() + await Promise.resolve() + observe(`held after release ${step + 1}`, { + released: release.slice(0, step + 1), + settled: [...settled], + durable: durableRows(storage), + }) + } + observe(`settled`, { + outcomes: await Promise.all(outcomes), + durable: durableRows(storage), + public: sortedRows(collection.values()), + }) + reopened = makeCollection(false) + await reopened.preload() + observe(`fresh restore`, sortedRows(reopened.values())) + }, + () => [ + () => gates.forEach((gate) => gate.resolve()), + () => reopened?.cleanup(), + () => collection.cleanup(), + ], + ) +} + +// -- Check ------------------------------------------------------------------------ +// +// After each release, the released prefix is the longest run of writes, in +// author order, whose handlers have all finished. An accepted write outside it +// must not have settled: it would report persistence ahead of an undecided +// predecessor. A rejected write may settle as soon as its own handler fails. +// Storage may lag, but it may only hold the fold of some part of the released +// prefix. At settlement, the outcomes are the decisions, and storage, the +// public rows, and a fresh restore all equal the fold of every write. + +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) + +function checkHistory( + history: History, + observations: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +) { + const count = history.writes.length + const seen = new Set(observations.map(({ checkpoint }) => checkpoint)) + for (const checkpoint of [`settled`, `fresh restore`]) { + if (!seen.has(checkpoint)) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}" observation`, + ) + } + for (const { checkpoint, value } of observations) { + if (checkpoint.startsWith(`held`)) { + const { released, settled, durable } = value as { + released: Array + settled: Array + durable: Array | null + } + let prefix = 0 + while (prefix < count && released.includes(prefix)) prefix += 1 + for (let index = prefix; index < count; index += 1) { + if (settled[index] && history.accepted[index]) + return violation( + checkpoint, + `accepted write ${index} settled while write ${prefix} was undecided`, + ) + } + const allowed = expected.prefixRows.slice(0, prefix + 1) + if (!allowed.some((rows) => same(rows, durable))) + return violation( + checkpoint, + `storage holds ${JSON.stringify(durable)}; only a fold of the first ${prefix} writes is allowed: ${JSON.stringify(allowed)}`, + ) + } else if (checkpoint === `settled`) { + const { + outcomes, + durable, + public: rows, + } = value as { + outcomes: Array + durable: Array | null + public: Array + } + if (!same(outcomes, expected.outcomes)) + return violation( + checkpoint, + `outcomes ${JSON.stringify(outcomes)}, decisions ${JSON.stringify(expected.outcomes)}`, + ) + if (!same(durable, expected.rows)) + return violation( + checkpoint, + `storage ${JSON.stringify(durable)}, expected ${JSON.stringify(expected.rows)}`, + ) + if (!same(rows, expected.rows)) + return violation( + checkpoint, + `public rows ${JSON.stringify(rows)}, expected ${JSON.stringify(expected.rows)}`, + ) + } else if (!same(value, expected.rows)) { + return violation( + checkpoint, + `restored ${JSON.stringify(value)}, expected ${JSON.stringify(expected.rows)}`, + ) + } + } + return pass +} + +oracleProperty({ + id: `local-storage.write-order`, + law: { + statement: `Automatic LocalStorage writes persist in author order: no accepted write settles or reaches storage ahead of an undecided predecessor, and settled storage, public rows, and a fresh restore equal the author-order fold of the accepted writes.`, + authority: `LocalStorage guide (direct, persisted mutations); Collection settlement (when('settled')); order law and prompt-failure rule in local-storage-order-oracle.test.ts`, + limits: `Up to three updates and deletes of two stored rows in one Collection; no inserts, manual acceptance, peers, or storage faults.`, + owner: `LocalStorage Collection`, + }, + histories, + label: ({ writes, release, accepted }) => + `${writes.map((write) => `${write.kind[0]}${write.id}`).join(` `)} | release ${release.join(``)} | decisions ${accepted.map((value) => (value ? `Y` : `N`)).join(``)}`, + model: expectedResult, + driver: drive, + check: checkHistory, +}) diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts new file mode 100644 index 0000000000..11b1c628b1 --- /dev/null +++ b/packages/db/tests/oracle-property.ts @@ -0,0 +1,162 @@ +/** + * Oracle properties: one constructor for an oracle's law, histories, model, + * production driver, and check. + * + * An oracle file used to assemble these parts by hand: `declareOracle`, a loop + * of `it` calls, assertions spread through the driver, and its own cleanup and + * failure text. `oracleProperty` takes the parts and does the mechanics that + * every oracle shares, so an oracle file holds only the parts that state its + * law. + * + * The parts keep their jobs separate (oracle rubric, ORC-003): + * + * - `law` states the promise, its authority, its limits, and its owner in the + * coverage map. A failure names it. + * - `histories` lists every legal history the property runs. This first + * version takes an enumerated source, which runs in full: bounded + * exhaustiveness (glossary). + * - `model` gives the expected result of a history, without production. + * - `driver` runs one history through production and records public + * observations with `observe(checkpoint, value)`. It returns nothing, so it + * cannot decide a verdict (oracle-framework design, decision D10). A + * production error that the law cares about is an observation the driver + * records. An error that escapes the driver is a setup error, never a + * violation. + * - `check` compares the observations with the model and returns a verdict. + * Only the check can report a violation of the law. + * + * Each history is one test, named by `label`. A violation fails it with an + * `OracleViolation` that names the property, the law, the checkpoint, the + * difference, and the command that reruns that history. Every observation is + * also offered to `globalThis.__oracleObserve` when a harness installs one, + * so the gap report can tell an output the check does not compare from one + * that never changed. + * + * The `oracle/no-unit-test` lint rule treats an `oracleProperty` call as an + * oracle test whose parts are its arguments, when its options are an object + * literal with a `check`. The property is its own declaration. + */ +import { appendFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +/** A law and where it comes from. */ +export interface OracleLaw { + /** The promise, stated so that a reader could predict an observation. */ + statement: string + /** The contract document and section, or the maintainer decision. */ + authority: string + /** What this property does not establish. */ + limits: string + /** The coverage-map owner. */ + owner: string +} + +/** One recorded public fact, at the checkpoint where the contract owes it. */ +export interface Observation { + checkpoint: string + value: unknown +} + +/** The check's judgment of one history. */ +export type Verdict = + | { kind: `pass` } + | { kind: `violation`; checkpoint: string; difference: string } + +export const pass: Verdict = { kind: `pass` } + +export function violation(checkpoint: string, difference: string): Verdict { + return { kind: `violation`, checkpoint, difference } +} + +/** The check found a history whose observations the law does not allow. */ +export class OracleViolation extends Error { + override name = `OracleViolation` +} + +export interface OraclePropertyOptions { + /** A stable identifier, used in failures, the manifest, and replay. */ + id: string + law: OracleLaw + /** Every legal history, in a fixed order. */ + histories: ReadonlyArray + /** A short, unique test name for a history. */ + label: (history: History) => string + model: (history: History) => Expected + driver: ( + history: History, + observe: (checkpoint: string, value: unknown) => void, + ) => Promise | void + check: ( + history: History, + observations: ReadonlyArray, + expected: Expected, + ) => Verdict +} + +declare global { + // Installed by the gap report's test setup, absent otherwise. + var __oracleObserve: + ((checkpoint: string, value: unknown) => void) | undefined +} + +export function oracleProperty( + options: OraclePropertyOptions, +): void { + const { id, law, histories, label } = options + recordRegistration(options) + describe(id, () => { + for (const history of histories) { + const name = label(history) + it(name, async () => { + const observations: Array = [] + await options.driver(history, (checkpoint, value) => { + observations.push({ checkpoint, value }) + globalThis.__oracleObserve?.(checkpoint, value) + }) + const verdict = options.check( + history, + observations, + options.model(history), + ) + if (verdict.kind === `violation`) { + throw new OracleViolation( + [ + `${id} violated at checkpoint "${verdict.checkpoint}".`, + `Law: ${law.statement}`, + `Difference: ${verdict.difference}`, + `Authority: ${law.authority}`, + `Known limits: ${law.limits}`, + `Rerun this history: vitest run ${relativeTestFile()} -t ${JSON.stringify(`${id} ${name}`)}`, + ].join(`\n`), + ) + } + }) + } + }) +} + +// A collect-only run (ORACLE_MANIFEST set) records each registration, so the +// committed manifest can be generated without running any history. +function recordRegistration(options: { + id: string + law: OracleLaw + histories: { length: number } +}): void { + const manifest = process.env.ORACLE_MANIFEST + if (manifest === undefined) return + appendFileSync( + manifest, + `${JSON.stringify({ + id: options.id, + file: relativeTestFile(), + owner: options.law.owner, + authority: options.law.authority, + histories: options.histories.length, + })}\n`, + ) +} + +// `expect.getState()` names the test file while it is collected and run. +function relativeTestFile(): string { + return expect.getState().testPath ?? `` +} diff --git a/scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js b/scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js index e78288a9ea..3fe84b0e5a 100644 --- a/scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js +++ b/scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js @@ -12,6 +12,13 @@ * that registers tests of its own gets one report for the call, unless the * helper's module is linted on its own. * + * A call to `oracleProperty` from the framework module `oracle-property` is + * an oracle property: it registers oracle tests whose parts are the functions + * it is given. With an object-literal options argument that has a `check`, it + * reaches a part and is neither a unit test nor an imported registration. Any + * other call to it gets an `incompleteProperty` report. An `oracleProperty` + * from any other module is an ordinary helper that registers its own tests. + * * A binding is reached however it is bound: a named, namespace, or default * import; a `let` that a hook assigns; or a `for...of` loop variable, which * runs one test per element, so every element must reach. A helper that @@ -25,7 +32,8 @@ * reach rules and the report rules, and its laws L1 (reports are a pure * function of the source files), L2 (every registration form is checked), L3 * (every declared part counts), and L6 (an oracle file registers only oracle - * tests, including tests that its imported functions register); + * tests, including tests that its imported functions register), and its + * oracle-property paragraph (maintainer decision, 2026-11-02); * docs/contributing/oracle-rubric.md "Name oracle files consistently", which * says that an oracle-named helper "is linted on its own"; and AGENTS.md "Name * Oracle Files for Discovery". `export *` does not re-export a default export @@ -37,7 +45,9 @@ * built from other models, a `let` assigned more than once, a `for...of` over * a named array, or a predicate or arbitrary from a package. It builds at most * three companion modules, three functions per module, and six registrations - * per oracle file. The receiving paths are a long-lived ESLint instance, a + * per oracle file. Oracle-property options are an inline literal or a const + * name; a spread of options, a computed `check` key, and a default import of + * the framework are not generated. The receiving paths are a long-lived ESLint instance, a * single-file Linter call, the lint CLI on changed files, and the CLI's * edit-hook form. The CLI lints only files inside its repository, so its paths * write scratch files in `part-reach-scratch-*` directories there, which git @@ -169,6 +179,7 @@ const FORMS = [ `prop-named`, `let-driver`, `for-of`, + `framework-property`, ] // How a caller-passed-body helper registers the body: as its argument, with a @@ -203,6 +214,9 @@ const registrationSkeleton = fc.record({ readsInput: fc.boolean(), // The functions that a `for...of` driver runs, one test each. elements: fc.array(fc.nat({ max: 9 }), { minLength: 1, maxLength: 2 }), + // An oracle property's options: a literal with a check and a model, with a + // check only, with no check, or a const name that the rule cannot read. + frameworkOptions: fc.constantFrom(`check-model`, `check`, `model`, `name`), }) const programSkeleton = fc.record({ @@ -218,6 +232,16 @@ const programSkeleton = fc.record({ `oracle-reg-helper.ts`, ), helperImport: fc.constantFrom(`named`, `namespace`), + // The framework module, and modules that export a function of the same + // name: another `oracle-` machinery module, an ordinary module, and an + // oracle-named module, which is linted on its own. + frameworkName: fc.constantFrom( + `oracle-property.ts`, + `oracle-propertyx.ts`, + `property.ts`, + `property-oracle.ts`, + ), + frameworkImport: fc.constantFrom(`named`, `namespace`), oracle: fc.record({ module: moduleSkeleton, objectRefs: fc.array(refSkeleton, { maxLength: REFS }), @@ -242,6 +266,8 @@ const BASE = { helperNames: false, spreads: false, jsCompanions: false, + // Off: every oracle property comes from the framework module, by name. + frameworkNames: false, } const ALL = { companions: true, @@ -256,12 +282,17 @@ const ALL = { helperNames: true, spreads: true, jsCompanions: true, + frameworkNames: true, } /** A module that the lint selection lints as an oracle file. */ const lintedOnItsOwn = (file) => file.includes(`oracle`) && !file.startsWith(`oracle-`) +/** The framework module, whose `oracleProperty` calls are oracle properties. */ +const isFrameworkModule = (file) => + file !== undefined && file.replace(/\.(ts|js)$/, ``) === `oracle-property` + function programArbitrary(features) { return programSkeleton.map((skeleton) => buildProgram(skeleton, features)) } @@ -445,6 +476,20 @@ function buildProgram(skeleton, features) { }), ) } + // The module that exports `oracleProperty`: the framework, or a lookalike. + let frameworkFile + if (tests.some((test) => test.framework !== undefined)) { + frameworkFile = features.frameworkNames + ? skeleton.frameworkName + : `oracle-property.ts` + const form = features.importForms ? skeleton.frameworkImport : `named` + oracle.imports.set(`oracleProperty`, { + from: frameworkFile, + imported: `oracleProperty`, + form, + }) + modules.set(frameworkFile, emptyModule(frameworkFile, `framework`)) + } // The module that exports the imported predicates, which read their input // or ignore it. if (tests.some((test) => test.property?.predicate === `imported`)) { @@ -474,7 +519,7 @@ function buildProgram(skeleton, features) { }) } } - return { oracle: ORACLE, modules, tests, calls, helperFile } + return { oracle: ORACLE, modules, tests, calls, helperFile, frameworkFile } } // The helper module's exports, in the order HELPER_TEXT defines them. @@ -713,6 +758,9 @@ function buildRegistration(skeleton, index, context) { readsInput: skeleton.readsInput ?? true, } test.body = { kind: `arrow`, refs: [] } + } else if (form === `framework-property`) { + test.framework = { options: skeleton.frameworkOptions ?? `check-model` } + test.body = { kind: `arrow`, refs: [] } } else if (form === `let-driver`) { test.driver = { kind: `let`, refs } test.body = { kind: `arrow`, refs: [] } @@ -785,6 +833,10 @@ function buildRegistration(skeleton, index, context) { // - It runs inside a `for...of` loop, which registers one test per element, // so every element must reach (rubric: every test uses a declared part). // +// - It is an oracle property: a call to `oracleProperty` from the framework +// module whose options are a literal with a `check`. The property is its +// own declaration, so the call reaches by construction. +// // The predicted report list follows the rule's contract. Each unreached // registration is a unit test. A file with no declared part and no reaching // registration gets one `undeclared` report, on its first unreached test. Each @@ -889,13 +941,24 @@ function predictReports(program) { ) return bodyReaches || tableReaches } + // Oracle-property calls are judged by their own rule, below. const judged = program.tests.filter( - (test) => test.helper === undefined || !lintedOnItsOwn(program.helperFile), + (test) => + test.framework === undefined && + (test.helper === undefined || !lintedOnItsOwn(program.helperFile)), + ) + const frameworkTests = program.tests.filter( + (test) => test.framework !== undefined, ) + const fromFramework = isFrameworkModule(program.frameworkFile) + const hasCheck = (test) => + [`check-model`, `check`].includes(test.framework.options) const unreached = judged .filter((test) => !reachesPart(test)) .map((test) => test.title) - const anyReached = unreached.length < judged.length + const anyReached = + unreached.length < judged.length || + (fromFramework && frameworkTests.some(hasCheck)) const declaresParts = oracle.parts.size > 0 || oracle.typeNames.length > 0 const keys = [] @@ -910,6 +973,14 @@ function predictReports(program) { for (const call of program.calls) { if (!lintedOnItsOwn(call.file)) keys.push(`imported:${call.alias}`) } + for (const test of frameworkTests) { + if (fromFramework) { + if (!hasCheck(test)) keys.push(`incomplete:${test.title}`) + } else if (!lintedOnItsOwn(program.frameworkFile)) { + // A lookalike registers its own test, as any imported helper does. + keys.push(`imported:oracleProperty`) + } + } return keys.sort() } @@ -922,6 +993,9 @@ function predictReports(program) { const REGISTRAR_TEXT = `import { it } from 'vitest'\nexport function regAll() { it('reg inner', () => { void 0 }) }\nexport default regAll\n` const BROKEN_TEXT = `export function brokenFn() {\n` +// The framework's and each lookalike's `oracleProperty` registers a test of its +// own, as the real framework does. +const FRAMEWORK_TEXT = `import { it } from 'vitest'\nexport function oracleProperty(options) {\n it(options.id, () => {\n void options\n })\n}\n` const PRED_TEXT = `export function predReads(value) {\n void value\n}\nexport function predIgnores(value) {\n void 0\n}\n` /** @@ -979,6 +1053,8 @@ function renderProgram(program) { files.set(file, helperText(module)) } else if (module.role === `pred`) { files.set(file, PRED_TEXT) + } else if (module.role === `framework`) { + files.set(file, FRAMEWORK_TEXT) } else { const rendered = renderModule(module, program) files.set(file, rendered.text) @@ -1124,6 +1200,11 @@ function renderTest(test, push, use) { push(test.property.readsInput ? ` void value` : ` void 0`) push(`}`) } + if (test.framework?.options === `name`) { + push( + `const opts${test.title} = { id: '${test.title}', check: () => {\n void 0\n} }`, + ) + } if (test.driver?.kind === `let`) { push(`let drv${test.title}`) push(`beforeAll(() => {`) @@ -1140,7 +1221,15 @@ function renderTest(test, push, use) { text.forEach((line, offset) => { push( line, - offset === 0 ? { id: test.title, helper: callHelper(test) } : undefined, + offset === 0 + ? { + id: test.title, + helper: + test.framework !== undefined + ? `oracleProperty` + : callHelper(test), + } + : undefined, ) }) if (test.wrapped) push(`})`) @@ -1196,6 +1285,16 @@ function registrationText(test, use) { ` })`, ] } + if (test.framework !== undefined) { + const call = use(`oracleProperty`) + if (test.framework.options === `name`) return [`${call}(opts${title})`] + const fields = [`id: '${title}'`] + if (test.framework.options !== `model`) + fields.push(`check: () => {\n void 0\n }`) + if (test.framework.options !== `check`) + fields.push(`model: () => {\n void 0\n }`) + return [`${call}({ ${fields.join(`, `)} })`] + } if (test.driver?.kind === `let`) { return [`it('${title}', () => {`, ` drv${title}.run()`, ` })`] } @@ -1257,6 +1356,7 @@ function keyOf(message, subjects) { if (id === `unknownTypeModel`) return `typeModel:${subject.id}` if (id === `importedRegistration`) return `imported:${subject.helper ?? subject.id}` + if (id === `incompleteProperty`) return `incomplete:${subject.id}` return `other:${id}@${message.line}` } @@ -1269,6 +1369,7 @@ const CLI_IDS = [ ], [`This typeModel name is not a type alias`, `unknownTypeModel`], [`This oracle file calls a helper`, `importedRegistration`], + [`This oracleProperty call`, `incompleteProperty`], ] /** @@ -1489,6 +1590,15 @@ const STATES = [ `named-constant-arbitrary`, `imported-predicate`, `predicate-ignores-input`, + // Oracle properties: a complete one, one without a check, options the rule + // cannot read, a namespace import of the framework, and each lookalike. + `oracle-property-complete`, + `oracle-property-without-check`, + `oracle-property-named-options`, + `oracle-property-namespace-import`, + `oracle-property-machinery-lookalike`, + `oracle-property-ordinary-lookalike`, + `oracle-property-oracle-named-lookalike`, ] const reach = createOracleReach(PROPERTY, STATES, { minimum: 1 }) @@ -1625,6 +1735,27 @@ function recordStates(program) { states.add(`for-of-mixed-elements`) } } + const frameworkTests = program.tests.filter( + (test) => test.framework !== undefined, + ) + if (frameworkTests.length > 0) { + const file = program.frameworkFile + if (isFrameworkModule(file)) { + for (const test of frameworkTests) { + const { options } = test.framework + if (options === `name`) states.add(`oracle-property-named-options`) + else if (options === `model`) + states.add(`oracle-property-without-check`) + else states.add(`oracle-property-complete`) + } + } else if (file.startsWith(`oracle-`)) { + states.add(`oracle-property-machinery-lookalike`) + } else if (lintedOnItsOwn(file)) { + states.add(`oracle-property-oracle-named-lookalike`) + } else states.add(`oracle-property-ordinary-lookalike`) + if (oracle.imports.get(`oracleProperty`)?.form === `namespace`) + states.add(`oracle-property-namespace-import`) + } for (const state of states) reach.reach(state) } @@ -1773,6 +1904,19 @@ describe(`declared reach in oracle files`, () => { ...BASE, forms: [...BASE.forms, `property`, `prop-named`, `helper-prop`], }) + // The framework module and its lookalikes need the module-name and + // import-form gates together. + campaign( + `oracle properties`, + { + ...BASE, + forms: [...BASE.forms, `framework-property`], + frameworkNames: true, + importForms: true, + }, + IN_PROCESS, + 200, + ) campaign(`all families`, ALL) }) diff --git a/scripts/eslint-plugin-oracle/rules/no-unit-test.js b/scripts/eslint-plugin-oracle/rules/no-unit-test.js index 60cf8c71e4..a29c111f8e 100644 --- a/scripts/eslint-plugin-oracle/rules/no-unit-test.js +++ b/scripts/eslint-plugin-oracle/rules/no-unit-test.js @@ -43,6 +43,19 @@ import { * or in a test module that the file imports. One that the rule cannot read, as * from a package, counts. * + * An oracle property is its own declaration. A call to `oracleProperty`, + * imported from the framework module `oracle-property`, registers oracle tests + * whose parts are the functions it is given: + * + * oracleProperty({ id, law, histories, driver, model, check }) + * + * The call counts as a test that reaches a part when its options are an object + * literal with a `check` property. Any other call to it is reported as an + * incomplete property, because the rule cannot see that the property has a + * check. A function named `oracleProperty` from any other module is an ordinary + * helper and is judged like one. (Maintainer decision, oracle-framework slice, + * 2026-11-02.) + * * A test reaches a part when its body uses the part, or uses a function or * value that reaches it. The rule follows bindings through the file and * through relative imports of other test modules, so a driver in a companion @@ -177,6 +190,38 @@ function importedHelperShape(node, file, sourceCode, session) { return loadModule(module, session)?.registrationShape(target.name) } +/** The framework module, whose `oracleProperty` calls are oracle properties. */ +const frameworkModule = `oracle-property` +const frameworkExport = `oracleProperty` + +/** + * A call to the framework's `oracleProperty`, as `{ complete }`, or undefined + * for any other call. A complete call passes an object literal with a `check` + * property, so the property names its own check. + */ +function oraclePropertyCall(node, file, sourceCode, session) { + const target = importedTarget(node.callee, sourceCode.getScope(node)) + if (target?.name !== frameworkExport) return undefined + const module = resolveTestModule(file, target.source, session) + if ( + module === undefined || + basename(module).replace(/\.[cm]?[jt]sx?$/, ``) !== frameworkModule + ) + return undefined + const [options] = node.arguments + const complete = + options?.type === `ObjectExpression` && + options.properties.some( + (property) => + property.type === `Property` && + !property.computed && + (property.key.type === `Identifier` + ? property.key.name === `check` + : property.key.value === `check`), + ) + return { complete } +} + /** * The import that an identifier or a namespace member names, as * `{ source, name }`, or undefined when it names no import. @@ -1227,6 +1272,7 @@ export default { messages: { unitTest: `This test reaches no part that this file declares with declareOracle, so its expected result has no independent source. A unit test belongs in an ordinary test file: move it there. A fixed history runs through the declared check, a model pin uses the declared model, and a grammar calibration uses the declared grammar.`, unknownTypeModel: `This typeModel name is not a type alias or interface in this file. A type model is the expected type that type-level tests compare with, so it must be declared here.`, + incompleteProperty: `This oracleProperty call does not pass an object literal with a check, so the rule cannot see that the property has one. Pass its options inline as \`oracleProperty({ id, law, histories, driver, model, check })\`.`, importedRegistration: `This oracle file calls a helper that registers tests. An oracle file registers only oracle tests, so register those tests from an ordinary test file and keep this file to its checks and model.`, undeclared: `This oracle file registers tests and declares no oracle part. Name its check, model, and grammar with \`declareOracle({ check: [...], model: [...], grammar: [...] })\` from oracle-declaration. Then move each test that reaches none of them to an ordinary test file.`, }, @@ -1252,6 +1298,16 @@ export default { const outermost = !( node.parent.type === `CallExpression` && node.parent.callee === node ) + // An oracle property is its own declaration: it reaches by construction + // when it names its check. + const property = outermost + ? oraclePropertyCall(node, context.filename, sourceCode, session) + : undefined + if (property !== undefined) { + if (property.complete) anyReached = true + else context.report({ node, messageId: `incompleteProperty` }) + return + } const helper = outermost ? importedHelperShape(node, context.filename, sourceCode, session) : undefined diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs new file mode 100644 index 0000000000..3a5daf7767 --- /dev/null +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -0,0 +1,245 @@ +// Test-side harness for the gap report. +// +// It does three jobs inside every test worker: +// +// 1. It defines `globalThis.__oracleGaps`, the switch that the instrumented +// source calls at each mutation site (see schemata.mjs). The switch runs +// the original code unless the site's mutant is the active one, and it +// records which sites each test reached and which outcomes each `if` +// condition took. +// 2. It records what each test asserted about. Every value a test passes to +// `expect(...)` is serialized and folded into one hash per test, so a +// mutant run can be compared with the unmutated run: equal hashes mean +// the assertions saw the same values in the same order. +// 3. It skips tests that the current run did not select, so a mutant runs +// only the tests that reach it. +// +// The runner provides the current run's state (active mutant, selected tests, +// output path) with `provide('oracleGaps', …)`, read here with `inject`. Each +// warm Vitest context provides its own state, so several can run mutants at +// once. A state file named by ORACLE_GAPS_STATE is the fallback. This file is +// copied into the target package, so `vitest` here is the target's own copy +// and its `chai` is the one the tests use. +// +// In a mutant run, fast-check stops a property at its first failure instead +// of shrinking it: a kill needs one failing history, not the smallest one. +import { createHash } from 'node:crypto' +import { appendFileSync, readFileSync } from 'node:fs' +import { afterAll, afterEach, beforeEach, chai, expect, inject } from 'vitest' + +const state = + inject(`oracleGaps`) ?? + (process.env.ORACLE_GAPS_STATE + ? JSON.parse(readFileSync(process.env.ORACLE_GAPS_STATE, `utf8`)) + : { mutant: null, select: null, capture: null }) +if (state.mutant !== null) { + const fc = await import(`fast-check`).catch(() => undefined) + fc?.configureGlobal({ ...fc.readConfigureGlobal(), endOnFailure: true }) +} +const selected = state.select ? new Set(state.select) : null +// Set ORACLE_GAPS_DEBUG to a path to keep every rendered value, for finding +// the source of a nondeterministic capture. +const debug = process.env.ORACLE_GAPS_DEBUG +let digest = createHash(`sha1`) +let count = 0 +// An oracle property's observations (oracle-property.ts) are hashed on their +// own. A mutant that changes one without failing the check is a comparison +// gap: the driver records an output that the check does not compare. +let observed = createHash(`sha1`) +let observations = 0 + +// Site hits and `if` outcomes for the running test. Code that runs outside +// any test (module setup, or async work that outlives its test) records into +// the file's background set, which counts for every test in the file. +let hits = new Set() +let outcomes = new Map() +let inTest = false +// The current test file. `afterAll`'s arguments differ between Vitest 3 +// (the suite) and Vitest 4 (a fixture context, then the suite), so the file +// is taken from the last test instead. +let currentFile +const background = new Set() +const active = state.mutant +const hit = (id) => (inTest ? hits : background).add(id) +const swapped = { + '===': `!==`, + '!==': `===`, + '<': `<=`, + '<=': `<`, + '>': `>=`, + '>=': `>`, +} +function compare(op, a, b) { + switch (op) { + case `===`: + return a === b + case `!==`: + return a !== b + case `<`: + return a < b + case `<=`: + return a <= b + case `>`: + return a > b + default: + return a >= b + } +} +globalThis.__oracleGaps = { + m(id) { + hit(id) + return id === active + }, + v: (value) => value, + cmp(id, a, b, op) { + hit(id) + return compare(id === active ? swapped[op] : op, a, b) + }, + not(id, value) { + hit(id) + return id === active ? value : !value + }, + // Records whether the condition was truthy or falsy, then applies a forced + // value if one of this `if`'s two mutants is active. + cond(idTrue, idFalse, value) { + hit(idTrue) + hit(idFalse) + if (inTest) { + const seen = outcomes.get(idTrue) ?? [false, false] + seen[value ? 0 : 1] = true + outcomes.set(idTrue, seen) + } + if (active === idTrue) return true + if (active === idFalse) return false + return value + }, +} + +function testKey(task) { + const names = [] + for (let t = task; t; t = t.suite) if (t.name) names.unshift(t.name) + return `${task.file?.filepath} > ${names.join(` > `)}` +} + +// Random identifiers such as `crypto.randomUUID()` version keys differ on +// every run. Within one test, each distinct UUID is renamed `uuid#n` in order +// of first appearance: whether two values share an identity is kept, the +// random value is not. +const uuidPattern = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi +let uuids = new Map() +function canonical(text) { + return text.replace(uuidPattern, (uuid) => { + const key = uuid.toLowerCase() + if (!uuids.has(key)) uuids.set(key, `uuid#${uuids.size}`) + return uuids.get(key) + }) +} + +// A stable, cycle-safe rendering. It keeps the distinctions an assertion can +// see (types, order, Map and Set entries, error identity by name and message) +// and drops ones it cannot (object identity, functions' bodies). +function render(value, seen = new Set(), depth = 0) { + if (depth > 8) return `…` + if (value === null) return `null` + const type = typeof value + if (type === `undefined`) return `undefined` + if (type === `number`) return Object.is(value, -0) ? `-0` : String(value) + if (type === `string`) return JSON.stringify(canonical(value.slice(0, 4000))) + if (type === `boolean` || type === `bigint`) return `${type}:${value}` + if (type === `symbol`) return `symbol:${value.description ?? ``}` + if (type === `function`) return `fn:${value.name}` + if (seen.has(value)) return `[cycle]` + seen.add(value) + try { + if (value instanceof Error) + return `Error:${value.name}:${value.message}:${render(value.cause, seen, depth + 1)}` + if (value instanceof Date) return `Date:${value.getTime()}` + if (value instanceof Map) + return `Map{${[...value].map(([k, v]) => `${render(k, seen, depth + 1)}=>${render(v, seen, depth + 1)}`).join(`,`)}}` + if (value instanceof Set) + return `Set{${[...value].map((v) => render(v, seen, depth + 1)).join(`,`)}}` + if (Array.isArray(value)) + return `[${value.map((v) => render(v, seen, depth + 1)).join(`,`)}]` + if (ArrayBuffer.isView(value)) + return `${value.constructor.name}(${Array.from(value).join(`,`)})` + if (typeof value.then === `function`) return `Promise` + const name = value.constructor?.name ?? `null-proto` + const keys = Object.keys(value).sort() + return `${name}{${keys.map((k) => `${k}:${render(value[k], seen, depth + 1)}`).join(`,`)}}` + } finally { + seen.delete(value) + } +} + +// Every matcher is a method on chai's Assertion prototype. Wrapping each one +// records its name and the value under test before the matcher runs. +const proto = chai.Assertion.prototype +for (const name of Object.getOwnPropertyNames(proto)) { + if (name === `constructor`) continue + const descriptor = Object.getOwnPropertyDescriptor(proto, name) + if (!descriptor || typeof descriptor.value !== `function`) continue + const matcher = descriptor.value + Object.defineProperty(proto, name, { + ...descriptor, + value: function (...args) { + try { + const rendered = `${name}(${render(chai.util.flag(this, `object`))})\n` + digest.update(rendered) + if (debug) + appendFileSync( + debug, + `${expect.getState().currentTestName}\t${rendered}`, + ) + count += 1 + } catch { + digest.update(`${name}(unrenderable)\n`) + } + return matcher.apply(this, args) + }, + }) +} + +globalThis.__oracleObserve = (checkpoint, value) => { + observed.update(`${checkpoint}(${render(value)})\n`) + observations += 1 +} + +beforeEach((context) => { + currentFile = context.task.file?.filepath + observed = createHash(`sha1`) + observations = 0 + if (selected && !selected.has(testKey(context.task))) context.skip() + digest = createHash(`sha1`) + count = 0 + uuids = new Map() + hits = new Set() + outcomes = new Map() + inTest = true +}) + +afterEach((context) => { + inTest = false + if (!state.capture) return + appendFileSync( + state.capture, + `${JSON.stringify({ + key: testKey(context.task), + hash: digest.digest(`hex`), + count, + observed: observations > 0 ? observed.digest(`hex`) : undefined, + hits: [...hits], + outcomes: [...outcomes], + })}\n`, + ) + hits = new Set() +}) + +afterAll(() => { + if (!state.capture || !currentFile) return + const file = currentFile + appendFileSync( + state.capture, + `${JSON.stringify({ key: `${file} > (background)`, background: true, hits: [...background] })}\n`, + ) +}) diff --git a/scripts/oracle-gaps/index.mjs b/scripts/oracle-gaps/index.mjs new file mode 100644 index 0000000000..ee6e9414b7 --- /dev/null +++ b/scripts/oracle-gaps/index.mjs @@ -0,0 +1,488 @@ +#!/usr/bin/env node +// Gap report prototype: which production behaviour do the oracles leave +// unexercised or unchecked? +// +// The report does not locate bugs or judge laws. It lists places where an +// oracle could improve, so that each one can be closed or given a reason +// (oracle-framework design, decisions D41 and D42). For each source file it: +// +// 1. Compiles every mutant into one instrumented copy (schemata.mjs) and +// serves that copy to Vitest through a plugin. Nothing is written to the +// source file. +// 2. Runs all given test files twice with no mutant active. Each test reports +// which mutation sites it reached, which outcomes each `if` took, and a +// hash of the values it asserted about. Tests whose hash differs between +// the two runs are nondeterministic. +// 3. Runs each mutant against the oracle files whose tests reached its site, +// in one warm Vitest process, stopping at the first failure. Whole files +// run, because tests in one file can share state (jsdom's localStorage +// persists across them), so skipping some can change the others. +// 4. Classifies each run: `killed` (an assertion failed), `crashed` (a test +// failed with a non-assertion error), `survived`, `timeout`, or +// `setup-error` (an error outside any test). A site that no oracle test +// reached is `unreached`. Killed and crashed are both detections. Because +// a run stops at its first failure, which of the two a mutant gets can +// depend on which file ran first. +// 5. Sorts each survivor: +// - `comparison-gap`: asserted values, or an oracle property's recorded +// observations, changed but no assertion failed. +// - `generator-gap`: no asserted value changed, and an `if` on the mutated +// line never took one of its outcomes in any oracle test. +// - `no-observed-difference`: no asserted value changed and every `if` +// outcome on the line occurred. This is not evidence of no contract: the +// explored histories may simply never distinguish the edit (D42). +// - `indeterminate`: only nondeterministic tests reached the site. +// +// Ordinary test files given with --tests form a second tier. A mutant that no +// oracle kills is run against the ordinary tests that reach it, and a kill +// there is reported as `killed-by-ordinary-test` (D41 (d)). +// +// --seed sets TANSTACK_DB_ORACLE_SEED, so random campaigns run the same +// histories every time and captured values stay comparable. +// +// Usage: +// node scripts/oracle-gaps/index.mjs --package --oracles +// --source [--tests ] [--seed ] +// [--diff-base ] [--out ] [--only ] +// [--test-timeout ] (default 2000; also bounds hooks and teardown) +// [--contexts ] (warm contexts, one worker each; default +// the machine's parallelism minus 2, at most 8) +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { availableParallelism } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { instrument } from './schemata.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) + +function parseArgs(argv) { + const args = {} + for (let i = 0; i < argv.length; i += 1) { + const [key, inline] = argv[i].replace(/^--/, ``).split(`=`) + args[key] = inline ?? argv[++i] + } + if (!args.package || !args.oracles || !args.source) + throw new Error(`--package, --oracles and --source are required`) + return { + // Test reports name files by their real path (on macOS, /tmp is + // /private/tmp), so the package path is resolved the same way. + pkg: realpathSync(resolve(args.package)), + oracles: args.oracles.split(`,`), + // Each warm context runs one mutant at a time with one worker. Measured on + // main's LocalStorage owners (320 mutants): 2 contexts 297 s, 4 185 s, + // 8 137 s, with identical oracle-tier classes. + contexts: Number( + args.contexts ?? Math.min(8, Math.max(2, availableParallelism() - 2)), + ), + tests: args.tests ? args.tests.split(`,`) : [], + sources: args.source.split(`,`), + seed: args.seed, + diffBase: args[`diff-base`], + out: resolve(args.out ?? `/tmp/oracle-gaps`), + only: args.only ? new Set(args.only.split(`,`)) : undefined, + // A mutant can make a test hang. Oracles that run in milliseconds do not + // need Vitest's default limits, which would make each hang cost seconds. + testTimeout: Number(args[`test-timeout`] ?? 2000), + } +} + +const options = parseArgs(process.argv.slice(2)) +mkdirSync(options.out, { recursive: true }) +const requireTarget = createRequire(join(options.pkg, `package.json`)) +const ts = requireTarget(`typescript`) + +// 1. Instrument every source file once. +const instrumented = {} +const mutants = [] +for (const source of options.sources) { + const path = realpathSync(join(options.pkg, source)) + const result = instrument(ts, path, readFileSync(path, `utf8`), source) + instrumented[path] = result.code + mutants.push(...result.mutants) +} +const schemataFile = join(options.out, `schemata.json`) +writeFileSync(schemataFile, JSON.stringify(instrumented)) + +// The wrapper config adds the harness setup to the package's own config and +// a plugin that serves the instrumented sources in place of the originals. +const wrapper = join(options.pkg, `.oracle-gaps.vitest.config.ts`) +const harness = join(options.pkg, `.oracle-gaps.capture-setup.mjs`) +const stateFile = join(options.out, `state.json`) +function installHarness() { + copyFileSync(join(here, `capture-setup.mjs`), harness) + writeFileSync( + wrapper, + `import { readFileSync, realpathSync } from 'node:fs' +import { mergeConfig } from 'vitest/config' +import base from './vite.config.ts' +const schemata = JSON.parse(readFileSync(process.env.ORACLE_GAPS_SCHEMATA, 'utf8')) +export default mergeConfig(base, { + plugins: [{ + name: 'oracle-gaps-schemata', + enforce: 'pre', + load(id) { + const path = id.split('?')[0] + try { return schemata[realpathSync(path)] } catch { return undefined } + }, + }], + test: { setupFiles: ['./.oracle-gaps.capture-setup.mjs'] }, +}) +`, + ) +} +function removeHarness() { + rmSync(wrapper, { force: true }) + rmSync(harness, { force: true }) +} + +// Lines changed relative to the diff base, for diff-scoped summaries. +function diffLines(source) { + if (!options.diffBase) return undefined + const child = spawnSync( + `git`, + [`diff`, `-U0`, options.diffBase, `--`, source], + { cwd: options.pkg, encoding: `utf8` }, + ) + const lines = new Set() + for (const match of child.stdout.matchAll( + /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm, + )) { + const start = Number(match[1]) + for (let line = start; line < start + Number(match[2] ?? 1); line += 1) + lines.add(line) + } + return lines +} + +let runCounter = 0 +async function run(vitest, specs, { mutant = null, select = null } = {}) { + const capture = join(options.out, `run-${++runCounter}.jsonl`) + const state = { mutant, select: select && [...select], capture } + // Vitest 3 and 4 both expose the root project's `provide`; the state file + // remains for setups that cannot `inject`. + vitest.getRootProject?.().provide(`oracleGaps`, state) + writeFileSync(stateFile, JSON.stringify(state)) + const started = Date.now() + const result = await vitest.runTestSpecifications(specs, false) + // The result also lists test modules from earlier runs that this run did + // not execute, with their old results. Only this run's files count. + const ran = new Set(specs.map((spec) => spec.moduleId)) + const modules = result.testModules.filter((module) => + ran.has(module.moduleId), + ) + const rows = new Map() + if (existsSync(capture)) { + for (const line of readFileSync(capture, `utf8`) + .split(`\n`) + .filter(Boolean)) { + const row = JSON.parse(line) + if (!select || select.has(row.key)) rows.set(row.key, row) + } + rmSync(capture) + } + return { result, modules, rows, seconds: (Date.now() - started) / 1000 } +} + +// A failure counts as a kill only when it is an assertion. A test that fails +// with any other error after setup is `crashed`: production threw where the +// oracle did not expect it, which the oracle noticed but did not judge +// (ORC-006 "runtime failure after setup outside the intended comparison"). +// An error outside any test is `setup-error`. Typed verdicts will make this +// exact; for now the error text decides. +function outcomeOf({ result, modules }) { + const failures = [] + for (const module of modules) { + for (const error of module.errors?.() ?? []) + failures.push({ assertion: false, setup: true, message: error.message }) + for (const testCase of module.children.allTests()) { + const testResult = testCase.result() + if (testResult.state !== `failed`) continue + for (const error of testResult.errors ?? []) { + const message = `${error.name ?? ``}: ${error.message ?? ``}` + failures.push({ + test: testCase.fullName, + // An oracle property's check reports a typed OracleViolation. + assertion: + /OracleViolation|AssertionError|Property failed|expected .+ to |Counterexample/.test( + message, + ), + timeout: /Test timed out/.test(message), + message: message.slice(0, 300), + }) + } + } + } + for (const error of result.unhandledErrors ?? []) + failures.push({ + assertion: false, + message: String(error?.message ?? error), + }) + if (failures.length === 0) return { outcome: `survived` } + if (failures.some((f) => f.assertion)) + return { + outcome: `killed`, + by: failures.filter((f) => f.assertion).map((f) => f.test), + } + if (failures.every((f) => f.timeout)) return { outcome: `timeout` } + if (failures.some((f) => f.test && !f.timeout)) + return { + outcome: `crashed`, + by: failures.filter((f) => f.test).map((f) => f.test), + detail: failures.find((f) => f.test).message, + } + return { outcome: `setup-error`, detail: failures[0].message } +} + +process.env.ORACLE_GAPS_STATE = stateFile +process.env.ORACLE_GAPS_SCHEMATA = schemataFile +if (options.seed) process.env.TANSTACK_DB_ORACLE_SEED = options.seed + +installHarness() +// Vitest resolves test-file filters and parts of its project setup against +// the working directory, so the runner works from the package directory. +process.chdir(options.pkg) +const { createVitest } = await import( + pathToFileURL(requireTarget.resolve(`vitest/node`)).href +) +// Two warm contexts with one worker each run two mutants at a time. A mutant +// run usually executes one file, so one context with two workers would leave +// a worker idle. The unmutated runs use the first context. +const contexts = [] +let exitCode = 0 +const newContext = () => + createVitest(`test`, { + root: options.pkg, + config: wrapper, + watch: false, + maxWorkers: 1, + bail: Number(process.env.ORACLE_GAPS_BAIL ?? 1), + testTimeout: options.testTimeout, + hookTimeout: options.testTimeout, + teardownTimeout: options.testTimeout, + typecheck: { enabled: false }, + coverage: { enabled: false }, + reporters: [{}], + }) +try { + for (let i = 0; i < options.contexts; i += 1) { + const context = await newContext() + // Vitest 3 calls this step `init`; Vitest 4 renamed it `standalone`. + await (context.standalone ? context.standalone() : context.init()) + contexts.push(context) + } + const vitest = contexts[0] + const fileSpecs = new Map() + for (const file of [...options.oracles, ...options.tests]) { + // Filters resolve against the working directory, not `root`, so each + // file is passed by its absolute path. + const path = realpathSync(join(options.pkg, file)) + const specs = await vitest.globTestSpecifications([path]) + if (specs.length === 0) throw new Error(`no test file matches ${file}`) + fileSpecs.set(path, specs) + } + const oracleFiles = new Set( + options.oracles.map((f) => realpathSync(join(options.pkg, f))), + ) + const fileOf = (key) => key.split(` > `)[0] + const allSpecs = [...fileSpecs.values()].flat() + + // 2. Two unmutated runs. + const first = await run(vitest, allSpecs) + const firstOutcome = outcomeOf(first) + if (firstOutcome.outcome !== `survived`) + throw new Error( + `unmutated run did not pass: ${JSON.stringify(firstOutcome)}`, + ) + const second = await run(vitest, allSpecs) + const nondeterministic = new Set() + for (const [key, row] of first.rows) { + const again = second.rows.get(key) + if ( + !row.background && + (again?.hash !== row.hash || again?.observed !== row.observed) + ) + nondeterministic.add(key) + } + console.error( + `unmutated: ${[...first.rows.values()].filter((r) => !r.background).length} tests in ${first.seconds.toFixed(1)}s and ${second.seconds.toFixed(1)}s; nondeterministic: ${nondeterministic.size}`, + ) + + // Which tests reached each site, and which `if` outcomes never occurred in + // an oracle test. + const reachedBy = new Map() + const outcomeSeen = new Map() + for (const [key, row] of first.rows) { + for (const id of row.hits) + reachedBy.set(id, [...(reachedBy.get(id) ?? []), key]) + if (row.background || !oracleFiles.has(fileOf(key))) continue + for (const [id, [truthy, falsy]] of row.outcomes) { + const seen = outcomeSeen.get(id) ?? [false, false] + outcomeSeen.set(id, [seen[0] || truthy, seen[1] || falsy]) + } + } + const missingOutcomeLines = new Set() + for (const mutant of mutants) { + if (mutant.operator !== `if-true`) continue + const [truthy, falsy] = outcomeSeen.get(mutant.number) ?? [false, false] + if (reachedBy.has(mutant.number) && !(truthy && falsy)) + missingOutcomeLines.add(`${mutant.source}:${mutant.line}`) + } + + // 3–5. Mutants. + const diffs = new Map(options.sources.map((s) => [s, diffLines(s)])) + const results = [] + const started = Date.now() + // Each context's specs are its own objects, so specs are looked up per + // context by file. + const contextSpecs = new Map([[vitest, fileSpecs]]) + for (const context of contexts.slice(1)) { + const map = new Map() + for (const path of fileSpecs.keys()) + map.set(path, await context.globTestSpecifications([path])) + contextSpecs.set(context, map) + } + const queue = mutants.filter((m) => !options.only || options.only.has(m.id)) + async function evaluate(mutant, vitest) { + const specsFor = (keys) => + [...new Set(keys.map(fileOf))].flatMap( + (f) => contextSpecs.get(vitest).get(f) ?? [], + ) + mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) + const keys = reachedBy.get(mutant.number) ?? [] + const oracleKeys = keys.filter((k) => oracleFiles.has(fileOf(k))) + const testKeys = keys.filter((k) => !oracleFiles.has(fileOf(k))) + const oracleTargets = [...new Set(oracleKeys.map(fileOf))] + const testTargets = [...new Set(testKeys.map(fileOf))] + const result = { + ...mutant, + oracleFiles: oracleTargets, + ordinaryFiles: testTargets, + } + if (oracleKeys.length > 0) { + const mutated = await run(vitest, specsFor(oracleKeys), { + mutant: mutant.number, + }) + Object.assign(result, outcomeOf(mutated), { seconds: mutated.seconds }) + if (result.outcome === `survived`) { + const compared = [...mutated.rows.keys()].filter( + (k) => !mutated.rows.get(k).background && !nondeterministic.has(k), + ) + // A changed asserted value or a changed property observation, with no + // failure, is a difference that the check did not judge. + result.changedTests = compared.filter( + (k) => + mutated.rows.get(k)?.hash !== first.rows.get(k)?.hash || + mutated.rows.get(k)?.observed !== first.rows.get(k)?.observed, + ) + result.class = + result.changedTests.length > 0 + ? `comparison-gap` + : compared.length === 0 + ? `indeterminate` + : missingOutcomeLines.has(`${mutant.source}:${mutant.line}`) + ? `generator-gap` + : `no-observed-difference` + } + } else result.outcome = `unreached` + if ( + (result.outcome === `survived` || result.outcome === `unreached`) && + testKeys.length > 0 + ) { + const tier = await run(vitest, specsFor(testKeys), { + mutant: mutant.number, + }) + result.testOutcome = outcomeOf(tier).outcome + // A crash in an ordinary test is a detection, as it is for an oracle. + if ( + result.outcome === `unreached` && + (result.testOutcome === `killed` || result.testOutcome === `crashed`) + ) + result.outcome = `killed-by-ordinary-test` + } + results.push(result) + console.error( + `${mutant.id} L${mutant.line} ${mutant.operator} ${result.outcome}${result.class ? ` (${result.class})` : ``}${result.testOutcome ? ` [ordinary: ${result.testOutcome}]` : ``}`, + ) + } + await Promise.all( + contexts.map(async (context) => { + for (let mutant = queue.shift(); mutant; mutant = queue.shift()) + await evaluate(mutant, context) + }), + ) + results.sort( + (a, b) => a.number - b.number || a.source.localeCompare(b.source), + ) + const mutantSeconds = (Date.now() - started) / 1000 + console.error( + `mutant phase: ${results.length} mutants in ${mutantSeconds.toFixed(1)}s`, + ) + writeFileSync( + join(options.out, `results.json`), + JSON.stringify( + { + options: { ...options, only: undefined }, + nondeterministic: [...nondeterministic], + mutantSeconds, + results, + }, + null, + 1, + ), + ) + const summary = summarize(results) + writeFileSync(join(options.out, `summary.md`), summary) + console.log(summary) +} catch (error) { + console.error(error) + exitCode = 1 +} finally { + for (const context of contexts) await context.close() + removeHarness() +} +process.exit(exitCode) + +function summarize(results) { + const count = (rows, predicate) => rows.filter(predicate).length + const line = (label, rows) => + `| ${label} | ${rows.length} | ${count(rows, (r) => r.outcome === `killed`)} | ${count(rows, (r) => r.class === `comparison-gap`)} | ${count(rows, (r) => r.class === `generator-gap`)} | ${count(rows, (r) => r.class === `no-observed-difference`)} | ${count(rows, (r) => r.class === `indeterminate`)} | ${count(rows, (r) => r.outcome === `unreached` || r.outcome === `killed-by-ordinary-test`)} | ${count(rows, (r) => r.outcome === `crashed`)} | ${count(rows, (r) => r.outcome === `setup-error` || r.outcome === `timeout`)} | ${count(rows, (r) => r.testOutcome === `killed` || r.testOutcome === `crashed`)} |` + const header = + `| Scope | Mutants | Killed by an oracle | Comparison gap | Generator gap | No observed difference | Indeterminate | No oracle reaches it | Crashed (not an assertion) | Setup error or timeout | Of the uncaught, an ordinary test kills |\n` + + `|---|---|---|---|---|---|---|---|---|---|---|` + const byFunction = new Map() + for (const r of results) + byFunction.set(r.function, [...(byFunction.get(r.function) ?? []), r]) + const rows = [line(`all`, results)] + if (results.some((r) => r.inDiff !== undefined)) + rows.push( + line( + `diff`, + results.filter((r) => r.inDiff), + ), + ) + const functions = [...byFunction] + .filter(([, rs]) => rs.some((r) => r.outcome !== `killed`)) + .map(([name, rs]) => line(`fn ${name}`, rs)) + return [ + `# Gap report`, + ``, + header, + ...rows, + ``, + `## Functions with gaps`, + ``, + header, + ...functions, + ``, + ].join(`\n`) +} diff --git a/scripts/oracle-gaps/schemata.mjs b/scripts/oracle-gaps/schemata.mjs new file mode 100644 index 0000000000..259fac641b --- /dev/null +++ b/scripts/oracle-gaps/schemata.mjs @@ -0,0 +1,143 @@ +// Mutant switching: compile every mutant of a source file into one copy. +// +// Each mutation site becomes a call into `globalThis.__oracleGaps`, which the +// test setup defines. The call evaluates the original expression unless its +// mutant is the active one, and it records that the current test reached the +// site. One instrumented copy therefore serves every mutant run: switching +// mutants changes a number, not the source, and nothing is written to disk. +// +// Operators, in the order the sites are numbered: +// - `binary-swap`: `===`↔`!==`, `<`↔`<=`, `>`↔`>=`, `&&`↔`||`, `??`→`&&`. +// - `drop-not`: `!x` → `x`. +// - `if-true` / `if-false`: force an `if` condition. +// - `delete-call`: skip a call statement (`switch || call()`). +// +// Comparisons and `!` go through helpers that evaluate each operand once. +// `&&`, `||`, and `??` must keep short-circuiting, so both variants are +// written out and the switch picks one. Every replacement starts with an +// identifier, so it cannot join the previous line under automatic semicolon +// insertion. +const G = `globalThis.__oracleGaps` + +const comparisons = new Map([ + [`===`, `!==`], + [`!==`, `===`], + [`<`, `<=`], + [`<=`, `<`], + [`>`, `>=`], + [`>=`, `>`], +]) +const logical = new Map([ + [`&&`, `||`], + [`||`, `&&`], + [`??`, `&&`], +]) + +function functionName(ts, node) { + if (node.name && ts.isIdentifier(node.name)) return node.name.text + const parent = node.parent + if ( + parent && + (ts.isVariableDeclaration(parent) || + ts.isPropertyAssignment(parent) || + ts.isPropertyDeclaration(parent)) && + ts.isIdentifier(parent.name) + ) + return parent.name.text + return undefined +} + +export function instrument(ts, path, text, source) { + const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) + const lineOf = (pos) => file.getLineAndCharacterOfPosition(pos).line + 1 + const mutants = [] + // `at` is the node whose line the mutant reports: the operator token for a + // swap and the condition for an `if`, as the earlier runner reported them. + const add = (at, operator, fn, original, replacement) => { + const id = mutants.length + 1 + mutants.push({ + id: `${source.split(`/`).pop()}:M${id}`, + number: id, + source, + line: lineOf(at.getStart(file)), + operator, + function: fn, + original, + replacement, + }) + return id + } + + // Emit `node` with its children's instrumented text spliced in, then wrap + // the node itself if it is a mutation site. Sites are numbered before their + // children, matching the order of the earlier per-file runner. + function emit(node, fn) { + const name = ts.isFunctionLike(node) ? (functionName(ts, node) ?? fn) : fn + const ids = {} + if (ts.isBinaryExpression(node)) { + const op = node.operatorToken.getText(file) + const swap = comparisons.get(op) ?? logical.get(op) + if (swap) + ids.binary = add(node.operatorToken, `binary-swap`, name, op, swap) + } + if ( + ts.isPrefixUnaryExpression(node) && + node.operator === ts.SyntaxKind.ExclamationToken + ) + ids.not = add(node, `drop-not`, name, `!`, ``) + if (ts.isIfStatement(node)) { + const condition = node.expression.getText(file) + ids.ifTrue = add(node.expression, `if-true`, name, condition, `true`) + ids.ifFalse = add(node.expression, `if-false`, name, condition, `false`) + } + if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) + ids.call = add(node, `delete-call`, name, node.getText(file), `;`) + + const children = [] + ts.forEachChild(node, (child) => { + children.push(child) + }) + // `emitted` maps each child to its instrumented text and to where that + // text starts in `out`, so a wrapper can replace exactly one child. + const emitted = new Map() + const start = node === file ? 0 : node.getStart(file) + let out = `` + let position = start + for (const child of children) { + const childStart = child.getStart(file) + const childText = emit(child, name) + out += text.slice(position, childStart) + emitted.set(child, { text: childText, at: out.length }) + out += childText + position = child.end + } + out += text.slice(position, node.end) + + if (ids.binary !== undefined) { + const op = node.operatorToken.getText(file) + const left = emitted.get(node.left).text + const right = emitted.get(node.right).text + if (comparisons.has(op)) + return `${G}.cmp(${ids.binary}, ${left}, ${right}, '${op}')` + return `${G}.v(${G}.m(${ids.binary}) ? (${left} ${logical.get(op)} ${right}) : (${left} ${op} ${right}))` + } + if (ids.not !== undefined) + return `${G}.not(${ids.not}, ${emitted.get(node.operand).text})` + if (ids.ifTrue !== undefined) { + // Replace only the condition; the rest of the `if` stays as emitted. + const { text: condition, at } = emitted.get(node.expression) + return `${out.slice(0, at)}${G}.cond(${ids.ifTrue}, ${ids.ifFalse}, ${condition})${out.slice(at + condition.length)}` + } + if (ids.call !== undefined) { + // `skip || call()` keeps the statement a single expression statement. + // Wrapping it in an `if` would capture a following `else` (as in + // `if (x) call() else other()`). + const { text: call, at } = emitted.get(node.expression) + return `${out.slice(0, at)}${G}.m(${ids.call}) || ${call}${out.slice(at + call.length)}` + } + return out + } + + const code = emit(file, ``) + return { code, mutants } +} From e9a5d21424ba245b5b67b106916263a4e88ca836 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Nov 2026 16:05:46 -0700 Subject: [PATCH 002/168] test: upgrade to fast-check 4 fast-check 4 removes RunDetails.error and the errorWithCause option. A failure now always carries the property's error as its cause, so failure text reads errorInstance and the replay witness always attaches the cause. fast-check 4 also infers literal and readonly types where 3 did not, so a few casts go away and the optimistic history arbitrary names its element type. fast-check 4 shrinks to different paths, so the two includes cross-formulation calibrations pin the new replay coordinates. Both documented direct replays were rerun and still fail once at checkpoint 0. Co-authored-by: Isaac --- package.json | 4 +- .../e2e/backend-oracle.test.ts | 10 +- .../hash-mixed-graph-oracle.property.test.ts | 4 +- .../tests/sqlite-core-adapter-oracle.test.ts | 2 +- packages/db/tests/btree-map-oracle.test.ts | 8 +- .../tests/comparison-oracle.property.test.ts | 10 +- .../index-update-oracle.property.test.ts | 4 +- packages/db/tests/indexed-db/campaign.ts | 2 +- .../cross-tab-history-oracle.test.ts | 4 +- .../db/tests/optimistic-history-oracle.ts | 2 +- packages/db/tests/oracle-replay-witness.ts | 15 +- packages/db/tests/oracle-replay.test.ts | 84 +- .../in-evaluator-oracle.property.test.ts | 12 +- ...-cross-formulation-oracle.property.test.ts | 40 +- .../query/includes-cross-formulation.test.ts | 14 +- .../tests/expression-index-oracle.test.ts | 26 +- .../KeyScheduler-oracle.property.test.ts | 8 +- ...saction-serializer-oracle.property.test.ts | 6 +- .../tests/op-sqlite-driver-oracle.test.ts | 4 +- pnpm-lock.yaml | 891 +++--------------- 20 files changed, 257 insertions(+), 893 deletions(-) diff --git a/package.json b/package.json index 35ef1b2c4c..01d99e9f64 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@changesets/get-release-plan": "4.0.14", "@changesets/read": "0.6.6", "@eslint/js": "^9.39.2", - "@fast-check/vitest": "^0.4.1", + "@fast-check/vitest": "^0.5.0", "@manypkg/get-packages": "1.1.3", "@svitejs/changesets-changelog-github-compact": "^1.2.0", "@tanstack/eslint-config": "0.3.4", @@ -65,7 +65,7 @@ "eslint": "^9.39.2", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-react": "^7.37.5", - "fast-check": "^3.23.0", + "fast-check": "^4.10.2", "husky": "^9.1.7", "jsdom": "^27.4.0", "knip": "^5.83.1", diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index fc54fa5466..7e9e859727 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -384,7 +384,7 @@ it.each([`insert`, `update`, `delete`] as const)( draft( fc .uint8Array({ maxLength: 12 }) - .map((bytes) => bytes.buffer as ArrayBuffer), + .map((bytes) => bytes.buffer), ), ) const histories = fc.record({ @@ -420,9 +420,11 @@ it.each([`insert`, `update`, `delete`] as const)( uncertainFirst, !uncertainFirst, ]) { - for (const { text, rank, payload } of lostAcknowledgement - ? uncertain - : known) { + // The two payload tuples have different lengths, so the loop + // reads either one as an array of its drafts. + const drafts: ReadonlyArray<(typeof known)[number]> = + lostAcknowledgement ? uncertain : known + for (const { text, rank, payload } of drafts) { reach.beginHistory() await durableHistory( { diff --git a/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts b/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts index feadf36119..37249585dc 100644 --- a/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts @@ -297,7 +297,7 @@ describe(`mixed-carrier structural graph laws`, () => { }) expect(failed.failed).toBe(true) expect(failed.counterexamplePath).not.toBeNull() - expect(failed.error).toContain(`reachable cycle`) + expect(String(failed.errorInstance)).toContain(`reachable cycle`) if (failed.counterexamplePath === null) throw new Error(`Missing mixed graph replay path`) const replay = fc.check(mixedGraphProperty(acceptsCycles), { @@ -308,7 +308,7 @@ describe(`mixed-carrier structural graph laws`, () => { }) expect(replay.failed).toBe(true) expect(replay.counterexample).toEqual(failed.counterexample) - expect(replay.error).toContain(`reachable cycle`) + expect(String(replay.errorInstance)).toContain(`reachable cycle`) }) it(`rejects unequal DAG and unfolding output and preserves the witness on replay`, () => { diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts index 54baa27672..50fb1b66ed 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts @@ -1014,7 +1014,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { ) expect( details.failed, - `${label}: seed=${details.seed} path=${details.counterexamplePath ?? ``} first=${firstFailure?.kind ?? ``}@${firstFailure?.checkpoint ?? ``} reduced=${reducedProblem?.kind ?? ``}@${reducedProblem?.checkpoint ?? ``} sameViolation=${sameViolation}; ${details.error ?? ``}`, + `${label}: seed=${details.seed} path=${details.counterexamplePath ?? ``} first=${firstFailure?.kind ?? ``}@${firstFailure?.checkpoint ?? ``} reduced=${reducedProblem?.kind ?? ``}@${reducedProblem?.checkpoint ?? ``} sameViolation=${sameViolation}; ${String(details.errorInstance ?? ``)}`, ).toBe(false) } diff --git a/packages/db/tests/btree-map-oracle.test.ts b/packages/db/tests/btree-map-oracle.test.ts index fa9a81435a..ad38d52b61 100644 --- a/packages/db/tests/btree-map-oracle.test.ts +++ b/packages/db/tests/btree-map-oracle.test.ts @@ -354,7 +354,7 @@ describe(`BTree Map oracle`, () => { numRuns: 100, }) expect(failed.failed).toBe(true) - expect(failed.error).toMatch(/expected/) + expect(String(failed.errorInstance)).toMatch(/expected/) if (failed.counterexamplePath === null) throw new Error(`Missing Map mismatch replay path`) const replay = fc.check(mapHistoryProperty, { @@ -363,7 +363,7 @@ describe(`BTree Map oracle`, () => { numRuns: 1, }) expect(replay.failed).toBe(true) - expect(replay.error).toMatch(/expected/) + expect(String(replay.errorInstance)).toMatch(/expected/) expect(replay.counterexample).toEqual(failed.counterexample) } finally { wrongScan.mockRestore() @@ -501,7 +501,7 @@ describe(`BTree Map oracle`, () => { ) const failed = fc.check(property, { seed: 303102, numRuns: 1 }) expect(failed.failed).toBe(true) - expect(failed.error).toMatch(/expected/) + expect(String(failed.errorInstance)).toMatch(/expected/) expect(failed.counterexample?.[0].length).toBeGreaterThan(0) if (failed.counterexamplePath === null) throw new Error(`Missing calibration replay path`) @@ -512,7 +512,7 @@ describe(`BTree Map oracle`, () => { endOnFailure: true, }) expect(replay.failed).toBe(true) - expect(replay.error).toMatch(/expected/) + expect(String(replay.errorInstance)).toMatch(/expected/) expect(replay.counterexample).toEqual(failed.counterexample) }) }) diff --git a/packages/db/tests/comparison-oracle.property.test.ts b/packages/db/tests/comparison-oracle.property.test.ts index dc1ca18558..70e5bdca9f 100644 --- a/packages/db/tests/comparison-oracle.property.test.ts +++ b/packages/db/tests/comparison-oracle.property.test.ts @@ -150,7 +150,7 @@ function assertComparisonProperty( `${label}: ${original.checkpoint} failed at the function-return checkpoint; ` + `reduction ${reduced.checkpoint === original.checkpoint ? `kept` : `changed`} the original violation; ` + `seed ${result.seed}, path ${result.counterexamplePath}; ` + - `direct replay ${reproduced ? `reproduced` : `did not reproduce`} that violation.\n${result.error}`, + `direct replay ${reproduced ? `reproduced` : `did not reproduce`} that violation.\n${String(result.errorInstance)}`, { cause: original.error }, ) } @@ -517,7 +517,7 @@ describe(`comparison law controls`, () => { }) const failure = fc.check(property, { seed: 20260911, numRuns: 20 }) expect(failure.failed).toBe(true) - expect(failure.error).toContain( + expect(String(failure.errorInstance)).toContain( `comparator result must be a number other than NaN`, ) expect(failure.counterexample).toEqual([0]) @@ -531,7 +531,7 @@ describe(`comparison law controls`, () => { endOnFailure: true, }) expect(replay.failed).toBe(true) - expect(replay.error).toContain( + expect(String(replay.errorInstance)).toContain( `comparator result must be a number other than NaN`, ) expect(replay.counterexample).toEqual(failure.counterexample) @@ -913,8 +913,8 @@ describe(`makeComparator property-based tests`, () => { `makeComparator produces valid comparator for any options (same types)`, ([a, b], direction, nulls) => { const opts: CompareOptions = { - direction: direction as `asc` | `desc`, - nulls: nulls as `first` | `last`, + direction: direction, + nulls: nulls, stringSort: `locale`, } const comparator = makeComparator(opts) diff --git a/packages/db/tests/index-update-oracle.property.test.ts b/packages/db/tests/index-update-oracle.property.test.ts index 02f2ba0847..cced09c866 100644 --- a/packages/db/tests/index-update-oracle.property.test.ts +++ b/packages/db/tests/index-update-oracle.property.test.ts @@ -1974,7 +1974,7 @@ test(`retired-identity calibration shrinks and replays the same semantic failure ) const failed = fc.check(property, { seed: 303108, numRuns: 1 }) expect(failed.failed).toBe(true) - expect(failed.error).toMatch(/expected/) + expect(String(failed.errorInstance)).toMatch(/expected/) expect(failed.counterexample).toEqual([[0]]) expect(failed.counterexamplePath).toBe(`0:0:0`) if (failed.counterexamplePath === null) @@ -1986,7 +1986,7 @@ test(`retired-identity calibration shrinks and replays the same semantic failure endOnFailure: true, }) expect(replay.failed).toBe(true) - expect(replay.error).toMatch(/expected/) + expect(String(replay.errorInstance)).toMatch(/expected/) expect(replay.counterexample).toEqual(failed.counterexample) }) diff --git a/packages/db/tests/indexed-db/campaign.ts b/packages/db/tests/indexed-db/campaign.ts index 95d7a92955..b23a041888 100644 --- a/packages/db/tests/indexed-db/campaign.ts +++ b/packages/db/tests/indexed-db/campaign.ts @@ -148,7 +148,7 @@ export async function runCampaign( ) if (result.failed) throw new Error( - `${name} failed: seed=${result.seed} path=${selectedPath}; replay=${JSON.stringify(reproduction)}\n${result.error}`, + `${name} failed: seed=${result.seed} path=${selectedPath}; replay=${JSON.stringify(reproduction)}\n${String(result.errorInstance)}`, ) if (result.numRuns !== (options.path === undefined ? options.runs : 1)) throw new Error('Campaign did not execute its declared budget') diff --git a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts index d6903b9340..e1a6e4764c 100644 --- a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts @@ -238,7 +238,9 @@ it('replays a generated intentional mismatch at its original law and checkpoint' premiseReached: replay.numRuns > 0, sameFailure: replay.failed && - String(replay.error).includes('public rows at replay calibration'), + String(String(replay.errorInstance)).includes( + 'public rows at replay calibration', + ), }) }) diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index 68c2413633..fc2ceb4534 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -660,7 +660,7 @@ const sourceBatch = fc.record({ }), // The source may also delete keys, including keys it does not hold. deletes: fc.oneof( - { weight: 3, arbitrary: fc.constant([]) }, + { weight: 3, arbitrary: fc.constant>([]) }, { weight: 1, arbitrary: fc.uniqueArray(fc.integer({ min: 1, max: 3 }), { diff --git a/packages/db/tests/oracle-replay-witness.ts b/packages/db/tests/oracle-replay-witness.ts index a6727ecc76..8a5475b189 100644 --- a/packages/db/tests/oracle-replay-witness.ts +++ b/packages/db/tests/oracle-replay-witness.ts @@ -95,16 +95,15 @@ export function oracleReplayReporter( appendWitness(channel, property, details) const rethrow = (details: RunDetails, message: string | undefined) => { // A custom reporter replaces fast-check's throw. Preserve its public - // formatter and errorWithCause policy, including precondition failures. + // formatter and its cause, which fast-check 4 always attaches, including + // for precondition failures. if (message === undefined) return const error = new Error(message) - if (details.runConfiguration.errorWithCause) { - Object.defineProperty(error, `cause`, { - value: details.errorInstance, - configurable: true, - writable: true, - }) - } + Object.defineProperty(error, `cause`, { + value: details.errorInstance, + configurable: true, + writable: true, + }) throw error } // fast-check accepts an async reporter only for an async property. When a diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index 1f6ed9f6fb..ad2ea4021a 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -337,50 +337,48 @@ describe(`oracle replay reporting`, () => { }) }) - it.each([false, true])( - `preserves native async failure formatting and cause=%s`, - async (errorWithCause) => { - directory = mkdtempSync(join(tmpdir(), `oracle-reporter-test-`)) - const channel = join(directory, `witness.jsonl`) - vi.stubEnv(`TANSTACK_DB_ORACLE_REPLAY_WITNESS`, channel) - vi.stubEnv(`TANSTACK_DB_ORACLE_PROPERTY`, `oracle-replay.calibration`) - vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, `42`) - vi.stubEnv(`TANSTACK_DB_ORACLE_PATH`, `0`) - const sentinel = new Error(`native cause sentinel`) - const value = { - [fc.asyncToStringMethod]: () => Promise.resolve(`async replay value`), - } - const property = fc.asyncProperty(fc.constant(value), () => - Promise.reject(sentinel), - ) - const options = { seed: 42, path: `0`, numRuns: 1, errorWithCause } - async function failure(instrumented: boolean): Promise { - try { - await fc.assert(property, { - ...options, - ...(instrumented - ? oracleReplayReporter(`oracle-replay.calibration`, 42, `0`) - : {}), - }) - } catch (error) { - if (error instanceof Error) return error - throw error - } - throw new Error(`expected property failure`) + // fast-check 4 always attaches the property's error as the cause. + it(`preserves native async failure formatting and cause`, async () => { + directory = mkdtempSync(join(tmpdir(), `oracle-reporter-test-`)) + const channel = join(directory, `witness.jsonl`) + vi.stubEnv(`TANSTACK_DB_ORACLE_REPLAY_WITNESS`, channel) + vi.stubEnv(`TANSTACK_DB_ORACLE_PROPERTY`, `oracle-replay.calibration`) + vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, `42`) + vi.stubEnv(`TANSTACK_DB_ORACLE_PATH`, `0`) + const sentinel = new Error(`native cause sentinel`) + const value = { + [fc.asyncToStringMethod]: () => Promise.resolve(`async replay value`), + } + const property = fc.asyncProperty(fc.constant(value), () => + Promise.reject(sentinel), + ) + const options = { seed: 42, path: `0`, numRuns: 1 } + async function failure(instrumented: boolean): Promise { + try { + await fc.assert(property, { + ...options, + ...(instrumented + ? oracleReplayReporter(`oracle-replay.calibration`, 42, `0`) + : {}), + }) + } catch (error) { + if (error instanceof Error) return error + throw error } - const native = await failure(false) - const instrumented = await failure(true) - expect(instrumented.message).toBe(native.message) - expect(instrumented.message).toContain(`async replay value`) - expect(Object.getOwnPropertyDescriptor(instrumented, `cause`)).toEqual( - Object.getOwnPropertyDescriptor(native, `cause`), - ) - expect(JSON.parse(readFileSync(channel, `utf8`))).toMatchObject({ - numRuns: 1, - failed: true, - }) - }, - ) + throw new Error(`expected property failure`) + } + const native = await failure(false) + const instrumented = await failure(true) + expect(instrumented.message).toBe(native.message) + expect(instrumented.message).toContain(`async replay value`) + expect(Object.getOwnPropertyDescriptor(instrumented, `cause`)).toEqual( + Object.getOwnPropertyDescriptor(native, `cause`), + ) + expect(JSON.parse(readFileSync(channel, `utf8`))).toMatchObject({ + numRuns: 1, + failed: true, + }) + }) }) describe(`named oracle replay manifest`, () => { diff --git a/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts b/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts index b0e4a144c6..653f5e5dd6 100644 --- a/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts +++ b/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts @@ -272,12 +272,12 @@ describe(`in evaluator`, () => { value === null || value === undefined ? null : list.some((entry) => entry === value) - expect(() => - fc.assert( - fc.property(inCase, (testCase) => check(raw, testCase)), - { numRuns: 500, seed: 0x2029 }, - ), - ).toThrow(/in mismatch/) + const details = fc.check( + fc.property(inCase, (testCase) => check(raw, testCase)), + { numRuns: 500, seed: 0x2029 }, + ) + expect(details.failed).toBe(true) + expect(String(details.errorInstance)).toMatch(/in mismatch/) }) it(`rejects display-text equality for equivalent Duration forms`, () => { diff --git a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts index 1ea3892a4f..665db3932d 100644 --- a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts +++ b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts @@ -1440,7 +1440,7 @@ describe(`includes cross-formulation oracle`, () => { // Checked direct replay witness: // TANSTACK_DB_ORACLE_SEED=1658004 - // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0 + // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 // TANSTACK_DB_ORACLE_PROPERTY=includes-cross-formulation.equivalence // TANSTACK_DB_ORACLE_CALIBRATION=missing-parent // pnpm --filter @tanstack/db exec vitest --run --coverage.enabled=false @@ -1469,17 +1469,23 @@ describe(`includes cross-formulation oracle`, () => { : { seed: 1_658_004, numRuns: oracleRuns(8) }, ) expect(failure.failed).toBe(true) - expect(failure.error).toContain(`cross-formulation checkpoint`) - expect(failure.error).toContain(`checkpoint":0`) + expect(String(failure.errorInstance)).toContain( + `cross-formulation checkpoint`, + ) + expect(String(failure.errorInstance)).toContain(`checkpoint":0`) if (directReplay) { expect(failure.seed).toBe(replay.replaySeed) - expect(replay.replayPath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0`) + expect(replay.replayPath).toBe( + `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, + ) return } expect(failure.numShrinks).toBeGreaterThan(0) if (failure.counterexamplePath === null) throw new Error(`missing-parent mutant did not produce a replay path`) - expect(failure.counterexamplePath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0`) + expect(failure.counterexamplePath).toBe( + `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, + ) const replayResult = await fc.check(property, { seed: failure.seed, path: failure.counterexamplePath, @@ -1487,15 +1493,17 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`cross-formulation checkpoint`) - expect(replayResult.error).toContain(`checkpoint":0`) + expect(String(replayResult.errorInstance)).toContain( + `cross-formulation checkpoint`, + ) + expect(String(replayResult.errorInstance)).toContain(`checkpoint":0`) expect(replayResult.counterexample).toEqual(failure.counterexample) }, ) // Checked direct replay witness: // TANSTACK_DB_ORACLE_SEED=1658005 - // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 + // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 // TANSTACK_DB_ORACLE_PROPERTY=includes-cross-formulation.ordered-window // TANSTACK_DB_ORACLE_CALIBRATION=extra-child // pnpm --filter @tanstack/db exec vitest --run --coverage.enabled=false @@ -1534,18 +1542,20 @@ describe(`includes cross-formulation oracle`, () => { : { seed: 1_658_005, numRuns: oracleRuns(12) }, ) expect(failure.failed).toBe(true) - expect(failure.error).toContain(`ordered-window checkpoint`) - expect(failure.error).toContain(`checkpoint":0`) + expect(String(failure.errorInstance)).toContain( + `ordered-window checkpoint`, + ) + expect(String(failure.errorInstance)).toContain(`checkpoint":0`) if (directReplay) { expect(failure.seed).toBe(replay.replaySeed) - expect(replay.replayPath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`) + expect(replay.replayPath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`) return } expect(failure.numShrinks).toBeGreaterThan(0) if (failure.counterexamplePath === null) throw new Error(`extra-child mutant did not produce a replay path`) expect(failure.counterexamplePath).toBe( - `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, + `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, ) const replayResult = await fc.check(property, { seed: failure.seed, @@ -1554,8 +1564,10 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`ordered-window checkpoint`) - expect(replayResult.error).toContain(`checkpoint":0`) + expect(String(replayResult.errorInstance)).toContain( + `ordered-window checkpoint`, + ) + expect(String(replayResult.errorInstance)).toContain(`checkpoint":0`) expect(replayResult.counterexample).toEqual(failure.counterexample) }, ) diff --git a/packages/db/tests/query/includes-cross-formulation.test.ts b/packages/db/tests/query/includes-cross-formulation.test.ts index deb0992fd4..d606a5913d 100644 --- a/packages/db/tests/query/includes-cross-formulation.test.ts +++ b/packages/db/tests/query/includes-cross-formulation.test.ts @@ -376,7 +376,9 @@ describe(`includes cross-formulation oracle`, () => { const failure = fc.check(property, { seed: 1_658_002, numRuns: 4 }) expect(failure.failed).toBe(true) expect(failure.numShrinks).toBeGreaterThan(0) - expect(failure.error).toContain(`reference route checkpoint`) + expect(String(failure.errorInstance)).toContain( + `reference route checkpoint`, + ) if (failure.counterexamplePath === null) throw new Error(`reference-route mutant did not produce a replay path`) const replayResult = fc.check(property, { @@ -386,7 +388,9 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`reference route checkpoint`) + expect(String(replayResult.errorInstance)).toContain( + `reference route checkpoint`, + ) expect(replayResult.counterexample).toEqual(failure.counterexample) }) @@ -406,7 +410,7 @@ describe(`includes cross-formulation oracle`, () => { const failure = fc.check(property, { seed: 1_658_003, numRuns: 4 }) expect(failure.failed).toBe(true) expect(failure.numShrinks).toBeGreaterThan(0) - expect(failure.error).toContain(`group-route checkpoint`) + expect(String(failure.errorInstance)).toContain(`group-route checkpoint`) if (failure.counterexamplePath === null) throw new Error(`symbol-group mutant did not produce a replay path`) const replayResult = fc.check(property, { @@ -416,7 +420,9 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`group-route checkpoint`) + expect(String(replayResult.errorInstance)).toContain( + `group-route checkpoint`, + ) expect(replayResult.counterexample).toEqual(failure.counterexample) }) diff --git a/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts b/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts index 864c524516..67db0fb857 100644 --- a/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts +++ b/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts @@ -1144,13 +1144,15 @@ describe(`SQLite expression-index oracle`, () => { const evidence = JSON.stringify({ seed: details.seed, path: details.counterexamplePath, - error: details.error, + error: String(details.errorInstance), }) expect(details.failed, evidence).toBe(true) expect(details.seed, evidence).toBe(DEFAULT_ORACLE_SEED) expect(details.counterexamplePath, evidence).not.toBeNull() - expect(details.error, evidence).toContain(`missing=${kind}`) + expect(String(details.errorInstance), evidence).toContain( + `missing=${kind}`, + ) }, ) @@ -1190,14 +1192,18 @@ describe(`SQLite expression-index oracle`, () => { const evidence = JSON.stringify({ seed: details.seed, path: details.counterexamplePath, - error: details.error, + error: String(details.errorInstance), }) expect(details.failed, evidence).toBe(true) expect(details.seed, evidence).toBe(DEFAULT_ORACLE_SEED) expect(details.counterexamplePath, evidence).not.toBeNull() - expect(details.error ?? ``, evidence).not.toContain(`did not reach`) - expect(details.error, evidence).toContain(`"kind": "${kind}"`) + expect(String(details.errorInstance ?? ``), evidence).not.toContain( + `did not reach`, + ) + expect(String(details.errorInstance), evidence).toContain( + `"kind": "${kind}"`, + ) if (details.counterexamplePath === null) { throw new Error(`${kind} wrong-answer control did not shrink to a path`) @@ -1215,11 +1221,15 @@ describe(`SQLite expression-index oracle`, () => { seed: replay.seed, requestedPath: details.counterexamplePath, replayPath: replay.counterexamplePath, - error: replay.error, + error: String(replay.errorInstance), }) expect(replay.failed, replayEvidence).toBe(true) - expect(replay.error ?? ``, replayEvidence).not.toContain(`did not reach`) - expect(replay.error, replayEvidence).toContain(`"kind": "${kind}"`) + expect(String(replay.errorInstance ?? ``), replayEvidence).not.toContain( + `did not reach`, + ) + expect(String(replay.errorInstance), replayEvidence).toContain( + `"kind": "${kind}"`, + ) }, ) }) diff --git a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts index 98004b3f28..4b2be872be 100644 --- a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts @@ -617,8 +617,8 @@ describe(`KeyScheduler generated lifecycle`, () => { }) const first = fc.check(wrongCount, { seed: FIXED_SEED, numRuns: RUNS }) expect(first.failed).toBe(true) - expect(first.error).toContain(`AssertionError:`) - expect(first.error).toContain(`"commandIndex":`) + expect(String(first.errorInstance)).toContain(`AssertionError:`) + expect(String(first.errorInstance)).toContain(`"commandIndex":`) const replayPath = first.counterexamplePath if (replayPath === null) throw new Error(`Missing shrink path`) @@ -629,7 +629,9 @@ describe(`KeyScheduler generated lifecycle`, () => { }) expect(replay.failed).toBe(true) expect(replay.counterexample).toEqual(first.counterexample) - expect(replay.error?.split(`\n`)[0]).toEqual(first.error.split(`\n`)[0]) + expect(String(replay.errorInstance).split(`\n`)[0]).toEqual( + String(first.errorInstance).split(`\n`)[0], + ) }) it(`executes every modeled transition in a fixed replay`, () => { diff --git a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts index a4cdcb8bea..1cb144022a 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -567,7 +567,7 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( }) expect(captured.failed).toBe(true) expect(captured.errorInstance).toMatchObject({ name: `AssertionError` }) - expect(captured.error).toContain(`mutation wire checkpoint`) + expect(String(captured.errorInstance)).toContain(`mutation wire checkpoint`) if (replay.seed !== undefined) return expect(captured.numShrinks).toBeGreaterThan(0) @@ -581,7 +581,7 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( }) expect(original.failed).toBe(true) expect(original.errorInstance).toMatchObject({ name: `AssertionError` }) - expect(original.error).toContain(`mutation wire checkpoint`) + expect(String(original.errorInstance)).toContain(`mutation wire checkpoint`) const replayed = await fc.check(property, { seed: captured.seed, path: capturedPath, @@ -590,7 +590,7 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( expect(replayed.failed).toBe(true) expect(replayed.counterexample).toEqual(captured.counterexample) expect(replayed.errorInstance).toMatchObject({ name: `AssertionError` }) - expect(replayed.error).toContain(`mutation wire checkpoint`) + expect(String(replayed.errorInstance)).toContain(`mutation wire checkpoint`) }, ) diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts index 5befd91fe6..80b32dcbae 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts @@ -433,7 +433,7 @@ it(`reserved-alias checker shrinks and replays the same row-law violation`, () = numRuns: 20, }) expect(original.failed).toBe(true) - expect(original.error).toContain( + expect(String(original.errorInstance)).toContain( `op-sqlite reserved-alias exact-row law violated`, ) if (original.counterexamplePath === null) { @@ -446,7 +446,7 @@ it(`reserved-alias checker shrinks and replays the same row-law violation`, () = endOnFailure: true, }) expect(reduced.failed).toBe(true) - expect(reduced.error).toContain( + expect(String(reduced.errorInstance)).toContain( `op-sqlite reserved-alias exact-row law violated`, ) expect(reduced.counterexample).toEqual(original.counterexample) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a4898b91a..53bcc6fcbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^9.39.2 version: 9.39.4 '@fast-check/vitest': - specifier: ^0.4.1 - version: 0.4.1(vitest@4.1.12) + specifier: ^0.5.0 + version: 0.5.0(vitest@4.1.12) '@manypkg/get-packages': specifier: 1.1.3 version: 1.1.3 @@ -102,8 +102,8 @@ importers: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.4(jiti@2.7.0)) fast-check: - specifier: ^3.23.0 - version: 3.23.2 + specifier: ^4.10.2 + version: 4.10.2 husky: specifier: ^9.1.7 version: 9.1.7 @@ -270,7 +270,7 @@ importers: version: 42.11.18 express: specifier: ^5.2.1 - version: 5.2.1 + version: 5.3.0 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -370,7 +370,7 @@ importers: version: 2.8.6 express: specifier: ^5.2.1 - version: 5.2.1 + version: 5.3.0 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -467,7 +467,7 @@ importers: version: 2.8.6 express: specifier: ^5.2.1 - version: 5.2.1 + version: 5.3.0 postgres: specifier: ^3.4.8 version: 3.4.8 @@ -890,7 +890,7 @@ importers: version: 0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@57.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.29.6)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@4.6.7) express: specifier: ^5.2.1 - version: 5.2.1 + version: 5.3.0 postgres: specifier: ^3.4.8 version: 3.4.8 @@ -1011,7 +1011,7 @@ importers: version: 0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@57.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.29.6)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@4.6.7) express: specifier: ^5.2.1 - version: 5.2.1 + version: 5.3.0 postgres: specifier: ^3.4.8 version: 3.4.8 @@ -4092,10 +4092,10 @@ packages: resolution: {integrity: sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw==} hasBin: true - '@fast-check/vitest@0.4.1': - resolution: {integrity: sha512-OX6TecB+ZzRfPc49jcPChcV1cf5aDu77CdDU8liecWVam7eD7mPvJNP50b9bBSTxVoGN5wzxrX9PIcCnqgC8bg==} + '@fast-check/vitest@0.5.0': + resolution: {integrity: sha512-Kgbj2smuq1P4OhGKuPATs5i2fAxNmGbJ/zSmTrzWhQSLUrHREbKWgv9WGbNUOXcx77q6joLVMmTygyXuwP2Bog==} peerDependencies: - vitest: ^4.1.0 + vitest: ^4.1.0 || ^5.0.0 '@gar/promise-retry@1.0.3': resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} @@ -5545,11 +5545,6 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm-eabi@4.64.0': - resolution: {integrity: sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.64.12': resolution: {integrity: sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==} cpu: [arm] @@ -5560,11 +5555,6 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-android-arm64@4.64.0': - resolution: {integrity: sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==} - cpu: [arm64] - os: [android] - '@rollup/rollup-android-arm64@4.64.12': resolution: {integrity: sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==} cpu: [arm64] @@ -5580,11 +5570,6 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-arm64@4.64.0': - resolution: {integrity: sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.64.12': resolution: {integrity: sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==} cpu: [arm64] @@ -5600,11 +5585,6 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.64.0': - resolution: {integrity: sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==} - cpu: [x64] - os: [darwin] - '@rollup/rollup-darwin-x64@4.64.12': resolution: {integrity: sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==} cpu: [x64] @@ -5615,11 +5595,6 @@ packages: cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.64.0': - resolution: {integrity: sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==} - cpu: [arm64] - os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.64.12': resolution: {integrity: sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==} cpu: [arm64] @@ -5630,11 +5605,6 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.64.0': - resolution: {integrity: sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==} - cpu: [x64] - os: [freebsd] - '@rollup/rollup-freebsd-x64@4.64.12': resolution: {integrity: sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==} cpu: [x64] @@ -5646,12 +5616,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-gnueabihf@4.64.0': - resolution: {integrity: sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-gnueabihf@4.64.12': resolution: {integrity: sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==} cpu: [arm] @@ -5664,12 +5628,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-arm-musleabihf@4.64.0': - resolution: {integrity: sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==} - cpu: [arm] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm-musleabihf@4.64.12': resolution: {integrity: sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==} cpu: [arm] @@ -5688,12 +5646,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-gnu@4.64.0': - resolution: {integrity: sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-gnu@4.64.12': resolution: {integrity: sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==} cpu: [arm64] @@ -5712,12 +5664,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-musl@4.64.0': - resolution: {integrity: sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-musl@4.64.12': resolution: {integrity: sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==} cpu: [arm64] @@ -5730,12 +5676,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-gnu@4.64.0': - resolution: {integrity: sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==} - cpu: [loong64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-loong64-gnu@4.64.12': resolution: {integrity: sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==} cpu: [loong64] @@ -5748,12 +5688,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-musl@4.64.0': - resolution: {integrity: sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==} - cpu: [loong64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-loong64-musl@4.64.12': resolution: {integrity: sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==} cpu: [loong64] @@ -5766,12 +5700,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.64.0': - resolution: {integrity: sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.64.12': resolution: {integrity: sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==} cpu: [ppc64] @@ -5784,12 +5712,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-musl@4.64.0': - resolution: {integrity: sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==} - cpu: [ppc64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-musl@4.64.12': resolution: {integrity: sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==} cpu: [ppc64] @@ -5802,12 +5724,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.64.0': - resolution: {integrity: sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.64.12': resolution: {integrity: sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==} cpu: [riscv64] @@ -5820,12 +5736,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-musl@4.64.0': - resolution: {integrity: sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-riscv64-musl@4.64.12': resolution: {integrity: sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==} cpu: [riscv64] @@ -5838,12 +5748,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-s390x-gnu@4.64.0': - resolution: {integrity: sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-s390x-gnu@4.64.12': resolution: {integrity: sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==} cpu: [s390x] @@ -5862,12 +5766,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.64.0': - resolution: {integrity: sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.64.12': resolution: {integrity: sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==} cpu: [x64] @@ -5886,12 +5784,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-x64-musl@4.64.0': - resolution: {integrity: sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==} - cpu: [x64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-x64-musl@4.64.12': resolution: {integrity: sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==} cpu: [x64] @@ -5903,11 +5795,6 @@ packages: cpu: [x64] os: [openbsd] - '@rollup/rollup-openbsd-x64@4.64.0': - resolution: {integrity: sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==} - cpu: [x64] - os: [openbsd] - '@rollup/rollup-openbsd-x64@4.64.12': resolution: {integrity: sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==} cpu: [x64] @@ -5918,11 +5805,6 @@ packages: cpu: [arm64] os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.64.0': - resolution: {integrity: sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==} - cpu: [arm64] - os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.64.12': resolution: {integrity: sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==} cpu: [arm64] @@ -5938,11 +5820,6 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.64.0': - resolution: {integrity: sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==} - cpu: [arm64] - os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.64.12': resolution: {integrity: sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==} cpu: [arm64] @@ -5953,11 +5830,6 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.64.0': - resolution: {integrity: sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==} - cpu: [ia32] - os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.64.12': resolution: {integrity: sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==} cpu: [ia32] @@ -5968,11 +5840,6 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.64.0': - resolution: {integrity: sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-gnu@4.64.12': resolution: {integrity: sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==} cpu: [x64] @@ -5988,11 +5855,6 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.64.0': - resolution: {integrity: sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-msvc@4.64.12': resolution: {integrity: sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==} cpu: [x64] @@ -7289,10 +7151,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-regex@6.4.0: resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} engines: {node: '>=12'} @@ -7537,11 +7395,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.30: - resolution: {integrity: sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.32: resolution: {integrity: sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==} engines: {node: '>=6.0.0'} @@ -7653,10 +7506,6 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -7695,11 +7544,6 @@ packages: browser-fs-access@0.35.0: resolution: {integrity: sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.29.4: resolution: {integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -7724,10 +7568,6 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - cacache@20.0.3: - resolution: {integrity: sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==} - engines: {node: ^20.17.0 || >=22.9.0} - cacache@20.0.4: resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -7768,9 +7608,6 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001818: - resolution: {integrity: sha512-XJbgo4adscw7R4MZU4/qVFkoMyPaNUAWmdNY4o/k9x7fFwmv4frIlndI9lWuaxviW9ObiZel9LuncnQPn6hQ5A==} - caniuse-lite@1.0.30001819: resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==} @@ -7966,10 +7803,6 @@ packages: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} - content-disposition@1.0.0: - resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} - engines: {node: '>= 0.6'} - content-disposition@2.0.1: resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} @@ -8387,9 +8220,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - electron-to-chromium@1.5.463: resolution: {integrity: sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==} @@ -8425,10 +8255,6 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} - engines: {node: '>=10.13.0'} - enhanced-resolve@5.26.0: resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} @@ -8747,9 +8573,6 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -8952,10 +8775,6 @@ packages: peerDependencies: express: '>= 4.11' - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - express@5.3.0: resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} engines: {node: '>= 18'} @@ -8970,9 +8789,9 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -9047,10 +8866,6 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} - finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} - engines: {node: '>= 0.8'} - finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -9067,9 +8882,6 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} @@ -9192,10 +9004,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-east-asian-width@1.7.0: resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} @@ -9343,10 +9151,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -9450,10 +9254,6 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -9536,10 +9336,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ip-address@10.7.3: - resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} - engines: {node: '>= 12'} - ip-address@10.7.4: resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} engines: {node: '>= 12'} @@ -9581,10 +9377,6 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - is-core-module@2.17.0: resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} @@ -9773,10 +9565,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} - isexe@4.0.0: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} @@ -9868,9 +9656,6 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - jose@6.2.12: - resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} - jose@6.2.13: resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} @@ -10385,10 +10170,6 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} @@ -10493,10 +10274,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.1: - resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -10784,11 +10561,6 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true - node-gyp@12.2.0: - resolution: {integrity: sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -10797,9 +10569,6 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - node-releases@2.0.59: resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} engines: {node: '>=18'} @@ -10989,10 +10758,6 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} - p-map@7.1.1: resolution: {integrity: sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==} engines: {node: '>=18'} @@ -11053,9 +10818,6 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.0: - resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} - parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -11327,10 +11089,6 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - proxy-addr@2.0.8: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} @@ -11354,17 +11112,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} - engines: {node: '>=0.6'} - qs@6.16.1: resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} engines: {node: '>=0.6'} @@ -11658,11 +11412,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.64.0: - resolution: {integrity: sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rollup@4.64.12: resolution: {integrity: sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -11808,10 +11557,6 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} - send@1.2.0: - resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} - engines: {node: '>= 18'} - send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -11848,10 +11593,6 @@ packages: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} - serve-static@2.2.0: - resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} - engines: {node: '>= 18'} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -11956,10 +11697,6 @@ packages: resolution: {integrity: sha512-DHg6+Pj7ORhYyC+CaSAr8DeRxqf9GXB90yqLmUILPtY7WhZuJatMir3id2MNjuF5I/1313SbrTTItIDu//G4jg==} hasBin: true - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -11972,10 +11709,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - side-channel@1.1.1: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} @@ -12219,10 +11952,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -12335,10 +12064,6 @@ packages: tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} - tapable@2.2.3: - resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} - engines: {node: '>=6'} - tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -12354,11 +12079,6 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - tar@7.5.7: - resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} - engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -12377,11 +12097,6 @@ packages: resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} engines: {node: '>=8'} - terser@5.44.0: - resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} - engines: {node: '>=10'} - hasBin: true - terser@5.51.2: resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} engines: {node: '>=10'} @@ -12532,10 +12247,6 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -12604,10 +12315,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@6.23.0: - resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} - engines: {node: '>=18.17'} - undici@6.29.1: resolution: {integrity: sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==} engines: {node: '>=18.17'} @@ -12635,14 +12342,6 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} - unique-filename@5.0.0: - resolution: {integrity: sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==} - engines: {node: ^20.17.0 || >=22.9.0} - - unique-slug@6.0.0: - resolution: {integrity: sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==} - engines: {node: ^20.17.0 || >=22.9.0} - unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -12705,12 +12404,6 @@ packages: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.3.4: resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true @@ -13066,11 +12759,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@6.0.0: - resolution: {integrity: sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==} - engines: {node: ^20.17.0 || >=22.9.0} - hasBin: true - which@6.0.1: resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -13599,7 +13287,7 @@ snapshots: '@auth/core@0.41.3': dependencies: '@panva/hkdf': 1.2.1 - jose: 6.2.12 + jose: 6.2.13 oauth4webapi: 3.8.8 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) @@ -13652,7 +13340,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.1 + browserslist: 4.29.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -14219,50 +13907,50 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4)': + '@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.7(zod@4.6.6) - jose: 6.2.12 + better-call: 1.3.7(zod@4.6.7) + jose: 6.2.13 kysely: 0.29.6 nanostores: 1.5.4 - zod: 4.6.6 + zod: 4.6.7 - '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@57.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.29.6)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))': + '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@57.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.29.6)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 optionalDependencies: drizzle-orm: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@57.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.29.6)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1) - '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)': + '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.6 - '@better-auth/memory-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -14297,7 +13985,7 @@ snapshots: prompts: 2.4.2 rimraf: 6.1.3 semver: 7.8.5 - tar: 7.5.7 + tar: 7.5.22 tslib: 2.8.1 xml2js: 0.6.2 transitivePeerDependencies: @@ -14545,7 +14233,7 @@ snapshots: dependencies: '@microsoft/fetch-event-source': 2.0.1 optionalDependencies: - '@rollup/rollup-darwin-arm64': 4.64.0 + '@rollup/rollup-darwin-arm64': 4.64.12 '@electron-internal/extract-zip@1.0.5': {} @@ -14568,7 +14256,7 @@ snapshots: debug: 4.4.3 node-abi: 4.36.0 node-api-version: 0.2.1 - node-gyp: 12.2.0 + node-gyp: 12.4.0 read-binary-file-arch: 1.0.6 transitivePeerDependencies: - supports-color @@ -15168,11 +14856,11 @@ snapshots: source-map-support: 0.5.21 stacktrace-parser: 0.1.11 structured-headers: 0.4.1 - tar: 7.5.7 + tar: 7.5.22 terminal-link: 2.1.1 - undici: 6.23.0 + undici: 6.29.1 wrap-ansi: 7.0.0 - ws: 8.20.0 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - graphql @@ -15594,7 +15282,7 @@ snapshots: '@expo/json-file': 10.0.12 '@expo/metro': 54.2.0 '@expo/spawn-async': 1.7.2 - browserslist: 4.28.1 + browserslist: 4.29.4 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 @@ -15790,9 +15478,9 @@ snapshots: chalk: 4.1.2 js-yaml: 4.1.1 - '@fast-check/vitest@0.4.1(vitest@4.1.12)': + '@fast-check/vitest@0.5.0(vitest@4.1.12)': dependencies: - fast-check: 3.23.2 + fast-check: 4.10.2 vitest: 4.1.12(@types/node@25.2.2)(@vitest/coverage-istanbul@4.1.12)(@vitest/ui@4.1.12)(jsdom@27.4.0)(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1)) '@gar/promise-retry@1.0.3': {} @@ -16094,7 +15782,7 @@ snapshots: '@inquirer/external-editor@1.0.3(@types/node@25.2.2)': dependencies: chardet: 2.1.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 optionalDependencies: '@types/node': 25.2.2 @@ -16248,7 +15936,7 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -16640,7 +16328,7 @@ snapshots: lru-cache: 11.5.3 npm-pick-manifest: 11.0.3 proc-log: 6.1.0 - semver: 7.7.2 + semver: 7.8.5 which: 6.0.1 '@npmcli/installed-package-contents@4.0.0': @@ -16657,7 +16345,7 @@ snapshots: hosted-git-info: 9.0.3 json-parse-even-better-errors: 5.0.0 proc-log: 6.1.0 - semver: 7.7.2 + semver: 7.8.5 spdx-expression-parse: 4.0.0 '@npmcli/promise-spawn@9.0.1': @@ -17398,18 +17086,12 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.59.0': optional: true - '@rollup/rollup-android-arm-eabi@4.64.0': - optional: true - '@rollup/rollup-android-arm-eabi@4.64.12': optional: true '@rollup/rollup-android-arm64@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.64.0': - optional: true - '@rollup/rollup-android-arm64@4.64.12': optional: true @@ -17419,9 +17101,6 @@ snapshots: '@rollup/rollup-darwin-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.64.0': - optional: true - '@rollup/rollup-darwin-arm64@4.64.12': optional: true @@ -17431,45 +17110,30 @@ snapshots: '@rollup/rollup-darwin-x64@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.64.0': - optional: true - '@rollup/rollup-darwin-x64@4.64.12': optional: true '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.64.0': - optional: true - '@rollup/rollup-freebsd-arm64@4.64.12': optional: true '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.64.0': - optional: true - '@rollup/rollup-freebsd-x64@4.64.12': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.64.0': - optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.64.12': optional: true '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.64.0': - optional: true - '@rollup/rollup-linux-arm-musleabihf@4.64.12': optional: true @@ -17479,9 +17143,6 @@ snapshots: '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.64.0': - optional: true - '@rollup/rollup-linux-arm64-gnu@4.64.12': optional: true @@ -17491,72 +17152,48 @@ snapshots: '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.64.0': - optional: true - '@rollup/rollup-linux-arm64-musl@4.64.12': optional: true '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.64.0': - optional: true - '@rollup/rollup-linux-loong64-gnu@4.64.12': optional: true '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.64.0': - optional: true - '@rollup/rollup-linux-loong64-musl@4.64.12': optional: true '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.64.0': - optional: true - '@rollup/rollup-linux-ppc64-gnu@4.64.12': optional: true '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.64.0': - optional: true - '@rollup/rollup-linux-ppc64-musl@4.64.12': optional: true '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.64.0': - optional: true - '@rollup/rollup-linux-riscv64-gnu@4.64.12': optional: true '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.64.0': - optional: true - '@rollup/rollup-linux-riscv64-musl@4.64.12': optional: true '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.64.0': - optional: true - '@rollup/rollup-linux-s390x-gnu@4.64.12': optional: true @@ -17566,9 +17203,6 @@ snapshots: '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.64.0': - optional: true - '@rollup/rollup-linux-x64-gnu@4.64.12': optional: true @@ -17578,27 +17212,18 @@ snapshots: '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.64.0': - optional: true - '@rollup/rollup-linux-x64-musl@4.64.12': optional: true '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-openbsd-x64@4.64.0': - optional: true - '@rollup/rollup-openbsd-x64@4.64.12': optional: true '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-openharmony-arm64@4.64.0': - optional: true - '@rollup/rollup-openharmony-arm64@4.64.12': optional: true @@ -17608,27 +17233,18 @@ snapshots: '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.64.0': - optional: true - '@rollup/rollup-win32-arm64-msvc@4.64.12': optional: true '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.64.0': - optional: true - '@rollup/rollup-win32-ia32-msvc@4.64.12': optional: true '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.64.0': - optional: true - '@rollup/rollup-win32-x64-gnu@4.64.12': optional: true @@ -17638,9 +17254,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.64.0': - optional: true - '@rollup/rollup-win32-x64-msvc@4.64.12': optional: true @@ -17853,7 +17466,7 @@ snapshots: '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.3 + enhanced-resolve: 5.26.0 jiti: 2.7.0 lightningcss: 1.30.2 magic-string: 0.30.21 @@ -18187,7 +17800,7 @@ snapshots: jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.9.9 - zod: 4.6.6 + zod: 4.6.7 transitivePeerDependencies: - supports-color @@ -18201,7 +17814,7 @@ snapshots: '@tanstack/router-utils': 1.162.3 chokidar: 5.0.0 unplugin: 3.4.0(esbuild@0.25.11)(rollup@4.64.12)(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1))(webpack@5.112.0(esbuild@0.25.11)(lightningcss@1.33.0)) - zod: 4.6.6 + zod: 4.6.7 optionalDependencies: '@tanstack/react-router': 1.170.41(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1) @@ -18227,7 +17840,7 @@ snapshots: '@tanstack/router-utils': 1.162.3 chokidar: 5.0.0 unplugin: 3.4.0(esbuild@0.28.1)(rollup@4.64.12)(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1))(webpack@5.112.0(esbuild@0.28.1)(lightningcss@1.33.0)) - zod: 4.6.6 + zod: 4.6.7 optionalDependencies: '@tanstack/react-router': 1.170.41(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1) @@ -18365,7 +17978,7 @@ snapshots: ufo: 1.6.4 vitefu: 1.1.3(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1)) xmlbuilder2: 4.0.3 - zod: 4.6.6 + zod: 4.6.7 optionalDependencies: vite: 7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: @@ -18404,7 +18017,7 @@ snapshots: ufo: 1.6.4 vitefu: 1.1.3(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1)) xmlbuilder2: 4.0.3 - zod: 4.6.6 + zod: 4.6.7 optionalDependencies: vite: 7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: @@ -19297,7 +18910,7 @@ snapshots: accepts@2.0.0: dependencies: - mime-types: 3.0.1 + mime-types: 3.0.2 negotiator: 1.1.0 acorn-jsx@5.3.2(acorn@8.18.0): @@ -19397,8 +19010,6 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-regex@6.4.0: {} ansi-styles@3.2.1: @@ -19738,8 +19349,6 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.30: {} - baseline-browser-mapping@2.11.32: {} beasties@0.3.5: @@ -19755,23 +19364,23 @@ snapshots: better-auth@1.6.22(261f3abdcb0412d90890f6a952f0c71b): dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4) - '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@57.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.29.6)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1)) - '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6) - '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@57.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.29.6)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.6.7))(jose@6.2.13)(kysely@0.29.6)(nanostores@1.5.4))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.4.0 '@noble/hashes': 2.4.0 - better-call: 1.3.7(zod@4.6.6) + better-call: 1.3.7(zod@4.6.7) defu: 6.1.7 - jose: 6.2.12 + jose: 6.2.13 kysely: 0.29.6 nanostores: 1.5.4 - zod: 4.6.6 + zod: 4.6.7 optionalDependencies: '@tanstack/react-start': 1.168.60(esbuild@0.25.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.12)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1))(webpack@5.112.0(esbuild@0.25.11)(lightningcss@1.33.0)) '@tanstack/solid-start': 1.168.57(@tanstack/react-router@1.170.41(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(esbuild@0.25.11)(rollup@4.64.12)(solid-js@1.9.11)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.7(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.1))(webpack@5.112.0(esbuild@0.25.11)(lightningcss@1.33.0)) @@ -19790,14 +19399,14 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.7(zod@4.6.6): + better-call@1.3.7(zod@4.6.7): dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 rou3: 0.7.12 set-cookie-parser: 3.1.2 optionalDependencies: - zod: 4.6.6 + zod: 4.6.7 better-opn@3.0.2: dependencies: @@ -19832,20 +19441,6 @@ snapshots: blake3-wasm@2.1.5: {} - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.14.1 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -19900,18 +19495,10 @@ snapshots: browser-fs-access@0.35.0: {} - browserslist@4.28.1: + browserslist@4.29.4: dependencies: baseline-browser-mapping: 2.11.32 caniuse-lite: 1.0.30001819 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - - browserslist@4.29.4: - dependencies: - baseline-browser-mapping: 2.11.30 - caniuse-lite: 1.0.30001818 electron-to-chromium: 1.5.463 node-releases: 2.0.59 update-browserslist-db: 1.3.4(browserslist@4.29.4) @@ -19936,20 +19523,6 @@ snapshots: bytes@3.1.2: {} - cacache@20.0.3: - dependencies: - '@npmcli/fs': 5.0.0 - fs-minipass: 3.0.3 - glob: 13.0.6 - lru-cache: 11.5.3 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 13.0.1 - unique-filename: 5.0.0 - cacache@20.0.4: dependencies: '@npmcli/fs': 5.0.0 @@ -19996,8 +19569,6 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001818: {} - caniuse-lite@1.0.30001819: {} chai@6.3.0: {} @@ -20207,10 +19778,6 @@ snapshots: transitivePeerDependencies: - supports-color - content-disposition@1.0.0: - dependencies: - safe-buffer: 5.2.1 - content-disposition@2.0.1: {} content-type@1.0.5: {} @@ -20257,7 +19824,7 @@ snapshots: core-js-compat@3.48.0: dependencies: - browserslist: 4.28.1 + browserslist: 4.29.4 core-util-is@1.0.3: {} @@ -20499,8 +20066,6 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.286: {} - electron-to-chromium@1.5.463: {} electron@42.11.18: @@ -20534,11 +20099,6 @@ snapshots: dependencies: once: 1.4.0 - enhanced-resolve@5.18.3: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.3 - enhanced-resolve@5.26.0: dependencies: graceful-fs: 4.2.11 @@ -20601,7 +20161,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -20668,11 +20228,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -20925,7 +20485,7 @@ snapshots: eslint-plugin-n@17.23.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) - enhanced-resolve: 5.18.3 + enhanced-resolve: 5.26.0 eslint: 9.39.4(jiti@2.7.0) eslint-plugin-es-x: 7.8.0(eslint@9.39.4(jiti@2.7.0)) get-tsconfig: 4.10.1 @@ -20964,7 +20524,7 @@ snapshots: es-iterator-helpers: 1.2.1 eslint: 9.39.4(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 @@ -21084,8 +20644,6 @@ snapshots: eventemitter3@4.0.7: {} - eventemitter3@5.0.1: {} - eventemitter3@5.0.4: {} events@3.3.0: {} @@ -21549,39 +21107,6 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.0 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.0 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.1 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.14.1 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.0 - serve-static: 2.2.0 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@5.3.0: dependencies: accepts: 2.0.0 @@ -21621,9 +21146,9 @@ snapshots: fake-indexeddb@6.2.5: {} - fast-check@3.23.2: + fast-check@4.10.2: dependencies: - pure-rand: 6.1.0 + pure-rand: 8.4.2 fast-deep-equal@3.1.3: {} @@ -21661,10 +21186,6 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -21699,17 +21220,6 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.0: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -21733,11 +21243,9 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.3.3: {} - flatted@3.4.4: {} flow-enums-runtime@0.0.6: {} @@ -21760,7 +21268,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 formatly@0.3.0: @@ -21824,7 +21332,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.4 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -21845,8 +21353,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-east-asian-width@1.7.0: {} get-graphql-from-jsonschema@8.1.0: @@ -21866,7 +21372,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-package-type@0.1.0: {} @@ -21989,10 +21495,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -22100,10 +21602,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -22162,15 +21660,13 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 + hasown: 2.0.4 + side-channel: 1.1.1 invariant@2.2.4: dependencies: loose-envify: 1.4.0 - ip-address@10.7.3: {} - ip-address@10.7.4: {} ipaddr.js@1.9.1: {} @@ -22213,10 +21709,6 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - is-core-module@2.17.0: dependencies: hasown: 2.0.4 @@ -22248,7 +21740,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.7.0 is-generator-function@1.1.0: dependencies: @@ -22309,7 +21801,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-set@2.0.3: {} @@ -22375,8 +21867,6 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.5: {} - isexe@4.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -22531,8 +22021,6 @@ snapshots: jose@5.10.0: {} - jose@6.2.12: {} - jose@6.2.13: {} js-base64@3.7.8: {} @@ -22562,7 +22050,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 + parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.0 @@ -22570,7 +22058,7 @@ snapshots: webidl-conversions: 8.0.0 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.20.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@exodus/crypto' @@ -22655,7 +22143,7 @@ snapshots: smol-toml: 1.5.2 strip-json-comments: 5.0.3 typescript: 5.9.3 - zod: 4.6.6 + zod: 4.6.7 known-css-properties@0.30.0: {} @@ -22859,7 +22347,7 @@ snapshots: dependencies: cli-truncate: 4.0.0 colorette: 2.0.20 - eventemitter3: 5.0.1 + eventemitter3: 5.0.4 log-update: 6.1.0 rfdc: 1.4.1 wrap-ansi: 9.0.2 @@ -22933,7 +22421,7 @@ snapshots: ansi-escapes: 7.1.0 cli-cursor: 5.0.0 slice-ansi: 7.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi: 9.0.2 loose-envify@1.4.0: @@ -22988,7 +22476,7 @@ snapshots: '@gar/promise-retry': 1.0.3 '@npmcli/agent': 4.0.0 '@npmcli/redact': 4.0.0 - cacache: 20.0.3 + cacache: 20.0.4 http-cache-semantics: 4.2.0 minipass: 7.1.3 minipass-fetch: 5.0.2 @@ -23028,8 +22516,6 @@ snapshots: mdurl@2.0.0: {} - media-typer@1.1.0: {} - media-typer@1.1.1: {} memoize-one@5.2.1: {} @@ -23110,7 +22596,7 @@ snapshots: metro-minify-terser@0.82.5: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.44.0 + terser: 5.51.2 metro-resolver@0.82.5: dependencies: @@ -23238,10 +22724,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mime-types@3.0.1: - dependencies: - mime-db: 1.54.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -23323,7 +22805,7 @@ snapshots: minipass-sized: 2.0.0 minizlib: 3.1.0 optionalDependencies: - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 minipass-flush@1.0.7: dependencies: @@ -23495,21 +22977,6 @@ snapshots: detect-libc: 2.1.2 optional: true - node-gyp@12.2.0: - dependencies: - env-paths: 2.2.1 - exponential-backoff: 3.1.3 - graceful-fs: 4.2.11 - make-fetch-happen: 15.0.6 - nopt: 9.0.0 - proc-log: 6.1.0 - semver: 7.8.5 - tar: 7.5.7 - tinyglobby: 0.2.17 - which: 6.0.0 - transitivePeerDependencies: - - supports-color - node-gyp@12.4.0: dependencies: env-paths: 2.2.1 @@ -23517,7 +22984,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.2 + semver: 7.8.5 tar: 7.5.22 tinyglobby: 0.2.17 undici: 6.29.1 @@ -23525,8 +22992,6 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.27: {} - node-releases@2.0.59: {} nopt@9.0.0: @@ -23541,7 +23006,7 @@ snapshots: npm-install-checks@8.0.0: dependencies: - semver: 7.7.2 + semver: 7.8.5 npm-normalize-package-bin@5.0.0: {} @@ -23556,7 +23021,7 @@ snapshots: dependencies: hosted-git-info: 9.0.3 proc-log: 5.0.0 - semver: 7.7.2 + semver: 7.8.5 validate-npm-package-name: 6.0.2 npm-packlist@10.0.4: @@ -23569,7 +23034,7 @@ snapshots: npm-install-checks: 8.0.0 npm-normalize-package-bin: 5.0.0 npm-package-arg: 13.0.0 - semver: 7.7.2 + semver: 7.8.5 npm-registry-fetch@19.1.1: dependencies: @@ -23767,8 +23232,6 @@ snapshots: p-map@2.1.0: {} - p-map@7.0.4: {} - p-map@7.1.1: {} p-queue@6.6.2: @@ -23850,10 +23313,6 @@ snapshots: dependencies: entities: 6.0.1 - parse5@8.0.0: - dependencies: - entities: 6.0.1 - parse5@8.0.1: dependencies: entities: 8.1.0 @@ -24082,11 +23541,6 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 @@ -24110,14 +23564,10 @@ snapshots: punycode@2.3.1: {} - pure-rand@6.1.0: {} + pure-rand@8.4.2: {} qrcode-terminal@0.11.0: {} - qs@6.14.1: - dependencies: - side-channel: 1.1.0 - qs@6.16.1: dependencies: es-define-property: 1.0.1 @@ -24150,7 +23600,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 unpipe: 1.0.0 rc@1.2.8: @@ -24595,7 +24045,7 @@ snapshots: resolve@1.22.11: dependencies: - is-core-module: 2.16.1 + is-core-module: 2.17.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -24605,7 +24055,7 @@ snapshots: resolve@2.0.0-next.5: dependencies: - is-core-module: 2.16.1 + is-core-module: 2.17.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -24671,38 +24121,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - rollup@4.64.0: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.64.0 - '@rollup/rollup-android-arm64': 4.64.0 - '@rollup/rollup-darwin-arm64': 4.64.0 - '@rollup/rollup-darwin-x64': 4.64.0 - '@rollup/rollup-freebsd-arm64': 4.64.0 - '@rollup/rollup-freebsd-x64': 4.64.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.64.0 - '@rollup/rollup-linux-arm-musleabihf': 4.64.0 - '@rollup/rollup-linux-arm64-gnu': 4.64.0 - '@rollup/rollup-linux-arm64-musl': 4.64.0 - '@rollup/rollup-linux-loong64-gnu': 4.64.0 - '@rollup/rollup-linux-loong64-musl': 4.64.0 - '@rollup/rollup-linux-ppc64-gnu': 4.64.0 - '@rollup/rollup-linux-ppc64-musl': 4.64.0 - '@rollup/rollup-linux-riscv64-gnu': 4.64.0 - '@rollup/rollup-linux-riscv64-musl': 4.64.0 - '@rollup/rollup-linux-s390x-gnu': 4.64.0 - '@rollup/rollup-linux-x64-gnu': 4.64.0 - '@rollup/rollup-linux-x64-musl': 4.64.0 - '@rollup/rollup-openbsd-x64': 4.64.0 - '@rollup/rollup-openharmony-arm64': 4.64.0 - '@rollup/rollup-win32-arm64-msvc': 4.64.0 - '@rollup/rollup-win32-ia32-msvc': 4.64.0 - '@rollup/rollup-win32-x64-gnu': 4.64.0 - '@rollup/rollup-win32-x64-msvc': 4.64.0 - fsevents: 2.3.3 - rollup@4.64.12: dependencies: '@types/estree': 1.0.9 @@ -24921,22 +24339,6 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.0: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.1 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - send@1.2.1: dependencies: debug: 4.4.3 @@ -24978,15 +24380,6 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.0: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.0 - transitivePeerDependencies: - - supports-color - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -25138,11 +24531,6 @@ snapshots: sherif-windows-arm64: 1.10.0 sherif-windows-x64: 1.10.0 - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -25163,14 +24551,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - side-channel@1.1.1: dependencies: es-errors: 1.3.0 @@ -25276,7 +24656,7 @@ snapshots: socks@2.8.10: dependencies: - ip-address: 10.7.3 + ip-address: 10.7.4 smart-buffer: 4.2.0 solid-js@1.9.11: @@ -25380,13 +24760,13 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string-width@7.2.0: dependencies: emoji-regex: 10.5.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 string-width@8.3.0: dependencies: @@ -25407,7 +24787,7 @@ snapshots: internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.0 + side-channel: 1.1.1 string.prototype.repeat@1.0.0: dependencies: @@ -25453,10 +24833,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - strip-ansi@7.2.0: dependencies: ansi-regex: 6.4.0 @@ -25577,8 +24953,6 @@ snapshots: tailwindcss@4.1.18: {} - tapable@2.2.3: {} - tapable@2.3.3: {} tar-fs@2.1.4: @@ -25604,14 +24978,6 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tar@7.5.7: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - temp-dir@2.0.0: {} temporal-polyfill@0.3.0: @@ -25627,13 +24993,6 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser@5.44.0: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.18.0 - commander: 2.20.3 - source-map-support: 0.5.21 - terser@5.51.2: dependencies: '@jridgewell/source-map': 0.3.11 @@ -25673,8 +25032,8 @@ snapshots: tinyglobby@0.2.14: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyglobby@0.2.17: dependencies: @@ -25767,12 +25126,6 @@ snapshots: type-fest@0.7.1: {} - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.1 - type-is@2.1.0: dependencies: content-type: 2.1.0 @@ -25858,8 +25211,6 @@ snapshots: undici-types@7.16.0: {} - undici@6.23.0: {} - undici@6.29.1: {} undici@7.24.4: {} @@ -25879,14 +25230,6 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} - unique-filename@5.0.0: - dependencies: - unique-slug: 6.0.0 - - unique-slug@6.0.0: - dependencies: - imurmurhash: 0.1.4 - unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -25947,12 +25290,6 @@ snapshots: untildify@4.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.3.4(browserslist@4.29.4): dependencies: browserslist: 4.29.4 @@ -26075,7 +25412,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 postcss: 8.5.31 - rollup: 4.64.0 + rollup: 4.64.12 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.2.2 @@ -26222,7 +25559,7 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - browserslist: 4.28.1 + browserslist: 4.29.4 chrome-trace-event: 1.0.4 enhanced-resolve: 5.26.0 es-module-lexer: 2.3.2 @@ -26260,7 +25597,7 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - browserslist: 4.28.1 + browserslist: 4.29.4 chrome-trace-event: 1.0.4 enhanced-resolve: 5.26.0 es-module-lexer: 2.3.2 @@ -26361,10 +25698,6 @@ snapshots: dependencies: isexe: 2.0.0 - which@6.0.0: - dependencies: - isexe: 3.1.5 - which@6.0.1: dependencies: isexe: 4.0.0 @@ -26418,13 +25751,13 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrappy@1.0.2: {} From 0e80a6f53ebcc09d8a11be6e865ac73e1dbe2098 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Nov 2026 16:06:05 -0700 Subject: [PATCH 003/168] test(oracle-gaps): bound each run, size contexts to the machine, and sample sites The gap-density survey ran every db oracle through the gap report. A hung run stalled a context, so each run now has a wall-clock limit and a replaced context, and results are written as they arrive. The default context count follows the available parallelism, --sample draws a seeded subset of sites, sites are numbered across source files, labels use paths, and the report adds a reach summary. Co-authored-by: Isaac --- scripts/oracle-gaps/index.mjs | 216 +++++++++++++++++++++++++------ scripts/oracle-gaps/schemata.mjs | 10 +- 2 files changed, 186 insertions(+), 40 deletions(-) diff --git a/scripts/oracle-gaps/index.mjs b/scripts/oracle-gaps/index.mjs index ee6e9414b7..9d5a17d8ef 100644 --- a/scripts/oracle-gaps/index.mjs +++ b/scripts/oracle-gaps/index.mjs @@ -44,11 +44,15 @@ // node scripts/oracle-gaps/index.mjs --package --oracles // --source [--tests ] [--seed ] // [--diff-base ] [--out ] [--only ] -// [--test-timeout ] (default 2000; also bounds hooks and teardown) +// [--test-timeout ] (default 15000; also bounds hooks and teardown. +// Each mutant run also has a wall-clock limit of +// 10 s plus three times its files' unmutated time.) +// [--sample ] (a fixed-seed sample of n mutants per source) // [--contexts ] (warm contexts, one worker each; default // the machine's parallelism minus 2, at most 8) import { spawnSync } from 'node:child_process' import { + appendFileSync, copyFileSync, existsSync, mkdirSync, @@ -90,9 +94,12 @@ function parseArgs(argv) { diffBase: args[`diff-base`], out: resolve(args.out ?? `/tmp/oracle-gaps`), only: args.only ? new Set(args.only.split(`,`)) : undefined, + // A fixed-seed random sample of this many mutants per source file, for + // surveys across many modules. + sample: args.sample ? Number(args.sample) : undefined, // A mutant can make a test hang. Oracles that run in milliseconds do not // need Vitest's default limits, which would make each hang cost seconds. - testTimeout: Number(args[`test-timeout`] ?? 2000), + testTimeout: Number(args[`test-timeout`] ?? 15000), } } @@ -106,7 +113,13 @@ const instrumented = {} const mutants = [] for (const source of options.sources) { const path = realpathSync(join(options.pkg, source)) - const result = instrument(ts, path, readFileSync(path, `utf8`), source) + const result = instrument( + ts, + path, + readFileSync(path, `utf8`), + source, + mutants.length, + ) instrumented[path] = result.code mutants.push(...result.mutants) } @@ -165,7 +178,17 @@ function diffLines(source) { } let runCounter = 0 -async function run(vitest, specs, { mutant = null, select = null } = {}) { +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// A mutant can hang a run outside any test, in a hook or teardown, where no +// test timeout applies. A run with a time limit that passes it is cancelled +// and reported as timed out; its context is then replaced (see the mutant +// loop), because a cancelled run can leave work behind in its workers. +async function run( + vitest, + specs, + { mutant = null, select = null, limitMs } = {}, +) { const capture = join(options.out, `run-${++runCounter}.jsonl`) const state = { mutant, select: select && [...select], capture } // Vitest 3 and 4 both expose the root project's `provide`; the state file @@ -173,7 +196,31 @@ async function run(vitest, specs, { mutant = null, select = null } = {}) { vitest.getRootProject?.().provide(`oracleGaps`, state) writeFileSync(stateFile, JSON.stringify(state)) const started = Date.now() - const result = await vitest.runTestSpecifications(specs, false) + const running = vitest.runTestSpecifications(specs, false) + let timer + const result = + limitMs === undefined + ? await running + : await Promise.race([ + running, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), limitMs) + }), + ]) + clearTimeout(timer) + if (result === undefined) { + await Promise.race([ + vitest.cancelCurrentRun(`keyboard-input`).catch(() => undefined), + sleep(5000), + ]) + return { + timedOut: true, + result: { testModules: [], unhandledErrors: [] }, + modules: [], + rows: new Map(), + seconds: (Date.now() - started) / 1000, + } + } // The result also lists test modules from earlier runs that this run did // not execute, with their old results. Only this run's files count. const ran = new Set(specs.map((spec) => spec.moduleId)) @@ -199,7 +246,8 @@ async function run(vitest, specs, { mutant = null, select = null } = {}) { // (ORC-006 "runtime failure after setup outside the intended comparison"). // An error outside any test is `setup-error`. Typed verdicts will make this // exact; for now the error text decides. -function outcomeOf({ result, modules }) { +function outcomeOf({ result, modules, timedOut }) { + if (timedOut) return { outcome: `timeout`, detail: `run time limit` } const failures = [] for (const module of modules) { for (const error of module.errors?.() ?? []) @@ -273,37 +321,94 @@ const newContext = () => coverage: { enabled: false }, reporters: [{}], }) -try { - for (let i = 0; i < options.contexts; i += 1) { - const context = await newContext() - // Vitest 3 calls this step `init`; Vitest 4 renamed it `standalone`. - await (context.standalone ? context.standalone() : context.init()) - contexts.push(context) - } - const vitest = contexts[0] - const fileSpecs = new Map() +// Each context's specs are its own objects, so specs are looked up per +// context by file. +const contextSpecs = new Map() +async function warmContext() { + const context = await newContext() + // Vitest 3 calls this step `init`; Vitest 4 renamed it `standalone`. + await (context.standalone ? context.standalone() : context.init()) + const map = new Map() for (const file of [...options.oracles, ...options.tests]) { // Filters resolve against the working directory, not `root`, so each // file is passed by its absolute path. const path = realpathSync(join(options.pkg, file)) - const specs = await vitest.globTestSpecifications([path]) + const specs = await context.globTestSpecifications([path]) if (specs.length === 0) throw new Error(`no test file matches ${file}`) - fileSpecs.set(path, specs) + map.set(path, specs) } + contextSpecs.set(context, map) + return context +} +try { + for (let i = 0; i < options.contexts; i += 1) + contexts.push(await warmContext()) + const vitest = contexts[0] + const fileSpecs = contextSpecs.get(vitest) const oracleFiles = new Set( options.oracles.map((f) => realpathSync(join(options.pkg, f))), ) const fileOf = (key) => key.split(` > `)[0] - const allSpecs = [...fileSpecs.values()].flat() - // 2. Two unmutated runs. - const first = await run(vitest, allSpecs) - const firstOutcome = outcomeOf(first) - if (firstOutcome.outcome !== `survived`) - throw new Error( - `unmutated run did not pass: ${JSON.stringify(firstOutcome)}`, + // 2. Two unmutated runs, with the files split across the contexts. A file + // that fails with no mutant active is left out of the report: it cannot + // detect anything, and it may read source text that instrumentation changes. + // Each file's unmutated duration, which sets a mutant run's time limit. + const fileSeconds = new Map() + const limitFor = (keys) => + 10_000 + + 3000 * + [...new Set(keys.map(fileOf))].reduce( + (sum, file) => sum + (fileSeconds.get(file) ?? 10), + 0, + ) + const runAcross = async (files) => { + const parts = contexts.map(() => []) + files.forEach((file, index) => parts[index % contexts.length].push(file)) + const runs = await Promise.all( + contexts.map((context, index) => + run( + context, + parts[index].flatMap((file) => contextSpecs.get(context).get(file)), + ), + ), ) - const second = await run(vitest, allSpecs) + const rows = new Map(runs.flatMap((r) => [...r.rows])) + for (const r of runs) + for (const module of r.modules) + fileSeconds.set( + module.moduleId, + Math.max( + fileSeconds.get(module.moduleId) ?? 0, + (module.diagnostic?.().duration ?? 0) / 1000, + ), + ) + const failed = new Set() + for (const r of runs) + for (const module of r.modules) + if ( + outcomeOf({ result: r.result, modules: [module] }).outcome !== + `survived` + ) + failed.add(module.moduleId) + return { rows, failed, seconds: Math.max(...runs.map((r) => r.seconds)) } + } + let files = [...fileSpecs.keys()] + const first = await runAcross(files) + const excluded = [...first.failed] + if (excluded.length > 0) { + console.error( + `left out, failing with no mutant active: ${excluded.join(`, `)}`, + ) + files = files.filter((file) => !first.failed.has(file)) + for (const file of excluded) { + fileSpecs.delete(file) + oracleFiles.delete(file) + for (const key of [...first.rows.keys()]) + if (fileOf(key) === file) first.rows.delete(key) + } + } + const second = await runAcross(files) const nondeterministic = new Set() for (const [key, row] of first.rows) { const again = second.rows.get(key) @@ -330,6 +435,17 @@ try { outcomeSeen.set(id, [seen[0] || truthy, seen[1] || falsy]) } } + // Reach over every site, by source, so a sampled survey still reports + // exact reach. + const reach = Object.fromEntries( + options.sources.map((source) => { + const own = mutants.filter((m) => m.source === source) + const reached = own.filter((m) => + (reachedBy.get(m.number) ?? []).some((k) => oracleFiles.has(fileOf(k))), + ) + return [source, { sites: own.length, reachedByAnOracle: reached.length }] + }), + ) const missingOutcomeLines = new Set() for (const mutant of mutants) { if (mutant.operator !== `if-true`) continue @@ -342,17 +458,26 @@ try { const diffs = new Map(options.sources.map((s) => [s, diffLines(s)])) const results = [] const started = Date.now() - // Each context's specs are its own objects, so specs are looked up per - // context by file. - const contextSpecs = new Map([[vitest, fileSpecs]]) - for (const context of contexts.slice(1)) { - const map = new Map() - for (const path of fileSpecs.keys()) - map.set(path, await context.globTestSpecifications([path])) - contextSpecs.set(context, map) + let queue = mutants.filter((m) => !options.only || options.only.has(m.id)) + if (options.sample !== undefined) { + // A fixed-seed shuffle, so a survey samples the same mutants every run. + let seed = 20251102 + const random = () => + (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31 + queue = options.sources.flatMap((source) => { + const own = queue.filter((m) => m.source === source) + for (let i = own.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)) + ;[own[i], own[j]] = [own[j], own[i]] + } + return own.slice(0, options.sample) + }) } - const queue = mutants.filter((m) => !options.only || options.only.has(m.id)) + // Results are appended as they finish, so a stopped survey keeps them. + const partial = join(options.out, `results.jsonl`) + rmSync(partial, { force: true }) async function evaluate(mutant, vitest) { + let replace = false const specsFor = (keys) => [...new Set(keys.map(fileOf))].flatMap( (f) => contextSpecs.get(vitest).get(f) ?? [], @@ -371,7 +496,9 @@ try { if (oracleKeys.length > 0) { const mutated = await run(vitest, specsFor(oracleKeys), { mutant: mutant.number, + limitMs: limitFor(oracleKeys), }) + if (mutated.timedOut) replace = true Object.assign(result, outcomeOf(mutated), { seconds: mutated.seconds }) if (result.outcome === `survived`) { const compared = [...mutated.rows.keys()].filter( @@ -400,7 +527,9 @@ try { ) { const tier = await run(vitest, specsFor(testKeys), { mutant: mutant.number, + limitMs: limitFor(testKeys), }) + if (tier.timedOut) replace = true result.testOutcome = outcomeOf(tier).outcome // A crash in an ordinary test is a detection, as it is for an oracle. if ( @@ -410,14 +539,25 @@ try { result.outcome = `killed-by-ordinary-test` } results.push(result) + appendFileSync(partial, `${JSON.stringify(result)}\n`) console.error( `${mutant.id} L${mutant.line} ${mutant.operator} ${result.outcome}${result.class ? ` (${result.class})` : ``}${result.testOutcome ? ` [ordinary: ${result.testOutcome}]` : ``}`, ) + return replace } await Promise.all( - contexts.map(async (context) => { - for (let mutant = queue.shift(); mutant; mutant = queue.shift()) - await evaluate(mutant, context) + contexts.map(async (initial, slot) => { + let context = initial + for (let mutant = queue.shift(); mutant; mutant = queue.shift()) { + if (!(await evaluate(mutant, context))) continue + // A cancelled run can leave work behind, so its context is replaced. + await Promise.race([ + context.close().catch(() => undefined), + sleep(10_000), + ]) + context = await warmContext() + contexts[slot] = context + } }), ) results.sort( @@ -432,6 +572,8 @@ try { JSON.stringify( { options: { ...options, only: undefined }, + excluded, + reach, nondeterministic: [...nondeterministic], mutantSeconds, results, diff --git a/scripts/oracle-gaps/schemata.mjs b/scripts/oracle-gaps/schemata.mjs index 259fac641b..2fc6671185 100644 --- a/scripts/oracle-gaps/schemata.mjs +++ b/scripts/oracle-gaps/schemata.mjs @@ -47,16 +47,20 @@ function functionName(ts, node) { return undefined } -export function instrument(ts, path, text, source) { +// `offset` keeps site numbers unique across every instrumented source file, +// because one switch serves them all. +export function instrument(ts, path, text, source, offset = 0) { const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) const lineOf = (pos) => file.getLineAndCharacterOfPosition(pos).line + 1 const mutants = [] // `at` is the node whose line the mutant reports: the operator token for a // swap and the condition for an `if`, as the earlier runner reported them. const add = (at, operator, fn, original, replacement) => { - const id = mutants.length + 1 + const local = mutants.length + 1 + const id = offset + local mutants.push({ - id: `${source.split(`/`).pop()}:M${id}`, + // The path below src/ keeps labels unique across files with one name. + id: `${source.replace(/^src\//, ``)}:M${local}`, number: id, source, line: lineOf(at.getStart(file)), From 1d7d7bbb65ad29db41775822856764f7868a83f2 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Nov 2026 16:52:22 -0700 Subject: [PATCH 004/168] test(oracles): move the hydration oracle onto oracleProperty with generated histories oracleProperty gains what the first generated migration needed: - Reach from the model: `reached(history, trace, expected)`, for states only the model can name; the driver's `observe('reach')` stays the default. - Declared states: every state in `states` needs a segment (ORC-015 with D21); `measured` states are counted only; a reached undeclared state fails; each lane prints a count for every declared state, through stdout because Vitest hides console output from passing tests. - Segments stay in the generated language, are rebuilt from the lane's seed so a seed-and-path replay reproduces them, and build half of each lane's histories, so the histories after each required state are explored. - Failure fidelity, from a loss audit of the migrations: a driver error after a violated checkpoint no longer hides the violation (it becomes the cause); a violation can carry actual and expected values for Vitest's diff; rerun commands escape the test name (an unescaped `|` selected 543 of 1,448). live-query-observer.hydration-terminal: the driver records each step's Collection status and rows as input and the observer's view and announcements as output; the model reads only inputs; the check judges laws `view` and `announcement`. Five prefixes plus three new ones are segments; the three replays in both modes are pins. Evidence (migration, no production change): - Mutants of src/live-query-observer.ts (seed hides failure; failure never ends the seed; late granular listener unseeded): the original and the migrated oracle fail the same 5/9, 9/9, 5/9 tests. - Gap report on src/live-query-observer.ts, seed 42: identical outcome for every site (82 killed, 53 generator-gap, 89 no-observed-difference, 45 unreached). Before segments joined the generated histories, 5 sites fell to unreached. - A reported seed and path replays the same counterexample. - Driver-error masking: a mutant that violates then throws in dispose() reported the raw error before (RED) and the violation with that error as its cause after (GREEN). - Limits: one source file and one gap-report seed; the old fixed seeds now draw different histories; histories cap at 10 steps (the old prefixed ones reached 13). The write-order and peers checks judge completeness last and give diff values; write-order restores the prose the loss audit found missing. The coverage map records the prompt-rejection witness gap for write order. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 4 +- ...observer-hydration-oracle.property.test.ts | 551 ++++++++++++------ .../tests/local-storage-peers-oracle.test.ts | 4 + .../local-storage-write-order-oracle.test.ts | 50 +- packages/db/tests/oracle-config.ts | 1 + packages/db/tests/oracle-property.ts | 460 +++++++++++++-- 6 files changed, 816 insertions(+), 254 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 8fdfa3b984..cf5b6b74f1 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1848,7 +1848,7 @@ generic visitor behavior outside the named collectors. | Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, and delete through handler parameter and mutation-alias access. All calls explicitly refetch; former `refetch`/`clearError` coordinates remain only as historical replay labels, preserving all 12 identity and 12 mutation-overlap executions. The ordinary [health scenarios](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.test.ts) observe exact public causes and settlement. A held refetch retains the current public error. A failed initial load, including a failed initial Collection application, remains a loading error for that sync run regardless of later fetch results. After readiness, a pending retry retains the prior failure reason; failure reports the later cause, and successful result application clears it. Query retry attempts and an older held success are crossed with newer failures, including reuse of one Error value in one clock tick. These scenarios supplement the oracle; they do not claim removed utility fields or error counts. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session) | -| Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. Generated histories cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart, with declared reach; replays pin the crash, fail-then-cleanup, and crash-before-attach traces. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests; the property is not in the `oracle-config.ts` path-replay registry. | +| Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. The `live-query-observer.hydration-terminal` property (`oracleProperty`, in the path-replay registry) generates histories of up to 10 steps that cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart. Its 11 declared states are model states (a status while the seed is active, attached or detached; attach to a failure; cleanup after a failure ended the seed; restart under the seed). Eight segments enter them, so every fixed and random lane reaches every state, and half of each lane's histories start with a segment; each lane prints its reach counts. Pins replay the crash, fail-then-cleanup, and crash-before-attach traces in both modes. The check judges laws `view` (status, flags, claimed rows) and `announcement` (last-read snapshot, granular rebuilt rows). The migration from the hand-built campaign kept every mutant kill and every gap-report outcome on `src/live-query-observer.ts` (seed 42); the old prefixed histories ran up to 13 steps. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | | Query DB ordered refetch | `packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts` | Issue #2136: after preload, and after `utils.refetch()` or a Query invalidation settles, an on-demand Query Collection behind a LEFT-joined, ordered, limited live query must show the window over the current backend rows. The design under test: query-db reports `invalidateLoadedSubsets()` when an ordered page changes keys and either has a later page or ends earlier in the order than before, and each ordered loader runs its bounded prefix repair. Its model is a pure top-k of marked backend rows. A fixed and a random campaign generate rounds of marker flips (which serve continuation pages), backend changes while the provider lags, a second live query (`observe`) whose own demand can load a page's new row first, optional Query invalidation with further changes, and a refetch, across one or two order terms, a numeric or locale-collated string order, limits 1–3, offsets 0–1, and a static or function Query key. With an unchanged backend, a repeated refetch must serve no more rows, start at most one first-page request, and report nothing. A refetch or invalidation must never start a new full-source request. A coverage law runs at the same checkpoints: every backend row from the start of the order through the window's last row (every row, when the window is short) must be in the source Collection with its current values, so a latent gap fails before a marker exposes it; a named replay of the issue trace with the moved row unmarked passes the window law and fails only the coverage law when Query DB never reports. The grammar can hold an optimistic update across a refetch, with the checkpoint moved to its release, and a premise-directed campaign under a hold (other rows observed, a row moved ahead of the page's end, a new row further ahead, a write to the last-sorting observed row) requires a report during a hold in every case and fails a skip that reads rows held back behind the transaction; the general campaigns did not. The general grammar holds optimistic deletes as well as updates. The generated campaigns declare reach (minimum 3) for a lagging refetch after a continuation, one during a hold, and a refetch that reported while the window was full, where the loader decides between ignoring the report and repairing. A named replay of a refetch during a held delete left the window short after release, also with this PR's report and overlap changes reverted, until the fix that resumes a continuation after a synchronous answer; no window is checked during a hold, where #2148 and #2169 remain open. Reports (`loadedSubsets:invalidated`) are bounded: a step reports at most once, and only when some ordered request was answered again with different rows; named replays require no report when a one-page window, or a window's last page, only loses a row, and a sync-boundary replay requires a report when a page ending on a row without an order value has its end move earlier (on the earlier gate, which read that page as empty, it reported nothing), and another requires a report when the provider returns a one-page window's rows in reverse order (the earlier gate took the last array row as the page's end). When a page's end moves earlier, Query DB reports. A loader whose window lies inside its own first page skips the repair only while its page read past the window or it read the tie of the current end; after an insert into a top-N list moves the end, a named replay requires the loader to prove the window again with bounded reads, no full-source read, and an unchanged Query count. A filtered one-page window whose page end moves past an unseen row must still repair (found by the random campaign; a loader that always skips fails it, the f9 tie replay, and the ordered-work oracle's signalling replays). The skip also refuses while the source holds accepted sync rows back: a replay refetches while an optimistic update persists, so the report arrives before the refetched rows are visible, and a skip that reads those older rows fails it. The ordered loader state oracle checks that a prefix read issued before an ordering invalidation, which settles after the bounded repair, does not retire the repair's prefix; the repair-first order failed before that fix. A `loadSubset` call that acquires a released, invalidated page can report, because Query then refetches that page on its own. A failed repair shows only the last window or the right one. Named replays cover the issue trace after a refetch and after an invalidation, with a page-capping provider, with another live query loading the page's new row first, with an empty later page, with a page whose end moves earlier while a tie request holds its old last row, a second refetch after an invalidation, a repair failure, and a provider that honors neither cursor nor offset. Never signalling, the old overlap-to-full-source rule, ignoring empty later pages, reporting on any page change, removing the repair loop guard, and reporting once per page each fail it by assertion. A replay makes a page's application fail and checks the window shows its last result or the right one, then the next refetch restores it; recording a page before its rows are accepted cannot be distinguished, because the failed attempt reports on its own. Open: the report laws bound reports but do not require each one; a page whose end moves earlier reports even with unique order values, where no row can be missing; it does not check whether a repair waits for in-flight work; the generated campaigns use an uncapped cursor provider only (an offset-only provider and a page-capping provider each hit a loader defect that reproduces on main: #2182, #2183); the generated campaigns do not check the window after a marker flip (a named replay checks the trace where the loader stopped refilling after a joined-side removal, which the repair-retirement fix below closed); a failed repair still recovers through the loader's full-source retry; interval, focus, and reconnect refetches share the invalidation path but are not driven; NaN order values have no executable witness; overlapping `refetch: true` acquisitions on one Query hash are counted, so the first to finish does not clear the loop guard for the other, but no witness separates that from a plain set; a Query refetch that joins a running `refetch: true` acquisition on the same hash does not report, and no witness reaches it (#2185); follower tabs of a multi-tab persisted collection never receive the report (#2174). The offset-only and page-capping loader defects have `it.fails` witnesses in the oracle. A finished bounded repair used to discard its boundary when its retirement made Query DB remove a row the loader had sent; the loader then restarted paging with a smaller first page that could not retire the repair's prefix, so every later refetch read both. A named replay found by the random campaign (at four times the run count) and an ordered loader state witness fail on that, and the fix also closed the joined-side refill defect. At four times the run count, the random campaign found a short window that stopped paging before the last row of the order: a report's repair released continuation Queries, and Query DB answered their reacquisition synchronously from its cache, so the graph turn inside each request asked for more while the loader was still requesting and nothing resumed the staged continuation. Only the coverage law failed. A named replay and an ordered loader state witness (a synchronous answer whose writes the graph already processed must continue the chain) fail on that, and pass with the fix. The loader side of the report also has a second owner: the ordered-work oracle's `refetch-signal` provider re-runs each served request alone and reports when any answer changed, across that oracle's generated plans, and a signalling refetch must start no full-source request unless a bounded page in that step ended on a null or NaN rank, which takes the documented full-source fallback (a random seed found that case). Never signalling and the old overlap-to-full-source rule each fail it. It excludes a peer live query, which can keep a short window after a refetch (#2155). The persisted-collection oracle checks that a source's report reaches the Collection through the managed cache-generation wrapper and that a retired source session's report is dropped. Remaining work for this class is tracked in #2186. | | Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md), [eager ordered-window repair review](oracle-reviews/perf-ordered-window-repair.md), [reversed index reads](https://github.com/TanStack/db/blob/main/packages/db/tests/reverse-index-oracle.property.test.ts) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. The ordered-work oracle also checks an eager indexed window over generated upserts and deletes: the rows equal the filtered, sorted model, and each change delivers a bounded number of source rows; an unbounded or always-full repair read fails it. Its filter dimension adds a function filter and a LEFT-joined filter, and a campaign fixed to the joined premise checks that a window refilled past a distant row the query already received holds the first eligible rows. Joined filters also run as anti-joins, change eligibility through the marker or the root row's join key, and drive an Effect beside the Collection. A full-join filter keeps every root row and adds a null-ranked rootless row for each marker no root row matches; on main a bounded root prefix showed markers whose root rows were not yet loaded as unmatched rows, and restoring the old inner-or-right full-source rule fails eight tests. Null placement is a generated dimension, with the index built to match, and a query that ignores it fails six tests. A full-join campaign has few or no filler rows, a matched null-ranked root row and at least one orphan marker in every case, so its windows reach the null group where rootless rows and null-ranked root rows tie; a reconstruction check samples exactly the cases its fixed-seed run executes and requires such windows (19 rootless and 7 mixed null-group windows of 40 at one times the run count), and the Effect runs beside the Collection. Full joins read their full source, so only the rows law covers them; paging them under the old join rule fails the window law. Pinned cases cover a null-ranked root row past a prefix that ends in the null group. Paging a nulls-last full join passed this oracle but was not adopted: it cost more per delete when the window reached the null group, which no work law here measures, and #2130 tracks full-join paging. Joined filters can match a row through several markers, and the model keeps one result row per matched pair; a model that collapses them fails. The eager harness also runs an on-demand source whose provider follows the LoadSubsetOptions contract (offset only without a cursor, the applied receipt returned). Its test provider combines a sync adapter and its provider (glossary model alignment rule 2); a held request is a physical acquisition whose provider answer is delayed, a served range is the prefix and boundary one answer establishes (a model abstraction), and the peer is a second live-query Collection. The provider sorts by the requested order and keeps its own ledger of sent rows. A forwarding provider sends live changes only for rows it sent or rows that match a served subset, and its served rows obey the work bound; a loader that asks for 100 extra rows fails it. A refetching provider, like query-db-collection, sends nothing until a refetch. The refetch keeps every served ordered range whole, as the LoadSubset contract requires: it sends every row from the start of the order through the last order value a limited page returned, and rows in no range leave; the rows law over the model applies once no change is pending. After every step on either provider, the Collection must hold exactly the rows in the provider's ledger, and the query and the Effect must show their window over those rows; a mutant that drops marker removals fails this law at steps 0 and 1 of the fixed refetching campaigns, earlier than the model's rows law, and in one more campaign. A holding provider answers each load only at an `answer` step, from the rows at that moment, so live changes arrive while loads are in flight. While a load is in flight, the query and its peer either keep the window they showed at the previous checkpoint or show distinct ledger rows that pass the query, in its order; the Effect is not checked then. That is weaker than the architecture's barrier rule, under which a repair or window move keeps the last public snapshot until it settles: a barrier law fails today on on-demand joined plans, where a visible-delete repair and a held `setWindow` publish before they settle (#2131; the law and traces are kept for that fix). The provider records a request as served only once it answers it, and an abort (or an adapter unload) of a held request follows `LoadSubsetOptions.signal`: a cooperative provider installs none of its rows and its load fulfills at once, while a provider that cannot cancel in-flight work (`late` cancellation, generated) answers it anyway and its load fulfills after those rows become visible. A `release-peer` step cleans up the peer while its loads are held, which aborts them; each request counts once although its acquisition release both aborts and unloads it. The fixed held campaign reaches 5 aborted held requests and 1 late answer, and its reconstruction check requires at least 3 cooperative cancellations and 1 late answer. A held load rejects when its applied receipt rejects, and the step then fails, because no signal or cleanup rejects a receipt in these histories; that branch is unwitnessed. The held campaign pins a forwarding provider, so `release-peer` never reaches a refetching provider; that branch is unwitnessed. A request whose signal is already aborted when the provider receives it is never reached; that branch is unwitnessed. The held campaign uses a forwarding provider and an indexed source filter or a joined filter, deletes three visible rows and then upserts an eligible row with an id no other command uses; its reconstruction check runs in the same test as the fixed-seed campaign and reads its per-case counts: 35 of 40 cases issue a held request, and every one of them changes rows while it is in flight. Loading must reach a fixed point: past a request budget the provider rejects and records it, and the initial load, every step and the end of each history assert the record is clear (a loader that re-requests a prefix it already holds can loop without reaching the provider, which this cannot see). A bounded step makes at most four provider requests, and each consumer's initial proven-prefix load is bounded separately, in total transfers (a repeated request for the same prefix counts) and in distinct rows (at most the source's size); a loader that asks for 100 extra rows on every page fails that bound and the initial-load work law. Loader mutants that never resume after a boundary request, or that ignore a load request while another load is pending, fail it; a mutant that drops a window move blocked behind a pending load still survives, even with the settlement check on held moves; whether it is equivalent is open. Open (#2148): on an on-demand source with a joined or anti-joined filter, an optimistic delete of the visible row leaves the window short until the delete persists, because the next row is never loaded. On an on-demand source with a source filter, and on an eager source with any filter, the same delete refills the window correctly; these are the contrast cases. Owner: this oracle. Needed witness: the optimistic-delete dimension (kept as a patch for #2148) in the grammar, plus a pinned replay of the anti-join trace. A refetch that re-runs each page alone, as query-db-collection does today, leaves a row that moved between two pages unloaded; random seeds found that trace, a named replay fails on it, and a query-db receiving witness reproduces it (#2136 tracks the provider fix). An Electric-style provider, which skips updates to rows it never sent, is not modeled; a mocked-stream witness shows a row moving into a served ordered range never arriving under the default replica mode (#2138). At one times the run count, the campaigns reach 87 refetch steps: 39 change the window, 6 remove a visible row, and 13 lead to a further provider request. A random seed found a refetch that empties a joined window, after which the loader refills it over several page requests and publishes `[]`, one row, then the final rows; a named replay keeps that trace for #2131. No incorrect final window has appeared on that path, and no loader mutant tried so far is caught only by it; the work law does not cover it. For an on-demand joined or anti-joined filter, the initial load must not request the whole source when the first window's worth of source rows in query order all pass the join and no boundary value is null or NaN; 35 executed cases meet that premise, and a compiler that reads the whole source for every joined filter fails eight tests on this law alone. The rows served by that initial load stay within the prefix, its boundary ties and a refill, for each consumer. Later steps of those plans have no work bound. An unindexed join filter over it left the window empty on main, until the plan counted the rows it still needs; restoring the old guard fails the campaigns and a pinned replay. A step that changes the visible rows must publish, and its subscriber starts with every visible key, so a removal-only change reaches it; mutants that drop update-only or removal-only publications fail. A step may publish one intermediate window per provider request it made, and only its last publication must show the final window, because a repair or refill can publish after each round trip (#2131); the content of those intermediate windows is unchecked. The model keeps its own copy of the markers, and full-join histories can insert the root rows of orphan markers. A premise-directed campaign inserts a fresh row and then changes an initial row's eligibility alone, so the row leaves the window only through the join; it and a named replay fail when a window row past the loader's settled boundary counts toward a full window. One case in four adds a second live query over the same sources with a larger window, and its rows law holds at every checkpoint; the work laws do not apply to those cases. #2155: a second live-query Collection over a refetching on-demand provider kept a short joined window after a refetch removed a tied row (the peer showed ranks `[0, 0]` where the model shows `[0, 0, 100]`). The loader fix that resumes a staged continuation after a synchronous answer the graph already processed closed that trace. The pinned replay of that indexed trace now expects the right window. Still open: an unindexed peer over a refetching provider keeps a short joined window (the peer shows `[100]` where the model shows `[100, 101]`), which also fails without that fix; each campaign therefore still excludes the peer and refetching pairing, as its last map. Owner: this oracle. Needed witness: a pinned replay of the unindexed trace flipped to pass, and the pairing restored to the grammar. Limits: Effects are checked for rows after each step, not for publication counts or positions. Histories move the first query's window with `setWindow` (150 moves at one times the run count, 56 on demand), and its rows law and ledger law then follow the moved window; the Effect and the peer keep theirs. On a holding provider every window-move promise must have fulfilled after the final drain. A refetching provider models query-db's unload: it drops the unloaded request and removes rows that no remaining request's last answer holds. The per-step work law applies only to forwarding, immediate, single-consumer cases: it skips refetching, holding and peer cases and move steps; a two-term indexed move scanned the source four times, which no law here bounds. Multi-term joined plans stay outside this harness, on both paths; on the on-demand path, the provider's transfer is bounded only for indexed single-term source-predicate plans. Joined windows compare order values, so a wrong row tied with the expected one passes; a window row tied with the boundary counts as proven before its tie request settles, which no witness exercises. A boundary that cannot advance past a window row would repeat one page per new key; reading the boundary's range locally should always reach that row, but no witness shows it. Joined ties are compared as any valid first rows; whether source-filter ties may be fixed to the source key is open. The same grammar includes NaN and null ranks and a second-order term, and bounds both rows read and rows delivered, including the initial load and index refills; a scan law limits each change to one pass over the source. A pinned case checks that an indexed descending window places nulls first, and a lifecycle witness checks that an eager full-source window is ready with its rows at creation when its source answers synchronously. The initial fallback cells check indexed and unindexed locale strings with distinct and tied values, plus a null boundary after a warm ordered request. The warm null-boundary case crosses preload-started and constructor-started live-query sync runs: the former completes at the synchronous cut, while the latter resumes deferred on-demand acquisition asynchronously and publishes after settlement. They require an unfiltered full-source request, no public row at fallback entry, and the expected row and status at the immediate readiness check. A Promise fallback stays loading at that checkpoint. A React receiving witness checks an eager locale-string `findOne()` at the first layout commit. A Query DB receiving witness checks warm cached subsets: both raw adapter calls return literal `true` with the source rows visible at each return, and the live query is immediately ready without a fetch. The same fallback cells observe subscriber changes from before the first source write: literal-true and Promise fallbacks each publish one nonempty final-window insert after the fallback settles, and the callback sees the complete row. Promise cells show no nonempty event at the immediate cut; a duplicate-event fault fails all eight cells. Other adapters and a real on-demand React first commit remain unwitnessed. Full-source work after initial publication and repair remain outside this synchronous guarantee. A separate post-publication oracle crosses marker deletion and retargeting with literal, Promise, and held-applied-receipt fallback responses. It also settles the child demand before holding the receipt, so the public live-query loading flag isolates the root fallback. The model checks final rows and net events, while event replay checks every callback snapshot; a retained or empty intermediate window is allowed. A loader fault that ignores the pending receipt fails at the held loading-flag check. Two literal-true cleanup-failure histories require that the replacement still publishes and the adapter error reaches the public query status without making the unrelated marker commit throw; the provider's applied receipts are recorded and compared outside provider callbacks. The Promise case checks eventual coherence on an asynchronous callback path, without claiming a separate timing cut. Four held-Promise histories apply fallback writes, change another join marker before provider settlement, and compare each callback and the final winner; an injected stale extra publication fails the event comparison. Other marker actions and overlapping root-source changes remain open; the provider has no later write or rejection, so this schedule does not establish when the loader claims full-source authority. The reversed-index oracle checks every reversed read's order and filter work for both null placements. | @@ -1882,7 +1882,7 @@ generic visitor behavior outside the named collectors. | Live-query notification algebra | [real-query and checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/notification-laws-oracle.test.ts), [independent Map checker](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts) | One-key synchronous source histories check exact live-query callback reconstruction and unsubscribe silence. Synthetic multi-key histories calibrate the checker; direct source Collection events remain with the change-event history owner. | | Flat-row change tracking | [flat change tracking oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/flat-change-tracking-oracle.property.test.ts) | Generated callbacks over flat rows, frozen or not and with or without a non-enumerable field (assignments of `0`, `-0`, `NaN`, `undefined`, functions, and fresh objects; reverts; deletions; added fields; data and accessor `defineProperty`; stored drafts; throws), run through the flat tracker, the draft proxy, and an independent model, which must report the same change sets with `0` and `-0` equal, or throw the same error and leave the rows unchanged. Defining a field acts as assigning it, and a non-enumerable field is row data only once written; the proxy broke three of those laws on `main` and the flat tracker one. Diffs using `!==`, `Object.is` alone, or no deletions fail it. It found that the proxy dropped a field added as `undefined` when another field reverted; the pinned history and both campaigns fail without that fix. Non-flat rows must fall back. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | | Virtual props cache | [cache laws](https://github.com/TanStack/db/blob/main/packages/db/tests/virtual-props-cache.test.ts) | Focused laws for the per-key cache of rows read with virtual props: a change's published value is the row later `get` and `toArray` reads return, for every subscriber, after sync and local writes, and a key that is deleted or rolled back leaves the cache. Enriching the value before the previous value fails the first two; skipping the drop on delete events fails the third. Rows read with virtual props through other paths, such as joins, are outside these laws. | -| LocalStorage Collection | [write order over every short history](../../packages/db/tests/local-storage-write-order-oracle.test.ts) (`local-storage.write-order`), [peers over every short history](../../packages/db/tests/local-storage-peers-oracle.test.ts) (`local-storage.peers`), [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | The `local-storage.write-order` property runs every history of one to three automatic updates and deletes of two stored rows (1,448 histories), every handler completion order, and every decision vector. After each handler finishes, no accepted write may have settled ahead of an undecided predecessor, and storage may hold only the author-order fold of part of the released prefix; a rejected write may settle as soon as its own handler fails. At settlement, outcomes, durable rows, public rows, and a fresh restore equal the fold of the accepted writes. Against `src/local-storage.ts` mutants it detects every mutant that the finite two-write loops it replaced detected (63), plus one. Inserts are excluded: an insert after a rejected delete of the same key is undecided behaviour. The `local-storage.peers` property runs every history of up to three actions (19,289 histories) across Collections A and B in one tab and C in another tab over the same bytes. Actions are direct, manual-acceptance, handler, and custom-parser writes of string keys and the number key 7, a held update by A, `clearStorage()` from any Collection, `reload()` of C, and delivery of pending storage events. After each action, stored rows equal the model's read-modify-write fold. Every Collection in the writing tab shows the stored rows, because a write reads storage for its whole tab (maintainer decision D43, 2026-11-02, raised by three comparison gaps in the gap report). The holder's own pending row overlays only the holder, and while it is held the holder may show its earlier view of other keys (glossary, accepted sync transaction). The other tab shows a changed row once its event arrives or it reloads. A fresh Collection restores the stored rows. Against `src/local-storage.ts` mutants the LocalStorage oracles detect 201 of 320 with this property and 153 without it; detections in the peer and event functions rise from 36 to 70 of 131. Excluded: write races between tabs on one key, value-normalizing parsers, startup errors, storage faults, and cleanup. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | +| LocalStorage Collection | [write order over every short history](../../packages/db/tests/local-storage-write-order-oracle.test.ts) (`local-storage.write-order`), [peers over every short history](../../packages/db/tests/local-storage-peers-oracle.test.ts) (`local-storage.peers`), [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | The `local-storage.write-order` property runs every history of one to three automatic updates and deletes of two stored rows (1,448 histories), every handler completion order, and every decision vector. After each handler finishes, no accepted write may have settled ahead of an undecided predecessor, and storage may hold only the author-order fold of part of the released prefix; a rejected write may settle as soon as its own handler fails (the two-write loops it replaced also held a rejected later receipt for two microtasks, which no law requires). At settlement, outcomes, durable rows, public rows, and a fresh restore equal the fold of the accepted writes. Against `src/local-storage.ts` mutants it detects every mutant that the finite two-write loops it replaced detected (63), plus one. Inserts are excluded: an insert after a rejected delete of the same key is undecided behaviour. The `local-storage.peers` property runs every history of up to three actions (19,289 histories) across Collections A and B in one tab and C in another tab over the same bytes. Actions are direct, manual-acceptance, handler, and custom-parser writes of string keys and the number key 7, a held update by A, `clearStorage()` from any Collection, `reload()` of C, and delivery of pending storage events. After each action, stored rows equal the model's read-modify-write fold. Every Collection in the writing tab shows the stored rows, because a write reads storage for its whole tab (maintainer decision D43, 2026-11-02, raised by three comparison gaps in the gap report). The holder's own pending row overlays only the holder, and while it is held the holder may show its earlier view of other keys (glossary, accepted sync transaction). The other tab shows a changed row once its event arrives or it reloads. A fresh Collection restores the stored rows. Against `src/local-storage.ts` mutants the LocalStorage oracles detect 201 of 320 with this property and 153 without it; detections in the peer and event functions rise from 36 to 70 of 131. Excluded: write races between tabs on one key, value-normalizing parsers, startup errors, storage faults, and cleanup. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Only two fixed tests in `packages/db/tests/local-storage-order.test.ts` check this (same and disjoint key); no generated history does. Open: `local-storage.write-order` needs a checkpoint a macrotask after a rejected release that compares the rejected write's receipt and public row with the model. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | | LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `when('settled')` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Pinned witnesses cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A pinned witness per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, pinned in `local-only.test.ts`. | | Local-only direct writes | [direct write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) | Focused witnesses pin when a local-only direct write skips the optimistic stage: a completed transaction and one publication per write, and the fallbacks for a pending, persisting, or ambient transaction and a user handler, for insert, update, and delete. Each history also runs on a local-only Collection whose handlers resolve, which must match its rows, change batches, errors, and transaction states, across mixed multi-key batches, a batch that fails midway, schema rejection, and handler rollback. Mutants that drop each guard, write only the first mutation, or never complete the transaction fail them. The change-event history oracle drives the direct path through generated histories (about two thousand writes per run); removing the guard, or guarding only persisting transactions, fails these witnesses. | | Reused sync row objects | [reused row witness](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-reused-row.test.ts) | Focused witnesses for a sync source that changes a stored row object in place: in development, writing it again as an update without `previousValue` throws, while the same write with `previousValue`, or with a new object, moves the row out of an `eq` live query. Production skips the check. The check compares a shallow snapshot taken when the object was written, so it misses changes to nested fields; a write that names `previousValue` and a row whose fields throw on read are not checked. | diff --git a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts index e7995997e0..e2d67be732 100644 --- a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts @@ -1,11 +1,8 @@ -import { fc } from '@fast-check/vitest' -import { afterAll, describe, expect, it } from 'vitest' +import fc from 'fast-check' import { DbClient } from '../src/client.js' import { createCollection } from '../src/collection/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' -import { createOracleReach } from './oracle-reach.js' +import { oracleProperty, pass, violation } from './oracle-property.js' import type { ChangeMessage, CollectionStatus, @@ -56,9 +53,11 @@ import type { * * The model is the seed, whether an attached observer has ended it, and * whether the observer is attached. The Collection is the observer's input: - * the driver reads its public status and rows and does not model the - * Collection lifecycle. Rows under a failure status are not claimed, because - * that status says the data cannot be shown; law 4 still checks them. + * the driver records its public status and rows as the step's input, and the + * model reads only those, so it does not model the Collection lifecycle. The + * observer's snapshot and announcements are the step's output, which only the + * check reads. Rows under a failure status are not claimed, because that + * status says the data cannot be shown; law 4 still checks them. * * ## Contract and ownership table @@ -70,8 +69,9 @@ import type { | announced changes | every step while attached | emit -> flushPublications | last-read snapshot and granular rebuilt rows equal the current snapshot | this generated oracle | | handoff timing, on-demand subsets, streamed errors, consumption | | | | focused `live-query-observer.test.ts` hydration tests | - * Keys are `a`..`c`, versions 0..3, histories of 1..10 generated steps after - * an optional fixed three-step prefix, both modes. Rows are + * Keys are `a`..`c`, versions 0..3, histories of 1..10 steps, both modes. + * Segments start a generated history with the steps that enter each declared + * state, so every lane reaches every state. Rows are * compared as sets by key: this oracle makes no order claim. The source is a * plain controlled Collection, not a live-query Collection, so source health * propagation through a query graph belongs to `source-health-oracle.test.ts`. @@ -96,23 +96,18 @@ type History = { type VisibleRow = [string, number] type View = { status: CollectionStatus | `disabled`; rows: Array } -const reachStates = [ - `ready-under-seed:attached`, - `error-under-seed:attached`, - `crashed-under-seed:attached`, - `cleaned-up-under-seed:attached`, - `ready-under-seed:detached`, - `error-under-seed:detached`, - `crashed-under-seed:detached`, - `cleaned-up-under-seed:detached`, - `attach-to-failure-under-seed`, - `cleanup-after-failure-ended-seed`, - `restart-under-seed`, -] as const -type ReachState = (typeof reachStates)[number] +// --------------------------------------------------------------------------- +// Histories +// --------------------------------------------------------------------------- +const MAX_EVENTS = 10 const key = fc.constantFrom(`a`, `b`, `c`) const version = fc.integer({ min: 0, max: 3 }) +// A record of constants, not `fc.constant(object)`, so that a pin or segment +// built from a literal is a member of the language (fast-check compares a +// constant by identity). +const step = (type: Type) => + fc.record({ type: fc.constant(type) }) const event: fc.Arbitrary = fc.oneof( { arbitrary: fc.record({ @@ -122,12 +117,12 @@ const event: fc.Arbitrary = fc.oneof( }), weight: 2, }, - { arbitrary: fc.constant({ type: `ready` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `fail` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `crash` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `cleanup` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `restart` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `attach` as const }), weight: 2 }, + { arbitrary: step(`ready`), weight: 1 }, + { arbitrary: step(`fail`), weight: 1 }, + { arbitrary: step(`crash`), weight: 1 }, + { arbitrary: step(`cleanup`), weight: 1 }, + { arbitrary: step(`restart`), weight: 1 }, + { arbitrary: step(`attach`), weight: 2 }, ) const history: fc.Arbitrary = fc.record({ mode: fc.constantFrom(`granular`, `wholesale`), @@ -135,13 +130,69 @@ const history: fc.Arbitrary = fc.record({ selector: ([id]) => id, maxLength: 3, }), - events: fc.array(event, { minLength: 1, maxLength: 10 }), + events: fc.array(event, { minLength: 1, maxLength: MAX_EVENTS }), }) +/** Starts a generated history with `prefix`, keeping it inside the grammar. */ +const startWith = + (...prefix: Array) => + (generated: History): History => ({ + ...generated, + events: [ + ...prefix.map((type) => ({ type }) as Event), + ...generated.events, + ].slice(0, MAX_EVENTS), + }) + +// --------------------------------------------------------------------------- +// Trace +// --------------------------------------------------------------------------- +// +// One record per checkpoint: `start`, then one per step. A skipped step (one +// that is illegal in the Collection's current status) records only that it +// was skipped. An executed step records its input, the Collection's status +// before the step, right after it, and after a macrotask, and its rows then; +// and its output, what the observer showed and announced. + +type StepInput = { + action: Event[`type`] | `start` + before: CollectionStatus + during: CollectionStatus + after: CollectionStatus + live: Array +} +type StepOutput = { + view: View + flags: Record< + `isReady` | `isLoadingError` | `isCrashed` | `isCleanedUp`, + boolean + > + /** The snapshot a listener last read; undefined before attach. */ + lastRead: View | undefined + /** A granular listener's rows rebuilt from delivered changes. */ + rebuilt: Array | undefined +} +type StepRecord = { skipped: true } | { input: StepInput; output: StepOutput } + // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- +const reachStates = [ + `ready-under-seed:attached`, + `error-under-seed:attached`, + `crashed-under-seed:attached`, + `cleaned-up-under-seed:attached`, + `ready-under-seed:detached`, + `error-under-seed:detached`, + `crashed-under-seed:detached`, + `cleaned-up-under-seed:detached`, + `attach-to-failure-under-seed`, + `cleanup-after-failure-ended-seed`, + `restart-under-seed`, +] as const +type ReachState = (typeof reachStates)[number] + /** * `ended` records law 2: an attached observer saw a status that ends the * seed, either initial-query readiness (the handoff) or a failure. @@ -154,6 +205,12 @@ type Model = { } const failures: ReadonlySet = new Set([`error`, `crashed`]) +const shownUnderSeed: ReadonlySet = new Set([ + `ready`, + `error`, + `crashed`, + `cleaned-up`, +]) function seedActive(model: Model): boolean { return !model.ended @@ -161,7 +218,7 @@ function seedActive(model: Model): boolean { /** * Advance the model across one step. `statuses` are the Collection statuses - * before and after the step. An attached observer ends the seed when it sees + * the observer could see. An attached observer ends the seed when it sees * readiness or a failure (laws 1 and 2); cleanup and restart do not (law 3). */ function advance(model: Model, statuses: ReadonlyArray): void { @@ -185,9 +242,51 @@ function expectedView( return { status: liveStatus, rows: liveRows } } -// The model predicts the view; the grammar draws the histories that the -// driver below replays, including the fixed replays at the end of the file. -declareOracle({ model: [advance, expectedView], grammar: [history] }) +type Expectation = ReturnType & { attached: boolean } + +/** + * Folds the trace's inputs through the model: the view owed at each + * checkpoint, and the declared states the history entered, which are model + * states (whether the seed is active), so the model names them. + */ +function hydrationModel( + { seed }: History, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, +): { owed: Map; reached: Set } { + const model: Model = { + seed: sortedRows(seed.map(([id, value]) => [id, { id, version: value }])), + ended: false, + endedByFailure: false, + attached: false, + } + const owed = new Map() + const reached = new Set() + for (const { checkpoint, value } of trace) { + const record = value as StepRecord + if (`skipped` in record) continue + const { action, before, during, after, live } = record.input + if (action === `cleanup` && model.endedByFailure && failures.has(before)) + reached.add(`cleanup-after-failure-ended-seed`) + if (action === `restart` && seedActive(model)) + reached.add(`restart-under-seed`) + if (action === `attach`) { + if (seedActive(model) && failures.has(before)) + reached.add(`attach-to-failure-under-seed`) + model.attached = true + } + if (seedActive(model) && before !== during && shownUnderSeed.has(during)) + reached.add( + `${during}-under-seed:${model.attached ? `attached` : `detached`}` as ReachState, + ) + advance(model, [before, during]) + advance(model, [after]) + owed.set(checkpoint, { + ...expectedView(model, after, live), + attached: model.attached, + }) + } + return { owed, reached } +} // --------------------------------------------------------------------------- // Production driver @@ -206,13 +305,14 @@ function settle(): Promise { let sequence = 0 /** - * Runs one history. Steps that are illegal in the Collection's current - * status, such as `markReady()` outside `loading`, are skipped, so every - * executed step is a legal source history. + * Runs one history and records each checkpoint. Steps that are illegal in + * the Collection's current status, such as `markReady()` outside `loading`, + * are skipped, so every executed step is a legal source history. Attach + * subscribes once and does the consistency read `useSyncExternalStore` does. */ -async function runHistory( +async function driveHistory( { mode, seed, events }: History, - reach?: ReturnType>, + observe: (checkpoint: string, value: StepRecord) => void, ): Promise { const id = `observer-hydration-${sequence++}` const queryHash = `${id}-query` @@ -250,18 +350,8 @@ async function runHistory( mode, }) - const model: Model = { - seed: sortedRows( - seed.map(([rowKey, rowVersion]) => [ - rowKey, - { id: rowKey, version: rowVersion }, - ]), - ), - ended: false, - endedByFailure: false, - attached: false, - } const rebuilt = new Map() + let attached = false let lastRead: View | undefined const read = (): View => { const snapshot = observer.getSnapshot() @@ -286,64 +376,53 @@ async function runHistory( } lastRead = read() } - - const check = (step: number) => { - const context = JSON.stringify({ mode, seed, events, step }) + const record = ( + checkpoint: string, + input: Omit, + ) => { const snapshot = observer.getSnapshot() - const view = read() - const expected = expectedView( - model, - source.status, - sortedRows(source.entries()), - ) - expect(view.status, context).toBe(expected.status) - expect( - { - isReady: snapshot.isReady, - isLoadingError: snapshot.isLoadingError, - isCrashed: snapshot.isCrashed, - isCleanedUp: snapshot.isCleanedUp, + observe(checkpoint, { + input: { + ...input, + after: source.status, + live: sortedRows(source.entries()), + }, + output: { + view: read(), + flags: { + isReady: snapshot.isReady, + isLoadingError: snapshot.isLoadingError, + isCrashed: snapshot.isCrashed, + isCleanedUp: snapshot.isCleanedUp, + }, + lastRead, + rebuilt: + attached && mode === `granular` ? sortedRows(rebuilt) : undefined, }, - context, - ).toEqual({ - isReady: expected.status === `ready`, - isLoadingError: expected.status === `error`, - isCrashed: expected.status === `crashed`, - isCleanedUp: expected.status === `cleaned-up`, }) - if (expected.rows) expect(view.rows, context).toEqual(expected.rows) - if (model.attached) { - expect(lastRead, `${context} (unannounced change)`).toEqual(view) - if (mode === `granular`) { - expect(sortedRows(rebuilt), `${context} (granular rows)`).toEqual( - view.rows, - ) - } - } - } - - const note = (state: ReachState) => reach?.reach(state) - const noteStatusUnderSeed = (before: string, after: string) => { - if (!seedActive(model) || before === after) return - if ( - after === `ready` || - after === `error` || - after === `crashed` || - after === `cleaned-up` - ) { - note(`${after}-under-seed:${model.attached ? `attached` : `detached`}`) - } } let failure: { error: unknown } | undefined try { - check(-1) - for (const [step, next] of events.entries()) { + record(`start`, { + action: `start`, + before: source.status, + during: source.status, + }) + for (const [index, next] of events.entries()) { + const checkpoint = `step ${index} (${next.type})` const before = source.status + const skip = () => observe(checkpoint, { skipped: true }) if (next.type === `write`) { - if (before !== `loading` && before !== `ready`) continue + if (before !== `loading` && before !== `ready`) { + skip() + continue + } const present = source.has(next.key) - if (next.version === null && !present) continue + if (next.version === null && !present) { + skip() + continue + } ops!.begin() ops!.write( next.version === null @@ -355,41 +434,48 @@ async function runHistory( ) ops!.commit() } else if (next.type === `ready`) { - if (before !== `loading`) continue + if (before !== `loading`) { + skip() + continue + } ops!.markReady() } else if (next.type === `fail`) { - if (before !== `loading`) continue + if (before !== `loading`) { + skip() + continue + } ops!.fail(new Error(`first load failed`)) } else if (next.type === `crash`) { if (before !== `loading` && before !== `ready` && before !== `error`) { + skip() continue } source._lifecycle.crash(new Error(`local storage closed`)) } else if (next.type === `cleanup`) { - if (before === `cleaned-up`) continue - if (model.endedByFailure && failures.has(before)) { - note(`cleanup-after-failure-ended-seed`) + if (before === `cleaned-up`) { + skip() + continue } await source.cleanup() } else if (next.type === `restart`) { - if (before !== `cleaned-up`) continue - if (seedActive(model)) note(`restart-under-seed`) + if (before !== `cleaned-up`) { + skip() + continue + } source.startSyncImmediate() } else { - if (model.attached) continue - if (seedActive(model) && failures.has(before)) { - note(`attach-to-failure-under-seed`) + if (attached) { + skip() + continue } - model.attached = true + attached = true observer.subscribe(listener) // The consistency read useSyncExternalStore does after subscribing. lastRead = read() } - noteStatusUnderSeed(before, source.status) - advance(model, [before, source.status]) + const during = source.status await settle() - advance(model, [source.status]) - check(step) + record(checkpoint, { action: next.type, before, during }) } } catch (error) { failure = { error } @@ -400,82 +486,177 @@ async function runHistory( } // --------------------------------------------------------------------------- -// Properties +// Check // --------------------------------------------------------------------------- +// +// At every executed checkpoint the snapshot's status and flags are the owed +// status, and its rows are the owed rows when the model claims them (law +// `view`: laws 1-3). While attached, the snapshot a listener last read and a +// granular listener's rebuilt rows equal the current snapshot (law +// `announcement`: law 4). The trace must cover the start and every step; that +// is judged last, so a driver error cannot hide an earlier violation. -/** - * Fixed prefixes put each declared state within reach of every run budget; - * the generated suffix explores what follows. - */ -const prefixes: ReadonlyArray> = [ - [{ type: `attach` }, { type: `crash` }, { type: `cleanup` }], - [{ type: `attach` }, { type: `fail` }, { type: `cleanup` }], - [{ type: `crash` }, { type: `attach` }, { type: `cleanup` }], - [{ type: `attach` }, { type: `cleanup` }, { type: `restart` }], - [{ type: `cleanup` }, { type: `attach` }, { type: `ready` }], -] -const prefixedHistory: fc.Arbitrary = fc - .tuple(history, fc.option(fc.constantFrom(...prefixes), { freq: 2 })) - .map(([generated, prefix]) => ({ - ...generated, - events: [...(prefix ?? []), ...generated.events], - })) +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) -describe(`LiveQueryObserver hydration seed under terminal statuses`, () => { - const reach = createOracleReach( - `live-query-observer.hydration-terminal`, - reachStates, - ) - let historiesRan = false - afterAll(() => { - if (historiesRan) reach.assertReached() - }) - - it.each([20260, -88123])( - `generated histories refine the hydration model with fixed seed=%s`, - async (seed) => { - await fc.assert( - fc.asyncProperty(prefixedHistory, async (generated) => { - historiesRan = true - reach.beginHistory() - await runHistory(generated, reach) - }), - { numRuns: oracleRuns(100), seed }, +function checkHistory( + { events }: History, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + { owed }: ReturnType, +) { + for (const { checkpoint, value } of trace) { + const record = value as StepRecord + if (`skipped` in record) continue + const { view, flags, lastRead, rebuilt } = record.output + const expected = owed.get(checkpoint)! + if (view.status !== expected.status) + return violation( + checkpoint, + `status ${view.status}, owed ${expected.status}`, + `view`, + { actual: view.status, expected: expected.status }, ) - }, - 60_000, - ) - - it(`generated histories refine the hydration model with a random or replayed seed`, async () => { - await fc.assert( - fc.asyncProperty(prefixedHistory, async (generated) => { - historiesRan = true - reach.beginHistory() - await runHistory(generated, reach) - }), - oraclePropertyOptions(100), + const owedFlags = { + isReady: expected.status === `ready`, + isLoadingError: expected.status === `error`, + isCrashed: expected.status === `crashed`, + isCleanedUp: expected.status === `cleaned-up`, + } + if (!same(flags, owedFlags)) + return violation( + checkpoint, + `flags ${JSON.stringify(flags)}, owed ${JSON.stringify(owedFlags)}`, + `view`, + { actual: flags, expected: owedFlags }, + ) + if (expected.rows !== undefined && !same(view.rows, expected.rows)) + return violation( + checkpoint, + `rows ${JSON.stringify(view.rows)}, owed ${JSON.stringify(expected.rows)}`, + `view`, + { actual: view.rows, expected: expected.rows }, + ) + if (!expected.attached) continue + if (!same(lastRead, view)) + return violation( + checkpoint, + `a listener last read ${JSON.stringify(lastRead)}; the snapshot is ${JSON.stringify(view)} (unannounced change)`, + `announcement`, + { actual: lastRead, expected: view }, + ) + if (rebuilt !== undefined && !same(rebuilt, view.rows)) + return violation( + checkpoint, + `granular rows ${JSON.stringify(rebuilt)}; the snapshot has ${JSON.stringify(view.rows)}`, + `announcement`, + { actual: rebuilt, expected: view.rows }, + ) + } + // Completeness last: a history whose driver threw keeps the violation + // found at a recorded checkpoint. + if (trace.length !== events.length + 1) + return violation( + `trace`, + `the driver recorded ${trace.length} checkpoints for ${events.length} steps`, + `trace`, ) - }, 60_000) + return pass +} - // Replays of the reported trace and its neighbors, in both modes. Each one - // failed before the fix (see the coverage map entry for this owner). - const replays: ReadonlyArray<[string, Array]> = [ - [ - `shows and announces a crash under an active seed`, - [{ type: `attach` }, { type: `crash` }, { type: `cleanup` }], - ], - [ - `shows cleanup after a first-load failure instead of the stale error`, - [{ type: `attach` }, { type: `fail` }, { type: `cleanup` }], - ], - [ - `shows a crash that happened before it attached`, - [{ type: `crash` }, { type: `attach` }, { type: `cleanup` }], +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +// Replays of the reported trace and its neighbors, in both modes. Each one +// failed before the fix (see the coverage map entry for this owner). +const replays: ReadonlyArray<[string, Array]> = [ + [ + `shows and announces a crash under an active seed`, + [`attach`, `crash`, `cleanup`], + ], + [ + `shows cleanup after a first-load failure instead of the stale error`, + [`attach`, `fail`, `cleanup`], + ], + [ + `shows a crash that happened before it attached`, + [`crash`, `attach`, `cleanup`], + ], +] + +oracleProperty({ + id: `live-query-observer.hydration-terminal`, + law: { + statement: `While a hydration seed is active, the observer shows a Collection error or crashed status, attached or detached; an attached observer that sees a failure ends the seed, so a later cleanup shows the live Collection; cleanup and restart alone keep the seed as ready; and every change visible to an attached observer is announced.`, + authority: `packages/db/src/query/live/ARCHITECTURE.md law 16; glossary "crash" and "cleanup"; the coverage-map decision that cleanup under a seed keeps the seed`, + limits: `A plain controlled Collection, not a live-query Collection; keys a..c, versions 0..3, up to ${MAX_EVENTS} steps; rows compared as sets; handoff timing, on-demand subsets, streamed errors, and consumption belong to the focused tests in live-query-observer.test.ts.`, + owner: `Observer hydration under terminal statuses`, + }, + histories: { + arbitrary: history, + states: reachStates, + runs: 100, + fixedSeeds: [20260, -88123], + segments: [ + { + name: `crash while attached, then cleanup`, + enters: [ + `crashed-under-seed:attached`, + `cleanup-after-failure-ended-seed`, + ], + build: startWith(`attach`, `crash`, `cleanup`), + }, + { + name: `first-load failure while attached, then cleanup`, + enters: [ + `error-under-seed:attached`, + `cleanup-after-failure-ended-seed`, + ], + build: startWith(`attach`, `fail`, `cleanup`), + }, + { + name: `crash before attach`, + enters: [`crashed-under-seed:detached`, `attach-to-failure-under-seed`], + build: startWith(`crash`, `attach`, `cleanup`), + }, + { + name: `first-load failure before attach`, + enters: [`error-under-seed:detached`, `attach-to-failure-under-seed`], + build: startWith(`fail`, `attach`), + }, + { + name: `cleanup and restart while attached`, + enters: [`cleaned-up-under-seed:attached`, `restart-under-seed`], + build: startWith(`attach`, `cleanup`, `restart`), + }, + { + name: `cleanup before attach`, + enters: [`cleaned-up-under-seed:detached`], + build: startWith(`cleanup`, `attach`, `ready`), + }, + { + name: `readiness while detached`, + enters: [`ready-under-seed:detached`], + build: startWith(`ready`), + }, + { + name: `readiness while attached`, + enters: [`ready-under-seed:attached`], + build: startWith(`attach`, `ready`), + }, ], - ] - for (const mode of [`granular`, `wholesale`] as const) { - it.each(replays)(`${mode} observer %s`, async (_name, events) => { - await runHistory({ mode, seed: [[`a`, 1]], events }) - }) - } + pins: [`granular`, `wholesale`].flatMap((mode) => + replays.map(([name, events]) => ({ + name: `${mode} observer ${name}`, + history: { + mode: mode as Mode, + seed: [[`a`, 1]] as Array<[Key, number]>, + events: events.map((type) => ({ type }) as Event), + }, + })), + ), + }, + model: hydrationModel, + reached: (_history, _trace, expected) => expected.reached, + driver: driveHistory, + check: checkHistory, }) diff --git a/packages/db/tests/local-storage-peers-oracle.test.ts b/packages/db/tests/local-storage-peers-oracle.test.ts index 069b0203b7..38fa751d4e 100644 --- a/packages/db/tests/local-storage-peers-oracle.test.ts +++ b/packages/db/tests/local-storage-peers-oracle.test.ts @@ -457,6 +457,8 @@ function checkHistory( return violation( checkpoint, `stored ${json(observed.stored)}, expected ${json(allowed.stored)}`, + undefined, + { actual: observed.stored, expected: allowed.stored }, ) for (const collection of [`A`, `B`, `C`] as const) { const rows = observed[collection] @@ -483,6 +485,8 @@ function checkHistory( return violation( `fresh restore`, `restored ${json(restored.value)}, expected ${json(expected.restored)}`, + undefined, + { actual: restored.value, expected: expected.restored }, ) return pass } diff --git a/packages/db/tests/local-storage-write-order-oracle.test.ts b/packages/db/tests/local-storage-write-order-oracle.test.ts index 2749e86fb8..86c647be68 100644 --- a/packages/db/tests/local-storage-write-order-oracle.test.ts +++ b/packages/db/tests/local-storage-write-order-oracle.test.ts @@ -19,9 +19,12 @@ * Limits. Writes are whole-row updates and deletes of two stored rows, made * by one Collection with automatic handlers. Inserts are excluded: an insert * after a rejected delete of the same key is undecided behaviour. Manual - * acceptance, peer writes, storage faults, and histories longer than three - * writes are outside this property; the order oracle's pinned witnesses and - * the peer tests cover some of them. + * acceptance and peer writes are outside this property because they need + * different event and ownership boundaries; the order oracle's pinned + * witnesses and `local-storage.peers` cover them. Storage faults and + * histories longer than three writes are also outside it. The prompt release + * of a failed later handler's receipt and optimistic row is witnessed only by + * the fixed tests in `local-storage-order.test.ts` (coverage map). */ import { createCollection } from '../src/collection/index' import { createDeferred } from '../src/deferred' @@ -40,7 +43,15 @@ import type { Row } from './local-storage-fixtures' // A history is a list of writes, the order in which their handlers finish, and // each handler's decision. A write is legal when it is submitted: an update or // a delete needs its row to exist in the Collection at that moment, which is -// the authored state with every earlier write applied optimistically. +// the authored state with every earlier write applied optimistically. The +// lists cross same-key and disjoint-key writes. An update followed by a delete +// of the same row distinguishes author order from a simple last-value +// replacement: an accepted delete removes the row even when its handler +// finishes before the update's. +// +// A test is named by its history: `ua db` is an update of `a` then a delete of +// `b`, `release 10` lets write 1's handler finish before write 0's, and +// `decisions YN` accepts write 0 and rejects write 1. type Write = { kind: `update` | `delete`; id: `a` | `b` } type History = { @@ -99,7 +110,8 @@ const histories: Array = legalWriteLists().flatMap((writes) => // -- Model ---------------------------------------------------------------------- // -// The documented effect of an accepted write, in author order: an update +// The model does not model handlers, optimistic overlays, storage caches, or +// sync queues. The documented effect of an accepted write, in author order: an update // replaces the whole row with the value it authored (its position plus one), // and a delete removes the row. A rejected write contributes nothing. The fold // of the first m writes is what storage may hold once those m writes are @@ -155,7 +167,7 @@ async function drive( const entered = writes.map(() => createDeferred()) // An update names itself by the value it authored. A delete names itself by // its key: a legal history deletes a key at most once. - const deletes = new Map( + const deletes = new Map( writes.flatMap((write, index) => write.kind === `delete` ? [[write.id, index] as const] : [], ), @@ -248,14 +260,6 @@ function checkHistory( expected: ReturnType, ) { const count = history.writes.length - const seen = new Set(observations.map(({ checkpoint }) => checkpoint)) - for (const checkpoint of [`settled`, `fresh restore`]) { - if (!seen.has(checkpoint)) - return violation( - checkpoint, - `the driver recorded no "${checkpoint}" observation`, - ) - } for (const { checkpoint, value } of observations) { if (checkpoint.startsWith(`held`)) { const { released, settled, durable } = value as { @@ -292,24 +296,42 @@ function checkHistory( return violation( checkpoint, `outcomes ${JSON.stringify(outcomes)}, decisions ${JSON.stringify(expected.outcomes)}`, + undefined, + { actual: outcomes, expected: expected.outcomes }, ) if (!same(durable, expected.rows)) return violation( checkpoint, `storage ${JSON.stringify(durable)}, expected ${JSON.stringify(expected.rows)}`, + undefined, + { actual: durable, expected: expected.rows }, ) if (!same(rows, expected.rows)) return violation( checkpoint, `public rows ${JSON.stringify(rows)}, expected ${JSON.stringify(expected.rows)}`, + undefined, + { actual: rows, expected: expected.rows }, ) } else if (!same(value, expected.rows)) { return violation( checkpoint, `restored ${JSON.stringify(value)}, expected ${JSON.stringify(expected.rows)}`, + undefined, + { actual: value, expected: expected.rows }, ) } } + // Completeness last: a history whose driver threw keeps the violation + // found at a recorded checkpoint. + const seen = new Set(observations.map(({ checkpoint }) => checkpoint)) + for (const checkpoint of [`settled`, `fresh restore`]) { + if (!seen.has(checkpoint)) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}" observation`, + ) + } return pass } diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 8c82724865..fb16bc1476 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -254,6 +254,7 @@ const staticOracleProperties = [ `pooled-live-query.publication`, `flat-change-tracking.equivalence`, `local-storage.value-fidelity`, + `live-query-observer.hydration-terminal`, `derived-publication.membership-work`, `collection-publication.metadata-cancellation`, `collection-publication.metadata-only`, diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 11b1c628b1..e2809d7861 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -12,32 +12,74 @@ * * - `law` states the promise, its authority, its limits, and its owner in the * coverage map. A failure names it. - * - `histories` lists every legal history the property runs. This first - * version takes an enumerated source, which runs in full: bounded - * exhaustiveness (glossary). - * - `model` gives the expected result of a history, without production. + * - `histories` is either an enumerated list, which runs in full (bounded + * exhaustiveness, glossary), or a generated source: a fast-check arbitrary + * with a run budget, fixed seeds, required segments, and pins (below). + * - `model` gives the expected result of a history and its recorded trace, + * without production. A model that answers from the history alone ignores + * the trace; a model of an environment the driver observes reads it + * (oracle-framework design, R7). * - `driver` runs one history through production and records public * observations with `observe(checkpoint, value)`. It returns nothing, so it * cannot decide a verdict (oracle-framework design, decision D10). A * production error that the law cares about is an observation the driver - * records. An error that escapes the driver is a setup error, never a - * violation. + * records. An error that escapes the driver is a setup error, unless the + * trace recorded before it already violates the law: then the violation is + * the failure, with the error as its cause, so a later crash cannot hide an + * earlier wrong observation. A check therefore judges the recorded + * checkpoints first and the trace's completeness last. * - `check` compares the observations with the model and returns a verdict. - * Only the check can report a violation of the law. + * Only the check can report a violation of the law. A violation that gives + * the `actual` and `expected` values gets the test runner's usual diff. * * Each history is one test, named by `label`. A violation fails it with an * `OracleViolation` that names the property, the law, the checkpoint, the - * difference, and the command that reruns that history. Every observation is + * difference, and the command that reruns that history (the name is escaped, + * because Vitest reads `-t` as a regular expression). Every observation is * also offered to `globalThis.__oracleObserve` when a harness installs one, * so the gap report can tell an output the check does not compare from one * that never changed. * + * A generated source runs one test per fixed seed and one random test, with + * the same arbitrary, check, and budget (ORC-007). The random test reads the + * replay variables through `oraclePropertyOptions`, and every test stands + * aside during another property's path replay. + * + * - **Reach** names the states a history entered. By default the driver + * reports them with `observe("reach", state)`. A state that only the model + * can name, such as "a failure while the seed is active", comes from + * `reached(history, trace, expected)` instead. Each lane prints how many + * histories entered each declared state, including none (ORC-015). + * - A **segment** builds a history that reaches states that the laws need, + * from a generated history, and names those states. Every lane runs every + * segment once as an example, and a segment run that does not enter a named + * state fails. Only segments make reach required: a state that random + * histories reach is measured, never gated (design, D21). So every state in + * `states` must be named by a segment, and a state the property only + * measures is declared in `measured` instead (ORC-015). Half of a lane's + * generated histories also go through a segment, so the histories after + * each required state are explored, not visited once. + * - A **pin** is a fixed history, such as a regression. A pin and a segment's + * history must be histories the arbitrary could generate + * (`canShrinkWithoutContext`), so they shrink like generated ones and cannot + * cover a dimension that the grammar lacks (design, R8). + * - **Shrinking keeps the law.** A violation can name the law it broke. Once + * a history violates one law, a smaller history that violates a different + * law counts as passing, so the reported counterexample still shows the + * first failure (ORC-010, design D24). + * * The `oracle/no-unit-test` lint rule treats an `oracleProperty` call as an * oracle test whose parts are its arguments, when its options are an object * literal with a `check`. The property is its own declaration. */ import { appendFileSync } from 'node:fs' +import fc from 'fast-check' import { describe, expect, it } from 'vitest' +import { + oraclePropertyOptions, + oracleReplayTest, + oracleRuns, +} from './oracle-config.js' /** A law and where it comes from. */ export interface OracleLaw { @@ -60,28 +102,86 @@ export interface Observation { /** The check's judgment of one history. */ export type Verdict = | { kind: `pass` } - | { kind: `violation`; checkpoint: string; difference: string } + | { + kind: `violation` + checkpoint: string + difference: string + /** The law broken, which shrinking preserves. Defaults to the checkpoint. */ + law?: string + /** The observed and owed values, which the failure shows as a diff. */ + values?: { actual: unknown; expected: unknown } + /** A driver error that came after the violated checkpoint. */ + cause?: unknown + } export const pass: Verdict = { kind: `pass` } -export function violation(checkpoint: string, difference: string): Verdict { - return { kind: `violation`, checkpoint, difference } +export function violation( + checkpoint: string, + difference: string, + law?: string, + values?: { actual: unknown; expected: unknown }, +): Verdict { + return { kind: `violation`, checkpoint, difference, law, values } } -/** The check found a history whose observations the law does not allow. */ +/** + * The check found a history whose observations the law does not allow. + * `actual` and `expected` are the fields Vitest reads to print a diff. + */ export class OracleViolation extends Error { override name = `OracleViolation` + actual?: unknown + expected?: unknown + showDiff?: boolean +} + +/** + * A history built from a generated one so that it reaches the states the + * laws need. The driver reports reached states with `observe("reach", state)`. + */ +export interface Segment { + name: string + enters: ReadonlyArray + build: (generated: History) => History +} + +export interface GeneratedHistories { + arbitrary: fc.Arbitrary + /** + * The states the laws depend on (ORC-015). Each must be named by a segment, + * so every lane reaches it. Each lane reports how many histories entered + * each one, including none. + */ + states?: ReadonlyArray + /** + * States that are counted but not required. A reached state outside + * `states` and `measured` fails, so a misspelled state cannot pass unseen. + */ + measured?: ReadonlyArray + /** The base run budget of each lane, scaled by the run multiplier. */ + runs: number + /** One fixed lane per seed, beside the random lane. */ + fixedSeeds: ReadonlyArray + segments?: ReadonlyArray> + pins?: ReadonlyArray<{ name: string; history: History }> } export interface OraclePropertyOptions { /** A stable identifier, used in failures, the manifest, and replay. */ id: string law: OracleLaw - /** Every legal history, in a fixed order. */ - histories: ReadonlyArray - /** A short, unique test name for a history. */ - label: (history: History) => string - model: (history: History) => Expected + /** Every legal history in a fixed order, or a generated source. */ + histories: ReadonlyArray | GeneratedHistories + /** A short, unique test name for an enumerated history. */ + label?: (history: History) => string + model: (history: History, trace: ReadonlyArray) => Expected + /** The states the history entered. Defaults to the trace's `reach` values. */ + reached?: ( + history: History, + trace: ReadonlyArray, + expected: Expected, + ) => Iterable driver: ( history: History, observe: (checkpoint: string, value: unknown) => void, @@ -99,59 +199,313 @@ declare global { ((checkpoint: string, value: unknown) => void) | undefined } +/** One test that a property registers. */ +interface PropertyTest { + name: string + run: () => Promise + /** Path replays this test runs during; empty stands aside during every one. */ + replays: ReadonlyArray + timeout?: number +} + export function oracleProperty( options: OraclePropertyOptions, ): void { - const { id, law, histories, label } = options - recordRegistration(options) + const { id, histories } = options + recordRegistration(id, options.law, histories) + // The tests are built as data first, so every one registers unconditionally + // (oracle/no-disabled). + const tests = Array.isArray(histories) + ? enumeratedTests(options, histories as ReadonlyArray) + : generatedTests(options, histories as GeneratedHistories) describe(id, () => { - for (const history of histories) { - const name = label(history) - it(name, async () => { - const observations: Array = [] - await options.driver(history, (checkpoint, value) => { - observations.push({ checkpoint, value }) - globalThis.__oracleObserve?.(checkpoint, value) - }) - const verdict = options.check( - history, - observations, - options.model(history), - ) - if (verdict.kind === `violation`) { - throw new OracleViolation( - [ - `${id} violated at checkpoint "${verdict.checkpoint}".`, - `Law: ${law.statement}`, - `Difference: ${verdict.difference}`, - `Authority: ${law.authority}`, - `Known limits: ${law.limits}`, - `Rerun this history: vitest run ${relativeTestFile()} -t ${JSON.stringify(`${id} ${name}`)}`, - ].join(`\n`), + for (const test of tests) + oracleReplayTest(it, ...test.replays)(test.name, test.run, test.timeout) + }) +} + +function enumeratedTests( + options: OraclePropertyOptions, + histories: ReadonlyArray, +): Array { + return histories.map((history) => { + const name = options.label?.(history) ?? JSON.stringify(history) + return { + name, + replays: [], + run: async () => { + const { verdict } = await runOne(options, history) + if (verdict.kind === `violation`) + throw failure( + options, + verdict, + history, + rerunByName(`${options.id} ${name}`), ) - } - }) + }, } }) } +/** Runs one history: the driver records, the model predicts, the check judges. */ +async function runOne( + options: OraclePropertyOptions, + history: History, +): Promise<{ verdict: Verdict; reached: Set }> { + const observations: Array = [] + let driverError: { error: unknown } | undefined + try { + await options.driver(history, (checkpoint, value) => { + observations.push({ checkpoint, value }) + globalThis.__oracleObserve?.(checkpoint, value) + }) + } catch (error) { + driverError = { error } + } + let expected: Expected + let verdict: Verdict + try { + expected = options.model(history, observations) + verdict = options.check(history, observations, expected) + } catch (error) { + // A partial trace the model or check cannot read is still a setup error. + throw driverError ? driverError.error : error + } + if (driverError) { + // Only a violation at a checkpoint the driver recorded before it threw + // outranks the error; one about the missing rest of the trace does not. + const recorded = observations.some( + ({ checkpoint }) => + verdict.kind === `violation` && checkpoint === verdict.checkpoint, + ) + if (verdict.kind === `pass` || !recorded) throw driverError.error + verdict = { ...verdict, cause: driverError.error } + } + const reached = new Set( + options.reached?.(history, observations, expected) ?? + observations + .filter((entry) => entry.checkpoint === `reach`) + .map((entry) => String(entry.value)), + ) + return { verdict, reached } +} + +function failure( + options: { id: string; law: OracleLaw }, + verdict: Extract, + history: History, + rerun: string, +): OracleViolation { + const { id, law } = options + const error = new OracleViolation( + [ + `${id} violated at checkpoint "${verdict.checkpoint}"${verdict.law ? ` (law: ${verdict.law})` : ``}.`, + `Law: ${law.statement}`, + `Difference: ${verdict.difference}`, + `History: ${JSON.stringify(history)}`, + `Authority: ${law.authority}`, + `Known limits: ${law.limits}`, + `Rerun: ${rerun}`, + ].join(`\n`), + verdict.cause === undefined ? undefined : { cause: verdict.cause }, + ) + if (verdict.values) { + error.actual = verdict.values.actual + error.expected = verdict.values.expected + error.showDiff = true + } + return error +} + +/** A Vitest `-t` argument that matches exactly this test name. */ +function rerunByName(name: string): string { + const pattern = name.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`) + return `-t ${JSON.stringify(`^${pattern}$`)}` +} + +function generatedTests( + options: OraclePropertyOptions, + source: GeneratedHistories, +): Array { + const { id } = options + const segments = source.segments ?? [] + const declared = + source.states || source.measured + ? new Set([...(source.states ?? []), ...(source.measured ?? [])]) + : undefined + const named = new Set(segments.flatMap((segment) => segment.enters)) + const unsegmented = (source.states ?? []).filter((state) => !named.has(state)) + if (unsegmented.length > 0) + throw new Error( + `${id}: no segment enters ${unsegmented.join(`, `)}. A required state needs a segment; a state that is only counted belongs in \`measured\`.`, + ) + // Generated histories, half of them built by a segment. A built history + // shrinks through its generated input, as a mapped arbitrary does. + const arbitrary = + segments.length === 0 + ? source.arbitrary + : fc.oneof( + { arbitrary: source.arbitrary, weight: segments.length }, + ...segments.map((segment) => ({ + arbitrary: source.arbitrary.map(segment.build), + weight: 1, + })), + ) + for (const segment of segments) { + const undeclared = segment.enters.filter( + (state) => + declared !== undefined && !(source.states ?? []).includes(state), + ) + if (undeclared.length > 0) + throw new Error( + `${id}: segment "${segment.name}" names undeclared states ${undeclared.join(`, `)}.`, + ) + } + + // Each lane gets a fresh predicate: its first violation fixes the law that + // shrinking must keep. It counts the histories that entered each state. + const predicateFor = ( + segmentOf: Map>, + counts: Map, + ) => { + let firstLaw: string | undefined + return async (history: History) => { + const { verdict, reached } = await runOne(options, history) + for (const state of reached) { + if (declared !== undefined && !declared.has(state)) + throw new Error( + `${id}: a history entered "${state}", which the property does not declare.`, + ) + counts.set(state, (counts.get(state) ?? 0) + 1) + } + const segment = segmentOf.get(history) + if (segment !== undefined) { + const missing = segment.enters.filter((state) => !reached.has(state)) + if (missing.length > 0) + throw new Error( + `${id}: segment "${segment.name}" did not enter ${missing.join(`, `)}. A segment must reach every state it names.`, + ) + } + if (verdict.kind === `pass`) return + const law = verdict.law ?? verdict.checkpoint + firstLaw ??= law + // A different law's violation during shrinking is not this failure. + if (law !== firstLaw) return + throw failure( + options, + verdict, + history, + `TANSTACK_DB_ORACLE_PROPERTY=${id} with the seed and path that fast-check reports`, + ) + } + } + // Each lane runs every segment, built from histories its own seed draws. + // fast-check passes example inputs by reference, which identifies them. + const examplesFor = (seed: number) => { + const segmentOf = new Map>() + const examples = segments.map((segment, index) => { + const [generated] = fc.sample(source.arbitrary, { + seed: seed + index, + numRuns: 1, + }) + const history = segment.build(generated!) + if (!source.arbitrary.canShrinkWithoutContext(history)) + throw new Error( + `${id}: segment "${segment.name}" built a history the arbitrary cannot generate. A segment must stay inside the generated language so that its failures shrink.`, + ) + segmentOf.set(history, segment) + return [history] as [History] + }) + return { examples, segmentOf } + } + + // A lane reports its reach counts after it passes; a failing lane reports + // its counterexample instead. + const runLane = async ( + name: string, + seed: number, + parameters: (examples: Array<[History]>) => Parameters[1], + ) => { + const { examples, segmentOf } = examplesFor(seed) + const counts = new Map( + [...(declared ?? [])].map((state) => [state, 0]), + ) + await fc.assert( + fc.asyncProperty(arbitrary, predicateFor(segmentOf, counts)), + parameters(examples), + ) + // Vitest hides console output from a passing test; stdout reaches the log. + process.stdout.write( + `${id} ${name} reach ${JSON.stringify(Object.fromEntries([...counts].sort(([a], [b]) => (a < b ? -1 : 1))))}\n`, + ) + } + // Examples come on top of a lane's budget, as in `oraclePropertyOptions`. + const fixedLanes = source.fixedSeeds.map((seed): PropertyTest => ({ + name: `fixed seed ${seed}`, + replays: [], + timeout: 120_000, + run: () => + runLane(`fixed seed ${seed}`, seed, (examples) => ({ + numRuns: oracleRuns(source.runs) + examples.length, + seed, + examples, + })), + })) + // The random lane picks its seed itself, so a replay of that seed rebuilds + // the same segment histories, which the replay path counts. + const randomLane: PropertyTest = { + name: `random seed`, + replays: [id], + timeout: 120_000, + run: () => { + const seed = + oraclePropertyOptions(source.runs, id).seed ?? Date.now() % 2 ** 31 + return runLane(`random seed`, seed, (examples) => ({ + ...oraclePropertyOptions(source.runs, id, { examples }), + seed, + })) + }, + } + const pins = (source.pins ?? []).map((pin): PropertyTest => ({ + name: `pin: ${pin.name}`, + replays: [], + run: async () => { + if (!source.arbitrary.canShrinkWithoutContext(pin.history)) + throw new Error( + `${id}: pin "${pin.name}" is not a history the arbitrary can generate. A pin must stay inside the generated language, or the property must declare the missing dimension as a limit.`, + ) + const { verdict } = await runOne(options, pin.history) + if (verdict.kind === `violation`) + throw failure( + options, + verdict, + pin.history, + rerunByName(`${id} pin: ${pin.name}`), + ) + }, + })) + return [...fixedLanes, randomLane, ...pins] +} + // A collect-only run (ORACLE_MANIFEST set) records each registration, so the // committed manifest can be generated without running any history. -function recordRegistration(options: { - id: string - law: OracleLaw - histories: { length: number } -}): void { +function recordRegistration( + id: string, + law: OracleLaw, + histories: ReadonlyArray | { runs: number }, +): void { const manifest = process.env.ORACLE_MANIFEST if (manifest === undefined) return appendFileSync( manifest, `${JSON.stringify({ - id: options.id, + id, file: relativeTestFile(), - owner: options.law.owner, - authority: options.law.authority, - histories: options.histories.length, + owner: law.owner, + authority: law.authority, + histories: Array.isArray(histories) + ? histories.length + : { runs: (histories as { runs: number }).runs }, })}\n`, ) } From 0742a4fbd832ee02dac12e51204f78b3cfcebc3f Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Nov 2026 19:00:02 -0700 Subject: [PATCH 005/168] test(oracles): make oracle runs deterministic, and check and lint for it A gap report compares an unmutated run with each mutant run, so a test whose captured values differ between two unmutated runs is left out of the comparison. On db's oracles that was 5,397 of 13,740 tests; it is now 0. Almost none were oracles checking unstable values; the capture recorded measurement artifacts: - wall-clock stamps, now rendered as `time`; - performance.now() deadlines inside production objects. A class instance now renders as its identity (`ClassName#n`), as `toBe` compares it; a toJSON class and a Temporal value render through their text. Limit: a mutant that changes only a class instance's fields under `toEqual` reads as no observed difference; - Vitest's `withTest` and chai's `assert`, which are not matchers; - UUIDs inside error messages. In a gap-report run, Math.random, crypto.getRandomValues, and crypto.randomUUID draw from a generator seeded by the test's name (and one for module load), and fast-check gets a global seed. On today's code, 4 of the first survey's 7 comparison gaps do not reproduce even with the old harness; with the new one 2 remain and lifecycle.ts M152 (reportRequestError `&&`->`||`) moves to generator-gap, cause undetermined. oracleProperty gains a determinism test: every pin and segment history (or eight evenly spaced enumerated histories) runs twice and must record the same trace. A driver that recorded performance.now() fails it at the first pin with both values; the three properties pass. Lint (no inline exception): - oracle/replayable-seed: a property seed must be a literal or constant, a recorded seed, a number-only `.each` table, the shared config's seed with a fallback, or an `undefined` lane guarded to `oraclePropertyOptions`. It found six lanes TANSTACK_DB_ORACLE_SEED could not replay (notification-laws 3, topKWithFractionalIndex 2, proxy-detachment 1); each now uses the shared config under a registered id. - oracle/pure-model: an oracleProperty model, check, or reached part may not read a clock, ambient randomness, or a timer, directly or through a same-file function (0 findings). 18 rule tests; the plugin suite passes (4,113); 0 findings across 344 oracle files. Co-authored-by: Isaac --- .../topKWithFractionalIndex-oracle.test.ts | 24 +- .../db/tests/notification-laws-oracle.test.ts | 22 +- packages/db/tests/oracle-config.ts | 8 + packages/db/tests/oracle-property.ts | 109 ++++++++- .../proxy-detachment-contract-oracle.test.ts | 7 +- scripts/eslint-plugin-oracle/index.js | 6 + .../eslint-plugin-oracle/rules/pure-model.js | 166 +++++++++++++ .../rules/replayable-seed.js | 230 ++++++++++++++++++ .../seed-and-purity.test.js | 81 ++++++ scripts/oracle-gaps/capture-setup.mjs | 105 +++++++- 10 files changed, 732 insertions(+), 26 deletions(-) create mode 100644 scripts/eslint-plugin-oracle/rules/pure-model.js create mode 100644 scripts/eslint-plugin-oracle/rules/replayable-seed.js create mode 100644 scripts/eslint-plugin-oracle/seed-and-purity.test.js diff --git a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts index 6bb58b09f4..af728c0f87 100644 --- a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts @@ -7,7 +7,10 @@ import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalI import { groupedTopKWithFractionalIndex } from '../../src/operators/groupedTopKWithFractionalIndex.js' import { join, map, output } from '../../src/operators/index.js' import { assertOnlyKeysAffected } from '../test-utils.js' -import { oracleRuns } from '../../../db/tests/oracle-config.js' +import { + oraclePropertyOptions, + oracleRuns, +} from '../../../db/tests/oracle-config.js' import { TopKMessageTracker, TopKRelation } from './topk-relation-oracle.js' // Helper function to check if indices are in lexicographic order @@ -73,9 +76,9 @@ const groupedWindow: typeof topKWithFractionalIndex = (comparator, options) => }) describe.each([ - { name: `array`, topK: topKWithFractionalIndex }, - { name: `grouped array`, topK: groupedWindow }, -])('Generated fractional $name windows', ({ topK }) => { + { name: `array`, key: `array`, topK: topKWithFractionalIndex }, + { name: `grouped array`, key: `grouped`, topK: groupedWindow }, +])('Generated fractional $name windows', ({ key, topK }) => { it.each([409036, undefined])( `preserves cyclic payloads through replacements and fresh transients (seed %s)`, (seed) => { @@ -159,8 +162,9 @@ describe.each([ }, ), { - seed, - numRuns: oracleRuns(30), + ...(seed === undefined + ? oraclePropertyOptions(30, `ivm.topk-cyclic-payloads-${key}`) + : { seed, numRuns: oracleRuns(30) }), examples: [ [ false, @@ -239,8 +243,12 @@ describe.each([ }, ), { - seed, - numRuns: oracleRuns(100), + ...(seed === undefined + ? oraclePropertyOptions( + 100, + `ivm.topk-outer-join-replacements-${key}`, + ) + : { seed, numRuns: oracleRuns(100) }), examples: [ [ 1, diff --git a/packages/db/tests/notification-laws-oracle.test.ts b/packages/db/tests/notification-laws-oracle.test.ts index f09ec97a4d..f2310e98c0 100644 --- a/packages/db/tests/notification-laws-oracle.test.ts +++ b/packages/db/tests/notification-laws-oracle.test.ts @@ -23,7 +23,7 @@ import { expectNotificationsStopped, } from '../../db-collection-e2e/src/utils/notification-laws-oracle' import { ScenarioLifetime } from './conformance/scenario-lifetime' -import { oracleRuns } from './oracle-config' +import { oraclePropertyOptions, oracleRuns } from './oracle-config' import type { Notification } from '../../db-collection-e2e/src/utils/notification-laws-oracle' type Row = { id: string; count: number } @@ -110,8 +110,9 @@ it.each([20260913, undefined])( }, ), { - seed, - numRuns: oracleRuns(30), + ...(seed === undefined + ? oraclePropertyOptions(30, `notification-laws.query-publications`) + : { seed, numRuns: oracleRuns(30) }), examples: [ [1, [2, null]], [1, [null]], @@ -177,7 +178,14 @@ it.each([20260913, undefined])( ).toThrow() }, ), - { seed, numRuns: oracleRuns(100) }, + { + ...(seed === undefined + ? oraclePropertyOptions( + 100, + `notification-laws.publication-histories`, + ) + : { seed, numRuns: oracleRuns(100) }), + }, ) }, ) @@ -251,7 +259,11 @@ it.each([20260913, undefined])( expectNotificationHistory([], [...callbacks, callbacks[1]!], [next]), ).toThrow() }), - { seed, numRuns: oracleRuns(50) }, + { + ...(seed === undefined + ? oraclePropertyOptions(50, `notification-laws.corrupt-prior-values`) + : { seed, numRuns: oracleRuns(50) }), + }, ) }, ) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index fb16bc1476..176dc740c0 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -15,6 +15,14 @@ const staticOracleProperties = [ `ivm.topk-negative-window-array`, `ivm.topk-negative-window-grouped`, `ivm.topk-negative-window-orderBy`, + `ivm.topk-cyclic-payloads-array`, + `ivm.topk-cyclic-payloads-grouped`, + `ivm.topk-outer-join-replacements-array`, + `ivm.topk-outer-join-replacements-grouped`, + `notification-laws.query-publications`, + `notification-laws.publication-histories`, + `notification-laws.corrupt-prior-values`, + `proxy.json-data-keys`, `framework.react.window-history`, `framework.react.pending-window-history`, `framework.react.publication-window-history`, diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index e2809d7861..a7dc013f43 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -63,6 +63,12 @@ * history must be histories the arbitrary could generate * (`canShrinkWithoutContext`), so they shrink like generated ones and cannot * cover a dimension that the grammar lacks (design, R8). + * - **Determinism.** A `determinism` test runs some histories twice and + * requires the same trace: every pin and segment history of a generated + * source, or up to eight evenly spaced enumerated histories. A trace that + * differs between runs of one history breaks replay, shrinking, and the gap + * report's comparison, so a driver records relations (before and after, + * same or different id) rather than clock readings or random ids. * - **Shrinking keeps the law.** A violation can name the law it broke. Once * a history violates one law, a smaller history that violates a different * law counts as passing, so the reported counterexample still shows the @@ -228,7 +234,24 @@ function enumeratedTests( options: OraclePropertyOptions, histories: ReadonlyArray, ): Array { - return histories.map((history) => { + const spacing = Math.max(1, Math.ceil(histories.length / 8)) + const sampled = histories.filter((_, index) => index % spacing === 0) + return [ + ...histories.map((history) => enumeratedTest(options, history)), + determinismTest(options, () => + sampled.map((history) => ({ + name: options.label?.(history) ?? JSON.stringify(history), + history, + })), + ), + ] +} + +function enumeratedTest( + options: OraclePropertyOptions, + history: History, +): PropertyTest { + { const name = options.label?.(history) ?? JSON.stringify(history) return { name, @@ -244,9 +267,76 @@ function enumeratedTests( ) }, } + } +} + +/** Runs only the driver, for a history's recorded trace. */ +async function recordTrace( + driver: OraclePropertyOptions[`driver`], + history: History, +): Promise> { + const observations: Array = [] + await driver(history, (checkpoint, value) => { + observations.push({ checkpoint, value }) + }) + return observations +} + +// A stable text for an observation: Maps, Sets, undefined, bigints, and +// errors keep their distinctions, which JSON alone drops. +function traceText(value: unknown): string { + return JSON.stringify(value, (_key, item: unknown) => { + if (item === undefined) return `$undefined` + if (typeof item === `bigint`) return `$bigint:${item}` + if (item instanceof Map) return { $map: [...item] } + if (item instanceof Set) return { $set: [...item] } + if (item instanceof Error) + return { $error: item.name, message: item.message } + return item }) } +function determinismTest( + options: OraclePropertyOptions, + histories: () => ReadonlyArray<{ name: string; history: History }>, +): PropertyTest { + return { + name: `determinism`, + replays: [], + run: async () => { + for (const { name, history } of histories()) { + const [first, second] = [ + await recordTrace(options.driver, history), + await recordTrace(options.driver, history), + ] + const length = Math.max(first.length, second.length) + for (let index = 0; index < length; index += 1) { + const [a, b] = [first[index], second[index]] + const [textA, textB] = [traceText(a), traceText(b)] + if (textA === textB) continue + const error = new Error( + [ + `${options.id}: two runs of "${name}" recorded different traces at observation ${index}${a ? ` ("${a.checkpoint}")` : ``}.`, + `First run: ${textA.slice(0, 2000)}`, + `Second run: ${textB.slice(0, 2000)}`, + `History: ${traceText(history)}`, + `A driver records relations, such as before and after or same and different, instead of clock readings or random ids.`, + ].join(`\n`), + ) as Error & { + actual?: unknown + expected?: unknown + showDiff?: boolean + } + error.actual = b + error.expected = a + error.showDiff = true + throw error + } + } + }, + } +} + /** Runs one history: the driver records, the model predicts, the check judges. */ async function runOne( options: OraclePropertyOptions, @@ -484,7 +574,22 @@ function generatedTests( ) }, })) - return [...fixedLanes, randomLane, ...pins] + // Segment histories come from the first fixed seed, so the check is + // reproducible; they are built when the test runs, like a lane's. + const determinism = determinismTest(options, () => { + const { examples, segmentOf } = examplesFor(source.fixedSeeds[0] ?? 0) + return [ + ...(source.pins ?? []).map(({ name, history }) => ({ + name: `pin: ${name}`, + history, + })), + ...examples.map(([history]) => ({ + name: `segment: ${segmentOf.get(history)!.name}`, + history, + })), + ] + }) + return [...fixedLanes, randomLane, ...pins, determinism] } // A collect-only run (ORACLE_MANIFEST set) records each registration, so the diff --git a/packages/db/tests/proxy-detachment-contract-oracle.test.ts b/packages/db/tests/proxy-detachment-contract-oracle.test.ts index 8b72f57dfc..68a09e7ca3 100644 --- a/packages/db/tests/proxy-detachment-contract-oracle.test.ts +++ b/packages/db/tests/proxy-detachment-contract-oracle.test.ts @@ -19,7 +19,7 @@ import { describe, expect, it, vi } from 'vitest' import fc from 'fast-check' import { withChangeTracking } from '../src/proxy.js' -import { oracleRuns } from './oracle-config.js' +import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' import { cycleContainer, cycleKinds, @@ -202,8 +202,9 @@ describe(`Mutation result detachment`, () => { }, ), { - seed, - numRuns: oracleRuns(100), + ...(seed === undefined + ? oraclePropertyOptions(100, `proxy.json-data-keys`) + : { seed, numRuns: oracleRuns(100) }), examples: [[`__proto__`, 1, false]], }, ) diff --git a/scripts/eslint-plugin-oracle/index.js b/scripts/eslint-plugin-oracle/index.js index d6a38d34da..e06ecf1273 100644 --- a/scripts/eslint-plugin-oracle/index.js +++ b/scripts/eslint-plugin-oracle/index.js @@ -1,5 +1,7 @@ import noDisabled from './rules/no-disabled.js' import noUnitTest from './rules/no-unit-test.js' +import pureModel from './rules/pure-model.js' +import replayableSeed from './rules/replayable-seed.js' /** * Lint rules for oracle files. @@ -27,6 +29,8 @@ const plugin = { rules: { 'no-disabled': noDisabled, 'no-unit-test': noUnitTest, + 'pure-model': pureModel, + 'replayable-seed': replayableSeed, }, } @@ -38,6 +42,8 @@ plugin.configs = { rules: { 'oracle/no-disabled': `error`, 'oracle/no-unit-test': `error`, + 'oracle/pure-model': `error`, + 'oracle/replayable-seed': `error`, }, }, } diff --git a/scripts/eslint-plugin-oracle/rules/pure-model.js b/scripts/eslint-plugin-oracle/rules/pure-model.js new file mode 100644 index 0000000000..17e1cb4951 --- /dev/null +++ b/scripts/eslint-plugin-oracle/rules/pure-model.js @@ -0,0 +1,166 @@ +/** + * An `oracleProperty` keeps its parts' jobs separate (oracle rubric, ORC-003): + * the driver touches production and the environment, and the model, the + * check, and the reach classifier are functions of the history and the + * recorded trace. A model that reads a clock or draws a random number gives a + * different expectation for the same history, so a failure stops replaying + * and the expectation is no longer independent of when it ran. This rule + * reports a `model`, `check`, or `reached` part that reads a clock, draws + * ambient randomness, or schedules a timer, directly or through a function + * declared in the same file. + * + * The driver is not restricted: the property's determinism test judges what + * it records. The rule cannot see into imported functions; reviewers still + * check those. + */ + +import { findVariable } from '../registration.js' + +const pureParts = new Set([`model`, `check`, `reached`]) +const ambientMembers = new Set([ + `Date.now`, + `Math.random`, + `performance.now`, + `crypto.getRandomValues`, + `crypto.randomUUID`, +]) +const ambientGlobals = new Set([ + `setTimeout`, + `setInterval`, + `setImmediate`, + `queueMicrotask`, +]) + +function propertyName(property) { + if (property.type !== `Property` || property.computed) return undefined + return property.key.type === `Identifier` + ? property.key.name + : property.key.value +} + +const isFunction = (node) => + node?.type === `ArrowFunctionExpression` || + node?.type === `FunctionExpression` || + node?.type === `FunctionDeclaration` + +/** The function that `node` is, or that a same-file binding `node` names. */ +function functionOf(node, sourceCode) { + if (isFunction(node)) return node + if (node?.type !== `Identifier`) return undefined + const definition = findVariable(sourceCode.getScope(node), node.name)?.defs[0] + if (definition?.type === `FunctionName`) return definition.node + if (definition?.type === `Variable` && isFunction(definition.node.init)) + return definition.node.init + return undefined +} + +/** The first ambient read in `fn` or a same-file function it calls. */ +function ambientRead(fn, sourceCode, seen = new Set()) { + if (seen.has(fn)) return undefined + seen.add(fn) + let found + const visit = (node) => { + if (found || node === null || typeof node !== `object`) return + if (Array.isArray(node)) return node.forEach(visit) + if (typeof node.type !== `string`) return + if ( + node.type === `NewExpression` && + node.callee.type === `Identifier` && + node.callee.name === `Date` && + node.arguments.length === 0 + ) { + found = { call: `new Date()` } + return + } + if (node.type === `CallExpression`) { + const { callee } = node + if ( + callee.type === `MemberExpression` && + callee.object.type === `Identifier` && + callee.property.type === `Identifier` && + ambientMembers.has(`${callee.object.name}.${callee.property.name}`) + ) { + found = { call: `${callee.object.name}.${callee.property.name}()` } + return + } + if (callee.type === `Identifier`) { + if (ambientGlobals.has(callee.name)) { + found = { call: `${callee.name}()` } + return + } + const called = functionOf(callee, sourceCode) + if (called !== undefined) { + const inner = ambientRead(called, sourceCode, seen) + if (inner !== undefined) { + found = { call: inner.call, through: inner.through ?? callee.name } + return + } + } + } + } + for (const key of Object.keys(node)) { + if (key !== `parent`) visit(node[key]) + } + } + visit(fn.body) + return found +} + +/** Whether `callee` is `oracleProperty` imported from the oracle-property module. */ +function isOracleProperty(callee, sourceCode) { + if (callee.type !== `Identifier` || callee.name !== `oracleProperty`) + return false + const definition = findVariable(sourceCode.getScope(callee), callee.name) + ?.defs[0] + return ( + definition?.type === `ImportBinding` && + /(^|\/)oracle-property(\.js)?$/.test(definition.parent.source.value) + ) +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: `problem`, + docs: { + description: `Report an oracleProperty model, check, or reach classifier that reads a clock, ambient randomness, or a timer.`, + }, + schema: [], + messages: { + impure: `\`{{part}}\` calls \`{{call}}\`{{through}}, so the same history can get a different {{result}}. The {{part}} is a function of the history and the recorded trace; the driver records anything it needs from the clock or the environment.`, + }, + }, + create(context) { + const { sourceCode } = context + return { + CallExpression(node) { + if (!isOracleProperty(node.callee, sourceCode)) return + const options = node.arguments[0] + if (options?.type !== `ObjectExpression`) return + for (const property of options.properties) { + const part = propertyName(property) + if (!pureParts.has(part)) continue + const fn = functionOf(property.value, sourceCode) + if (fn === undefined) continue + const read = ambientRead(fn, sourceCode) + if (read === undefined) continue + context.report({ + node: property, + messageId: `impure`, + data: { + part, + call: read.call, + through: read.through ? ` through \`${read.through}\`` : ``, + result: + part === `check` + ? `verdict` + : part === `reached` + ? `set of reached states` + : `expectation`, + }, + }) + } + }, + } + }, +} diff --git a/scripts/eslint-plugin-oracle/rules/replayable-seed.js b/scripts/eslint-plugin-oracle/rules/replayable-seed.js new file mode 100644 index 0000000000..addfdb71b7 --- /dev/null +++ b/scripts/eslint-plugin-oracle/rules/replayable-seed.js @@ -0,0 +1,230 @@ +/** + * A generated property's failure is reported as a seed and a shrink path, + * and the shared variables TANSTACK_DB_ORACLE_SEED and + * TANSTACK_DB_ORACLE_PATH replay it. That works only when every lane takes + * its seed from one of the places a replay can reach. This rule reports a + * `seed` option that a replay cannot reach: + * + * - A seed that can be `undefined`: the literal `undefined`, or a parameter of + * an `.each(table)` callback whose table holds `undefined`, used where + * nothing routes the `undefined` lane elsewhere. fast-check then draws a + * fresh seed on every run, which the shared variables never select, and the + * explicit `undefined` also overrides a global seed. The accepted form + * routes that lane to the shared config: + * `seed === undefined ? oraclePropertyOptions(runs, id) : { seed, ... }`. + * - A seed computed from a clock or ambient randomness, such as `Date.now()` + * or `Math.random()`, unless it only fills in for the shared config's seed, + * as in `oraclePropertyOptions(runs, id).seed ?? Date.now()`: a replay + * supplies that seed, and the fallback is the random lane. + * + * A number literal, a constant, a recorded seed such as `failure.seed`, and a + * parameter whose table holds only numbers are all replayable. oracle-config + * and oracle-property implement the shared selection, so they may pick a + * random lane's seed themselves. + * + * The rule cannot see a seed passed through a helper's parameter from a call + * elsewhere, or a table built by a function. Reviewers still check those. + */ + +import { findVariable } from '../registration.js' + +const sharedSelectionModule = + /(^|[/\\])packages[/\\]db[/\\]tests[/\\]oracle-(config|property)\.ts$/ +const ambientCalls = new Set([ + `Date.now`, + `Math.random`, + `performance.now`, + `crypto.getRandomValues`, + `crypto.randomUUID`, +]) + +function propertyName(property) { + if (property.type !== `Property` || property.computed) return undefined + return property.key.type === `Identifier` + ? property.key.name + : property.key.value +} + +/** `oraclePropertyOptions(...).seed ?? fallback`: the fallback is a random lane. */ +function isSharedSeedFallback(node) { + return ( + node.type === `LogicalExpression` && + node.operator === `??` && + node.left.type === `MemberExpression` && + node.left.property.type === `Identifier` && + node.left.property.name === `seed` && + node.left.object.type === `CallExpression` && + node.left.object.callee.type === `Identifier` && + node.left.object.callee.name === `oraclePropertyOptions` + ) +} + +const isUndefined = (node) => + node?.type === `Identifier` && node.name === `undefined` + +/** The `a.b` name of a call's callee, for a member call on an identifier. */ +function memberCallName(node) { + if (node.type !== `CallExpression`) return undefined + const { callee } = node + if ( + callee.type !== `MemberExpression` || + callee.object.type !== `Identifier` || + callee.property.type !== `Identifier` + ) { + return undefined + } + return `${callee.object.name}.${callee.property.name}` +} + +/** The ambient call inside `node`, if any. */ +function ambientCall(node) { + let found + const visit = (current) => { + if (found || current === null || typeof current !== `object`) return + if (Array.isArray(current)) return current.forEach(visit) + if (typeof current.type !== `string`) return + if (current.type === `NewExpression` && current.callee.name === `Date`) { + if (current.arguments.length === 0) found = `new Date()` + return + } + const name = memberCallName(current) + if (name !== undefined && ambientCalls.has(name)) { + found = `${name}()` + return + } + for (const key of Object.keys(current)) { + if (key !== `parent`) visit(current[key]) + } + } + visit(node) + return found +} + +/** + * The `.each(table)` table that supplies `parameter`, when the parameter's + * function is the test body of `test.each(table)(name, body)`, inline or + * named. + */ +function eachTableOf(fn, sourceCode) { + const bodyCall = (node) => { + const call = node.parent + if (call?.type !== `CallExpression` || call.arguments[1] !== node) { + return undefined + } + const each = call.callee + return each.type === `CallExpression` && + each.callee.type === `MemberExpression` && + each.callee.property.name === `each` + ? each.arguments[0] + : undefined + } + const direct = bodyCall(fn) + if (direct !== undefined) return direct + // A named body: find the `.each(table)(name, body)` call that passes it. + const name = + fn.type === `FunctionDeclaration` + ? fn.id?.name + : fn.parent?.type === `VariableDeclarator` + ? fn.parent.id.name + : undefined + if (name === undefined) return undefined + const variable = findVariable(sourceCode.getScope(fn), name) + for (const reference of variable?.references ?? []) { + const table = bodyCall(reference.identifier) + if (table !== undefined) return table + } + return undefined +} + +/** The array literal that `node` is, or that a constant `node` names. */ +function arrayLiteral(node, sourceCode) { + if (node?.type === `ArrayExpression`) return node + if (node?.type !== `Identifier`) return undefined + const definition = findVariable(sourceCode.getScope(node), node.name)?.defs[0] + return definition?.type === `Variable` && + definition.node.init?.type === `ArrayExpression` + ? definition.node.init + : undefined +} + +/** Whether `node` lies on the branch where `name` is known to be defined. */ +function guardedAgainstUndefined(node, name) { + const definedWhen = (test) => { + if (test?.type !== `BinaryExpression`) return undefined + const sides = [test.left, test.right] + const namesIt = sides.some( + (side) => side.type === `Identifier` && side.name === name, + ) + if (!namesIt || !sides.some(isUndefined)) return undefined + if (test.operator === `===` || test.operator === `==`) return false + if (test.operator === `!==` || test.operator === `!=`) return true + return undefined + } + for (let child = node, parent = node.parent; parent; ) { + if ( + parent.type === `ConditionalExpression` || + parent.type === `IfStatement` + ) { + const defined = definedWhen(parent.test) + const branch = + child === parent.consequent + ? true + : child === parent.alternate + ? false + : undefined + if (defined !== undefined && branch !== undefined && defined === branch) + return true + } + child = parent + parent = parent.parent + } + return false +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: `problem`, + docs: { + description: `Report a property seed that the shared replay variables cannot select.`, + }, + schema: [], + messages: { + undefinedSeed: `This seed can be \`undefined\`, so fast-check draws a fresh seed that TANSTACK_DB_ORACLE_SEED and TANSTACK_DB_ORACLE_PATH cannot replay, and the explicit \`undefined\` overrides a global seed. Route that lane to the shared config: \`seed === undefined ? oraclePropertyOptions(runs, id) : { seed, ... }\`.`, + ambientSeed: `This seed comes from \`{{call}}\`, so the shared replay variables cannot select it and two runs draw different histories. Use a number literal or constant, a recorded seed, or \`oraclePropertyOptions(runs, id)\`.`, + }, + }, + create(context) { + const { sourceCode } = context + if (sharedSelectionModule.test(context.filename)) return {} + return { + Property(node) { + if (propertyName(node) !== `seed`) return + const value = node.value + const call = isSharedSeedFallback(value) ? undefined : ambientCall(value) + if (call !== undefined) { + context.report({ node, messageId: `ambientSeed`, data: { call } }) + return + } + if (isUndefined(value)) { + context.report({ node, messageId: `undefinedSeed` }) + return + } + if (value.type !== `Identifier`) return + const definition = findVariable(sourceCode.getScope(value), value.name) + ?.defs[0] + if (definition?.type !== `Parameter`) return + const fn = definition.node + if (fn.params[0]?.type !== `Identifier` || fn.params[0].name !== value.name) + return + const table = arrayLiteral(eachTableOf(fn, sourceCode), sourceCode) + if ( + table?.elements.some(isUndefined) && + !guardedAgainstUndefined(node, value.name) + ) { + context.report({ node, messageId: `undefinedSeed` }) + } + }, + } + }, +} diff --git a/scripts/eslint-plugin-oracle/seed-and-purity.test.js b/scripts/eslint-plugin-oracle/seed-and-purity.test.js new file mode 100644 index 0000000000..6932f91fc2 --- /dev/null +++ b/scripts/eslint-plugin-oracle/seed-and-purity.test.js @@ -0,0 +1,81 @@ +import { describe, it } from 'vitest' +import { RuleTester } from 'eslint' +import tsParser from '@typescript-eslint/parser' +import pureModel from './rules/pure-model.js' +import replayableSeed from './rules/replayable-seed.js' + +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester({ + languageOptions: { parser: tsParser }, +}) + +ruleTester.run(`replayable-seed`, replayableSeed, { + valid: [ + `fc.assert(property, { seed: 42, numRuns: oracleRuns(10) })`, + `const SEED = 7; fc.assert(property, { seed: SEED })`, + `fc.assert(property, { seed: failure.seed, path: failure.counterexamplePath })`, + `it.each([1, 2])('lane %s', (seed) => fc.assert(property, { seed, numRuns: oracleRuns(5) }))`, + `it.each([1, undefined])('lane %s', (seed) => fc.assert(property, seed === undefined ? oraclePropertyOptions(5, 'id') : { seed, numRuns: oracleRuns(5) }))`, + `it.each([1, undefined])('lane %s', (seed) => fc.assert(property, { ...(seed !== undefined ? { seed, numRuns: oracleRuns(5) } : oraclePropertyOptions(5, 'id')) }))`, + `const lanes = [{ seed: oraclePropertyOptions(10, 'id').seed ?? Math.floor(Math.random() * 1000) }]`, + { + code: `const seed = replay ?? Date.now() % 2 ** 31; fc.assert(property, { seed })`, + filename: `/repo/packages/db/tests/oracle-property.ts`, + }, + ], + invalid: [ + { + code: `it.each([1, undefined])('lane %s', (seed) => fc.assert(property, { seed, numRuns: oracleRuns(5) }))`, + errors: [{ messageId: `undefinedSeed` }], + }, + { + code: `function lane(seed) { fc.assert(property, { seed, numRuns: oracleRuns(5) }) } it.each([1, undefined])('lane %s', lane)`, + errors: [{ messageId: `undefinedSeed` }], + }, + { + code: `fc.assert(property, { seed: undefined })`, + errors: [{ messageId: `undefinedSeed` }], + }, + { + code: `fc.assert(property, { seed: Date.now() % 2 ** 31 })`, + errors: [{ messageId: `ambientSeed`, data: { call: `Date.now()` } }], + }, + { + code: `fc.assert(property, { seed: Math.floor(Math.random() * 1000) })`, + errors: [{ messageId: `ambientSeed`, data: { call: `Math.random()` } }], + }, + ], +}) + +const header = `import { oracleProperty } from './oracle-property'\n` + +ruleTester.run(`pure-model`, pureModel, { + valid: [ + header + + `function model(history, trace) { return trace.length } oracleProperty({ id: 'x', model, check: () => pass, driver: async (h, observe) => observe('at', Date.now()) })`, + // Another module's oracleProperty is not this framework's. + `import { oracleProperty } from './elsewhere'; oracleProperty({ model: () => Date.now() })`, + ], + invalid: [ + { + code: + header + + `oracleProperty({ id: 'x', model: () => Date.now(), check: () => pass })`, + errors: [{ messageId: `impure` }], + }, + { + code: + header + + `function stamp() { return new Date() } function check() { return stamp() ? pass : pass } oracleProperty({ id: 'x', model: () => 1, check })`, + errors: [{ messageId: `impure` }], + }, + { + code: + header + + `const reached = () => { setTimeout(() => {}); return [] }; oracleProperty({ id: 'x', model: () => 1, check: () => pass, reached })`, + errors: [{ messageId: `impure` }], + }, + ], +}) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 3a5daf7767..98f30cb508 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -32,10 +32,57 @@ const state = (process.env.ORACLE_GAPS_STATE ? JSON.parse(readFileSync(process.env.ORACLE_GAPS_STATE, `utf8`)) : { mutant: null, select: null, capture: null }) -if (state.mutant !== null) { - const fc = await import(`fast-check`).catch(() => undefined) - fc?.configureGlobal({ ...fc.readConfigureGlobal(), endOnFailure: true }) +const fc = await import(`fast-check`).catch(() => undefined) +if (fc) { + // A property that takes no seed from the shared config would draw new + // histories on every run; a global seed makes every run draw the same ones. + // A mutant run stops a property at its first failure. + fc.configureGlobal({ + ...fc.readConfigureGlobal(), + seed: fc.readConfigureGlobal().seed ?? 20251102, + ...(state.mutant !== null ? { endOnFailure: true } : {}), + }) +} + +// Ambient randomness is made deterministic per test: `Math.random`, +// `crypto.getRandomValues`, and `crypto.randomUUID` draw from a generator +// seeded by the test's name, so a test sees the same values in every run +// whatever ran before it. Production paths are unchanged; only the values +// they draw repeat. +function seededRandom(text) { + let seed = 2166136261 + for (let i = 0; i < text.length; i += 1) + seed = Math.imul(seed ^ text.charCodeAt(i), 16777619) + return () => { + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } } +function installRandom(text) { + const next = seededRandom(text) + Math.random = next + const crypto = globalThis.crypto + if (!crypto) return + crypto.getRandomValues = (array) => { + const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength) + for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(next() * 256) + return array + } + crypto.randomUUID = () => { + const hex = Array.from({ length: 32 }, () => + Math.floor(next() * 16).toString(16), + ) + hex[12] = `4` + hex[16] = ((Number.parseInt(hex[16], 16) & 3) | 8).toString(16) + const text = hex.join(``) + return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}` + } +} +// Code that runs while the test file loads, such as a runtime nonce created +// on first use, draws from its own seeded generator; it runs in a fixed order. +installRandom(`module`) const selected = state.select ? new Set(state.select) : null // Set ORACLE_GAPS_DEBUG to a path to keep every rendered value, for finding // the source of a nondeterministic capture. @@ -136,6 +183,31 @@ function canonical(text) { }) } +// Wall-clock times such as `Date.now()` stamps differ on every run too. An +// integer or Date within ten years of this run's start is a time and renders +// as `time`. Even whether two stamps are equal depends on whether two events +// fell in the same millisecond, so no identity is kept. Time cannot be made +// deterministic the way randomness is (below): timers depend on it. +const runStart = Date.now() +const tenYears = 10 * 365 * 24 * 60 * 60 * 1000 +const time = () => `time` + +// A class instance renders as its identity, `ClassName#n` in order of first +// appearance in the test, not as its fields. A matcher such as `toBe` compares +// identity, and a production object's fields hold internal state, including +// clock readings, that no assertion sees. A class with its own `toJSON`, and a +// Temporal value, render through it instead. Limit: a mutant that changes +// only the fields of a class instance compared with `toEqual` reads as no +// observed difference; captures classify survivors and never decide a kill. +let identities = new Map() +function identity(value, name) { + if (!identities.has(value)) + identities.set(value, `${name}#${identities.size}`) + return identities.get(value) +} +const isTime = (value) => + Number.isInteger(value) && Math.abs(value - runStart) < tenYears + // A stable, cycle-safe rendering. It keeps the distinctions an assertion can // see (types, order, Map and Set entries, error identity by name and message) // and drops ones it cannot (object identity, functions' bodies). @@ -144,7 +216,8 @@ function render(value, seen = new Set(), depth = 0) { if (value === null) return `null` const type = typeof value if (type === `undefined`) return `undefined` - if (type === `number`) return Object.is(value, -0) ? `-0` : String(value) + if (type === `number`) + return Object.is(value, -0) ? `-0` : isTime(value) ? time() : String(value) if (type === `string`) return JSON.stringify(canonical(value.slice(0, 4000))) if (type === `boolean` || type === `bigint`) return `${type}:${value}` if (type === `symbol`) return `symbol:${value.description ?? ``}` @@ -153,8 +226,9 @@ function render(value, seen = new Set(), depth = 0) { seen.add(value) try { if (value instanceof Error) - return `Error:${value.name}:${value.message}:${render(value.cause, seen, depth + 1)}` - if (value instanceof Date) return `Date:${value.getTime()}` + return `Error:${value.name}:${canonical(value.message)}:${render(value.cause, seen, depth + 1)}` + if (value instanceof Date) + return `Date:${isTime(value.getTime()) ? time() : value.getTime()}` if (value instanceof Map) return `Map{${[...value].map(([k, v]) => `${render(k, seen, depth + 1)}=>${render(v, seen, depth + 1)}`).join(`,`)}}` if (value instanceof Set) @@ -164,7 +238,16 @@ function render(value, seen = new Set(), depth = 0) { if (ArrayBuffer.isView(value)) return `${value.constructor.name}(${Array.from(value).join(`,`)})` if (typeof value.then === `function`) return `Promise` - const name = value.constructor?.name ?? `null-proto` + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + const name = value.constructor?.name ?? `anonymous` + if (typeof value.toJSON === `function`) + return `${name}(${render(value.toJSON(), seen, depth + 1)})` + if (String(value[Symbol.toStringTag] ?? ``).startsWith(`Temporal.`)) + return `${name}(${String(value)})` + return identity(value, name) + } + const name = prototype === null ? `null-proto` : `Object` const keys = Object.keys(value).sort() return `${name}{${keys.map((k) => `${k}:${render(value[k], seen, depth + 1)}`).join(`,`)}}` } finally { @@ -175,8 +258,12 @@ function render(value, seen = new Set(), depth = 0) { // Every matcher is a method on chai's Assertion prototype. Wrapping each one // records its name and the value under test before the matcher runs. const proto = chai.Assertion.prototype +// `withTest` is Vitest's binding of an assertion to its test, and `assert` is +// the check every matcher calls; neither is a matcher. Whether `withTest` +// runs varies between runs, and wrapping `assert` counts each matcher twice. +const notMatchers = new Set([`constructor`, `withTest`, `assert`]) for (const name of Object.getOwnPropertyNames(proto)) { - if (name === `constructor`) continue + if (notMatchers.has(name)) continue const descriptor = Object.getOwnPropertyDescriptor(proto, name) if (!descriptor || typeof descriptor.value !== `function`) continue const matcher = descriptor.value @@ -210,9 +297,11 @@ beforeEach((context) => { observed = createHash(`sha1`) observations = 0 if (selected && !selected.has(testKey(context.task))) context.skip() + installRandom(testKey(context.task)) digest = createHash(`sha1`) count = 0 uuids = new Map() + identities = new Map() hits = new Set() outcomes = new Map() inTest = true From d47787f7fb8c57a767a6ec413a09f4eb7573d46a Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 06:46:51 -0700 Subject: [PATCH 006/168] test(oracles): replace the optimistic oracle's 20 ms race with logical time and a named cut check Two framework helpers from the optimistic-history migration (stage 1): - withinTurns(promise, turns), form F-F: resolves true when the promise settles, at the microtask an await would, or false after `turns` event-loop turns. The optimistic driver raced each settlement against a 20 ms timer as a deadlock guard. Across all four owners, 1,037 settlements arrived before the first event-loop turn and the timer never won, so a 10-turn bound keeps every checkpoint and removes the wall-clock dependence that a loaded machine could turn into a failure. A deterministic-simulation scheduler is not needed for this case. - forwardCut(cuts, observed, from, same), form F-E: the first allowed cut at or after `from` equal to an observation, or -1. The driver's hand-built publication matching now uses it; its backwards-cuts mutant still fails the publication owner. Owners: outcomes, publication, and settlement-boundaries (353 tests) and the persisted lane (10 of 12, as before) pass. Co-authored-by: Isaac --- .../db/tests/optimistic-history-oracle.ts | 19 ++++---- packages/db/tests/oracle-property.ts | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index fc2ceb4534..44d0a2e4e0 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -8,6 +8,7 @@ import { createLiveQueryCollection } from '../src/query/index.js' import { whenSyncAccepted } from '../src/sync-receipt.js' import { createTransaction } from '../src/transactions.js' import { declareOracle } from './oracle-declaration.js' +import { forwardCut, withinTurns } from './oracle-property.js' import type { CollectionConfig, SyncConfig } from '../src/types.js' /** @@ -1281,9 +1282,11 @@ export async function runOptimisticHistory( change.value.id, ) } - const next = cuts.findIndex( - (cut, index) => - index >= cutIndex && isDeepStrictEqual(cut, publication.replica), + const next = forwardCut( + cuts, + publication.replica, + cutIndex, + isDeepStrictEqual, ) expect( next, @@ -1553,11 +1556,11 @@ export async function runOptimisticHistory( op.tx.rollback({ isSecondaryRollback: !step.cascade }) op.done.resolve() } - // A receipt that waited for visibility would deadlock the handler. - await Promise.race([ - op.outcome.settled, - new Promise((resolve) => setTimeout(resolve, 20)), - ]) + // A receipt that waited for visibility would deadlock the handler, + // so the wait is bounded. Settlement arrives within microtasks in + // every history (1,037 settlements, none after the first turn), so + // the bound counts event-loop turns, not milliseconds. + await withinTurns(op.outcome.settled, 10) await Promise.resolve() expect( op.settledRows, diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index a7dc013f43..ba3b9a08a1 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -131,6 +131,54 @@ export function violation( return { kind: `violation`, checkpoint, difference, law, values } } +/** + * History checking against allowed cuts (oracle-framework design, form F-E). + * A model can often name, in order, the states an observer may see after a + * step without fixing which of them it sees: a publication may skip a state + * but never go back to an earlier one. `forwardCut` returns the index of the + * first cut at or after `from` that equals `observed`, or -1 when the + * observation is no allowed state ahead of `from`. The caller keeps the + * returned index as the next `from`. + */ +export function forwardCut( + cuts: ReadonlyArray, + observed: State, + from: number, + same: (allowed: State, observed: State) => boolean, +): number { + for (let index = from; index < cuts.length; index += 1) + if (same(cuts[index]!, observed)) return index + return -1 +} + +/** + * Logical time for drivers (oracle-framework design, form F-F). A driver that + * must not hang when production never settles a promise waits with + * `withinTurns(promise, turns)`: it resolves `true` as soon as the promise + * settles, at the same microtask an `await` would, or `false` after `turns` + * event-loop turns. The bound counts turns, not milliseconds, so a loaded + * machine cannot turn a slow but legal settlement into a timeout. It cannot + * wait for work that production schedules on a delayed timer; a driver for + * such work controls that timer. + */ +export function withinTurns( + promise: Promise, + turns: number, +): Promise { + // An object, so the loop reads the flag that the callbacks set. + const state = { settled: false } + const done = promise.then( + () => (state.settled = true), + () => (state.settled = true), + ) + const expiry = (async () => { + for (let turn = 0; turn < turns && !state.settled; turn += 1) + await new Promise((resolve) => setTimeout(resolve, 0)) + return false + })() + return Promise.race([done, expiry]) +} + /** * The check found a history whose observations the law does not allow. * `actual` and `expected` are the fields Vitest reads to print a diff. From 8a05fb03b88f0035ecf5467774b7b6c3c06bbc40 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 07:00:23 -0700 Subject: [PATCH 007/168] test(oracles): add resolution, calibration, and pin reach to oracleProperty Three parts that the optimistic-history split needs: - resolve(history) -> plan. A history may name actions indirectly ("settle the second active transaction"); the plan holds the concrete actions that the driver runs and the model and check read, never expected observations. The Plan type defaults to the history, so existing properties are unchanged. - calibrations (design G8, rubric ORC-006): a named wrong answer and the law that must reject it, either a `corrupt` trace transform (an observation mutant) or a hostile `driver` (production set up wrongly). The test finds a candidate history where the corruption applies, requires its clean trace to pass, and requires the named law for the corrupted one. runOne is now record + judge, so a corrupted trace is judged on the same path. - A pin may name the states it `enters`. The hydration property gains two calibrations: a failure status shown as `ready` must violate `view`, and a stale last read must violate `announcement`. Both pass; a copy with the wrong law named, and a copy whose corruption changes nothing, fail with messages that say so. Co-authored-by: Isaac --- ...observer-hydration-oracle.property.test.ts | 58 +++++ packages/db/tests/oracle-property.ts | 237 +++++++++++++++--- 2 files changed, 258 insertions(+), 37 deletions(-) diff --git a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts index e2d67be732..b1130d22ee 100644 --- a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts @@ -655,6 +655,64 @@ oracleProperty({ })), ), }, + // Wrong answers the check must reject: a failure shown as `ready` under + // the seed (law 1), and a change the listener never heard about (law 4). + calibrations: [ + { + name: `failure shown as ready`, + law: `view`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => { + const record = value as StepRecord + return !(`skipped` in record) && failures.has(record.input.after) + }) + if (at < 0) return undefined + return trace.map((entry, index) => { + if (index !== at) return entry + const record = entry.value as Extract + return { + ...entry, + value: { + ...record, + output: { + ...record.output, + view: { ...record.output.view, status: `ready` }, + }, + }, + } + }) + }, + }, + { + name: `unannounced change`, + law: `announcement`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => { + const record = value as StepRecord + return ( + !(`skipped` in record) && + record.output.lastRead !== undefined && + record.output.view.rows.length > 0 + ) + }) + if (at < 0) return undefined + return trace.map((entry, index) => { + if (index !== at) return entry + const record = entry.value as Extract + return { + ...entry, + value: { + ...record, + output: { + ...record.output, + lastRead: { ...record.output.lastRead!, rows: [] }, + }, + }, + } + }) + }, + }, + ], model: hydrationModel, reached: (_history, _trace, expected) => expected.reached, driver: driveHistory, diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index ba3b9a08a1..cb12bbdcba 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -59,10 +59,23 @@ * measures is declared in `measured` instead (ORC-015). Half of a lane's * generated histories also go through a segment, so the histories after * each required state are explored, not visited once. - * - A **pin** is a fixed history, such as a regression. A pin and a segment's + * - A **pin** is a fixed history, such as a regression. It may name the + * states it `enters`, which its run must reach. A pin and a segment's * history must be histories the arbitrary could generate * (`canShrinkWithoutContext`), so they shrink like generated ones and cannot * cover a dimension that the grammar lacks (design, R8). + * - **Resolution.** A history can name its actions indirectly, such as "settle + * the second active transaction". `resolve(history)` turns it into the plan + * of concrete actions that the driver runs and that the model and check read. + * The plan holds actions, never expected observations, so the driver still + * decides nothing. A history shrinks before resolution, so every shrunk + * history still resolves to a legal plan. + * - **Calibration.** A `calibration` names a wrong answer and the law that + * must reject it: a `corrupt` function that alters a recorded trace, as an + * observation mutant does, or a hostile `driver`, such as production set up + * wrongly. The calibration test finds a history where the corruption + * applies, requires the clean trace to pass, and requires the check to report + * that law for the corrupted one (rubric, ORC-006). * - **Determinism.** A `determinism` test runs some histories twice and * requires the same trace: every pin and segment history of a generated * source, or up to eight evenly spaced enumerated histories. A trace that @@ -218,10 +231,36 @@ export interface GeneratedHistories { /** One fixed lane per seed, beside the random lane. */ fixedSeeds: ReadonlyArray segments?: ReadonlyArray> - pins?: ReadonlyArray<{ name: string; history: History }> + pins?: ReadonlyArray> } -export interface OraclePropertyOptions { +/** A fixed history, such as a regression, and the states it must enter. */ +export interface Pin { + name: string + history: History + enters?: ReadonlyArray +} + +/** A wrong answer and the law that must reject it (ORC-006). */ +export type Calibration = { + name: string + /** The law the check must report. */ + law: string +} & ( + | { + /** A corrupted copy of the trace, or undefined when nothing applies. */ + corrupt: ( + trace: ReadonlyArray, + plan: Plan, + ) => ReadonlyArray | undefined + } + | { + /** A hostile driver, such as production configured wrongly. */ + driver: OraclePropertyOptions[`driver`] + } +) + +export interface OraclePropertyOptions { /** A stable identifier, used in failures, the manifest, and replay. */ id: string law: OracleLaw @@ -229,22 +268,25 @@ export interface OraclePropertyOptions { histories: ReadonlyArray | GeneratedHistories /** A short, unique test name for an enumerated history. */ label?: (history: History) => string - model: (history: History, trace: ReadonlyArray) => Expected + /** The plan of concrete actions; required when Plan is not History. */ + resolve?: (history: History) => Plan + model: (plan: Plan, trace: ReadonlyArray) => Expected /** The states the history entered. Defaults to the trace's `reach` values. */ reached?: ( - history: History, + plan: Plan, trace: ReadonlyArray, expected: Expected, ) => Iterable driver: ( - history: History, + plan: Plan, observe: (checkpoint: string, value: unknown) => void, ) => Promise | void check: ( - history: History, + plan: Plan, observations: ReadonlyArray, expected: Expected, ) => Verdict + calibrations?: ReadonlyArray> } declare global { @@ -262,8 +304,8 @@ interface PropertyTest { timeout?: number } -export function oracleProperty( - options: OraclePropertyOptions, +export function oracleProperty( + options: OraclePropertyOptions, ): void { const { id, histories } = options recordRegistration(id, options.law, histories) @@ -278,14 +320,20 @@ export function oracleProperty( }) } -function enumeratedTests( - options: OraclePropertyOptions, +function enumeratedTests( + options: OraclePropertyOptions, histories: ReadonlyArray, ): Array { const spacing = Math.max(1, Math.ceil(histories.length / 8)) const sampled = histories.filter((_, index) => index % spacing === 0) return [ ...histories.map((history) => enumeratedTest(options, history)), + ...calibrationTests(options, () => + histories.map((history) => ({ + name: options.label?.(history) ?? JSON.stringify(history), + history, + })), + ), determinismTest(options, () => sampled.map((history) => ({ name: options.label?.(history) ?? JSON.stringify(history), @@ -295,8 +343,8 @@ function enumeratedTests( ] } -function enumeratedTest( - options: OraclePropertyOptions, +function enumeratedTest( + options: OraclePropertyOptions, history: History, ): PropertyTest { { @@ -319,17 +367,30 @@ function enumeratedTest( } /** Runs only the driver, for a history's recorded trace. */ -async function recordTrace( - driver: OraclePropertyOptions[`driver`], - history: History, +async function recordTrace( + driver: ( + plan: Plan, + observe: (checkpoint: string, value: unknown) => void, + ) => Promise | void, + plan: Plan, ): Promise> { const observations: Array = [] - await driver(history, (checkpoint, value) => { + await driver(plan, (checkpoint, value) => { observations.push({ checkpoint, value }) }) return observations } +/** The plan a history resolves to; a history is its own plan by default. */ +function planOf( + options: OraclePropertyOptions, + history: History, +): Plan { + return options.resolve + ? options.resolve(history) + : (history as unknown as Plan) +} + // A stable text for an observation: Maps, Sets, undefined, bigints, and // errors keep their distinctions, which JSON alone drops. function traceText(value: unknown): string { @@ -344,8 +405,8 @@ function traceText(value: unknown): string { }) } -function determinismTest( - options: OraclePropertyOptions, +function determinismTest( + options: OraclePropertyOptions, histories: () => ReadonlyArray<{ name: string; history: History }>, ): PropertyTest { return { @@ -353,9 +414,10 @@ function determinismTest( replays: [], run: async () => { for (const { name, history } of histories()) { + const plan = planOf(options, history) const [first, second] = [ - await recordTrace(options.driver, history), - await recordTrace(options.driver, history), + await recordTrace(options.driver, plan), + await recordTrace(options.driver, plan), ] const length = Math.max(first.length, second.length) for (let index = 0; index < length; index += 1) { @@ -386,25 +448,55 @@ function determinismTest( } /** Runs one history: the driver records, the model predicts, the check judges. */ -async function runOne( - options: OraclePropertyOptions, +async function runOne( + options: OraclePropertyOptions, history: History, + driver: OraclePropertyOptions< + History, + Expected, + Plan + >[`driver`] = options.driver, ): Promise<{ verdict: Verdict; reached: Set }> { + const plan = planOf(options, history) + const { observations, driverError } = await recordGuarded(driver, plan) + return judge(options, plan, observations, driverError) +} + +/** Runs the driver and keeps an error that escapes it beside the trace. */ +async function recordGuarded( + driver: ( + plan: Plan, + observe: (checkpoint: string, value: unknown) => void, + ) => Promise | void, + plan: Plan, +): Promise<{ + observations: Array + driverError?: { error: unknown } +}> { const observations: Array = [] - let driverError: { error: unknown } | undefined try { - await options.driver(history, (checkpoint, value) => { + await driver(plan, (checkpoint, value) => { observations.push({ checkpoint, value }) globalThis.__oracleObserve?.(checkpoint, value) }) } catch (error) { - driverError = { error } + return { observations, driverError: { error } } } + return { observations } +} + +/** The model predicts and the check judges a recorded trace. */ +function judge( + options: OraclePropertyOptions, + plan: Plan, + observations: ReadonlyArray, + driverError?: { error: unknown }, +): { verdict: Verdict; reached: Set } { let expected: Expected let verdict: Verdict try { - expected = options.model(history, observations) - verdict = options.check(history, observations, expected) + expected = options.model(plan, observations) + verdict = options.check(plan, observations, expected) } catch (error) { // A partial trace the model or check cannot read is still a setup error. throw driverError ? driverError.error : error @@ -420,7 +512,7 @@ async function runOne( verdict = { ...verdict, cause: driverError.error } } const reached = new Set( - options.reached?.(history, observations, expected) ?? + options.reached?.(plan, observations, expected) ?? observations .filter((entry) => entry.checkpoint === `reach`) .map((entry) => String(entry.value)), @@ -428,6 +520,61 @@ async function runOne( return { verdict, reached } } +const lawOf = (verdict: Extract) => + verdict.law ?? verdict.checkpoint + +/** + * One test per calibration. It tries candidate histories in order until the + * wrong answer applies, and requires the check to report the named law. + */ +function calibrationTests( + options: OraclePropertyOptions, + candidates: () => ReadonlyArray<{ name: string; history: History }>, +): Array { + const { id } = options + return (options.calibrations ?? []).map((calibration) => ({ + name: `calibration: ${calibration.name}`, + replays: [], + timeout: 120_000, + run: async () => { + const seen: Array = [] + for (const { name, history } of candidates()) { + const plan = planOf(options, history) + if (`corrupt` in calibration) { + const { observations, driverError } = await recordGuarded( + options.driver, + plan, + ) + const corrupted = calibration.corrupt(observations, plan) + if (corrupted === undefined) continue + // A calibration is meaningful only where the true trace passes. + const clean = judge(options, plan, observations, driverError).verdict + if (clean.kind === `violation`) + throw failure( + options, + clean, + history, + `the calibration's history "${name}"`, + ) + const wrong = judge(options, plan, corrupted).verdict + if (wrong.kind === `violation` && lawOf(wrong) === calibration.law) + return + throw new Error( + `${id}: calibration "${calibration.name}" corrupted the trace of "${name}", but the check ${wrong.kind === `pass` ? `passed it` : `reported law "${lawOf(wrong)}"`}. It must report law "${calibration.law}".`, + ) + } + const { verdict } = await runOne(options, history, calibration.driver) + if (verdict.kind === `pass`) continue + if (lawOf(verdict) === calibration.law) return + seen.push(`"${name}" reported law "${lawOf(verdict)}"`) + } + throw new Error( + `${id}: calibration "${calibration.name}" found no history where the check reports law "${calibration.law}"${seen.length ? `; ${seen.slice(0, 3).join(`, `)}` : ``}. Add a pin or segment that reaches the state its wrong answer changes.`, + ) + }, + })) +} + function failure( options: { id: string; law: OracleLaw }, verdict: Extract, @@ -461,8 +608,8 @@ function rerunByName(name: string): string { return `-t ${JSON.stringify(`^${pattern}$`)}` } -function generatedTests( - options: OraclePropertyOptions, +function generatedTests( + options: OraclePropertyOptions, source: GeneratedHistories, ): Array { const { id } = options @@ -612,7 +759,7 @@ function generatedTests( throw new Error( `${id}: pin "${pin.name}" is not a history the arbitrary can generate. A pin must stay inside the generated language, or the property must declare the missing dimension as a limit.`, ) - const { verdict } = await runOne(options, pin.history) + const { verdict, reached } = await runOne(options, pin.history) if (verdict.kind === `violation`) throw failure( options, @@ -620,11 +767,16 @@ function generatedTests( pin.history, rerunByName(`${id} pin: ${pin.name}`), ) + const missing = (pin.enters ?? []).filter((state) => !reached.has(state)) + if (missing.length > 0) + throw new Error( + `${id}: pin "${pin.name}" did not enter ${missing.join(`, `)}. A pin must reach every state it names.`, + ) }, })) - // Segment histories come from the first fixed seed, so the check is - // reproducible; they are built when the test runs, like a lane's. - const determinism = determinismTest(options, () => { + // Pins and the segment histories of the first fixed seed, so these checks + // are reproducible; they are built when a test runs, like a lane's. + const fixedHistories = () => { const { examples, segmentOf } = examplesFor(source.fixedSeeds[0] ?? 0) return [ ...(source.pins ?? []).map(({ name, history }) => ({ @@ -636,8 +788,19 @@ function generatedTests( history, })), ] - }) - return [...fixedLanes, randomLane, ...pins, determinism] + } + const determinism = determinismTest(options, fixedHistories) + // A calibration also tries a fixed sample of generated histories. + const calibrations = calibrationTests(options, () => [ + ...fixedHistories(), + ...fc + .sample(source.arbitrary, { + seed: source.fixedSeeds[0] ?? 0, + numRuns: 100, + }) + .map((history, index) => ({ name: `sample ${index}`, history })), + ]) + return [...fixedLanes, randomLane, ...pins, ...calibrations, determinism] } // A collect-only run (ORACLE_MANIFEST set) records each registration, so the From 0b9d02c026a28c3bbcf1ee69353692be4e82bf58 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 07:20:12 -0700 Subject: [PATCH 008/168] test(oracles): split the optimistic-history oracle into oracleProperty parts The optimistic-history runner fused grammar resolution, model, driver, and assertions in one 830-line function. Its laws and model are unchanged; the jobs are now separate parts: - resolveOptimisticHistory: a history names actions indirectly ("settle active transaction `slot`", an edit that may be a no-op), so it resolves, by replaying the model's state, to a plan of concrete actions. - optimisticHistoryModel: the same replay owes, per checkpoint, the allowed publication cuts, visible rows, request outcomes and states, waiting receipts, and unsettled transactions. - optimisticHistoryDriver(lane): runs the plan through a real Collection and records publications, raw batches, rows, replicas, downstream rows, request outcomes, receipts, and tracked transactions. It decides nothing. Waits for settlement are bounded in event-loop turns, including at cleanup, so a request that never settles fails the cleanup law instead of hanging the run. - checkOptimisticHistory: 18 named laws (same-tick, forward-cuts, outcomes, rows, tracked-transactions, and others), judged in the old order except that callback shape now precedes event semantics: the old wrong-key mutant was always caught by event semantics, so the callback-shape law had never been calibrated. - optimisticHistoryCalibrations: the seven observation mutants as trace corruptions, each required to fail its named law. The new owner optimistic-history.core runs the core grammar with the settlement-boundary regressions as pins; that owner file is removed. The pins were not members of the grammar until `deletes: fc.constant([])` became a mapped constant (fast-check compares a constant by identity); the generated sequences are unchanged. eventSemanticsDifference is now a pure function under expectHistoryEventSemantics. Evidence: a gap report on a seeded sample of 240 mutants of state.ts, transactions.ts, mutations.ts, and sync.ts (seed 42), against a control of the old campaign plus the same pins: killed 63 -> 65 (three rollbackConflictingTransactions mutants the old campaign never reached), unreached 94 -> 81, comparison gaps 2 -> 5 (three capturePreSyncVisibleState mutants change a random-lane publication the check accepts: open leads). One kill became a timeout and, with bounded cleanup waits, is killed again. Limits: the core grammar does not generate pending manual edits, external completion, rejected handlers, reentrant drains, partial updates, ordered operations, or schema defaults; those stay pinned in the owners that still drive runOptimisticHistory (coverage map). Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/package.json | 2 +- ...ptimistic-history-oracle.property.test.ts} | 84 +- .../db/tests/optimistic-history-oracle.ts | 1580 ++++++++++++++++- packages/db/tests/oracle-config.ts | 1 + packages/db/tests/oracle-replay-manifest.ts | 5 + 6 files changed, 1634 insertions(+), 40 deletions(-) rename packages/db/tests/{optimistic-settlement-boundaries-oracle.test.ts => optimistic-history-oracle.property.test.ts} (51%) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 9cc1c0f62a..5a93c41b5d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1845,7 +1845,7 @@ generic visitor behavior outside the named collectors. | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [GC and persisting transactions](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-transaction-oracle.property.test.ts), [GC retention boundaries](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.property.test.ts), [shared GC model](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.ts), [GC mutation projection](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-mutation-oracle.property.test.ts), [native idle receiver](https://github.com/TanStack/db/tree/main/packages/db/e2e/collection-gc), [initial GC review evidence](oracle-reviews/pr-2258-gc-retention.md), [second GC review and decisions](oracle-reviews/pr-2258-gc-retention-round2.md), [interaction and mutation receiving evidence](oracle-reviews/pr-2258-gc-retention-round3.md), [GC checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.test.ts), [Electric confirmation receiver](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Every bounded change-event history, and about half of each generated campaign, runs again on a local-only Collection that ends with `reload()`: it must resolve with the Collection `ready`, the change-message mirror of valid messages must equal the public rows, the public rows must equal the history's kept rows (maintainer decision: a local-only reload keeps the user's writes, and `initialData` loads only into a Collection that starts empty), and a later write must reach both. The adapter that re-inserted `initialData` in each run failed 210 histories (a crash when a kept row changed, or a mix of kept rows and `initialData`); a reset to `initialData` fails 344, and loading `initialData` whenever the Collection is empty fails 45 (a history that deleted every row). Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A ready callback that throws while entering sync surfaces when the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). The GC property (`collection-lifecycle.gc-persisting-transaction`) checks on a fake clock that automatic garbage collection retires a Collection only after `gcTime` with no subscriber and no persisting transaction, that the delay restarts when the last transaction completes or fails, and that explicit `cleanup()` still retires it while a transaction persists. Histories run over two Collections, with inserts through one Collection's `onInsert` or through a manually committed transaction that touches both. After each subscribe, unsubscribe, insert, settlement, and clock step it compares each Collection's `cleaned-up` status and each persisting row's visibility with an independent deadline model. `main` before the fix, a hold that never restarts the delay, a restart only on completion, and a restart only for the first Collection a transaction touched each fail it. The companion `collection-lifecycle.gc-retention-boundaries` property separates elapsed eligibility from host idle delivery. Its shared reference model checks the absolute 50ms startup/preload handoff, real last unsubscribe, pending versus persisting transactions, delayed commit, success/failure/rollback, surviving siblings, disabled GC, and old idle work after renewal. Generated configured delays are 1, 10, 49, 50, 51, and 100ms; observations include immediately before and at the independent deadline. Eager sync and write-started sync both run. A Chromium receiving driver uses the same model/checker with native idle delivery and cancellation and controlled elapsed timers. It records the actual budget core reads and checks public status at core delivery and a usable native observation opportunity. Exact wall-clock cleanup latency is not promised. Paced mutations, offline transaction restoration, live-query Collections, and actual Electric `awaitTxId` plus provider delivery are not generated by these GC owners. The boundary grammar now generates repeated warm preloads over queued idle and persisting holds, successful and failed cold preloads as first or last releases, both sibling settlement orders, and pending drafts held by subscriptions. Held adapter cleanup crosses settlement with surviving pending or persisting siblings, both before and after cleanup settlement. Exact key comparisons reject leftover settled rows and cleanup resurrection; tracked transaction identities still match unsettled transactions. The 250-history fixed and random campaigns require all 33 declared states. Alongside the 15 focused lanes, constructive interaction histories combine up to seven transactions and three subscribers, delayed commit, three persisting siblings, non-FIFO settlement and preload between releases. Automatic GC also starts held or rejecting adapter cleanup, checks synchronous row discard before adapter settlement, and rejects pending-sibling resurrection. The shared model additionally receives direct updates/deletes, shared update/delete and cancelled insert/delete ownership with a surviving peer update through `collection-lifecycle.gc-mutation-shapes`. Its independent source/intent projection checks exact keys and values across completion/failure/rollback, both Collection orientations and subscriber states; each 150-history campaign requires all 11 declared states. The maintainer chose first-usable-idle retirement as a fixture scheduling assumption, not a public liveness guarantee. The maintainer also chose caller-owned pending draft visibility: keep a subscription; restart after GC does not promise draft restoration. The boundary grammar therefore does not establish post-GC draft restoration, paced/offline restoration, or arbitrary cleanup/source-recovery histories. A real Electric/Postgres receiving driver now crosses gcTime 10/100ms with absent/last-unsubscribed owners, holds an assigned uncommitted txid across the unheld deadline, and observes pending caller, persisting phase, optimistic row, genuine confirmation, successful settlement, synced row, renewed GC and idle delivery. Its 12 paths retain four one-write cases and add eight two-write cases with separate real SQL transactions and both local settlement orders. A genuine acknowledgement releases only one hold; the other SQL transaction remains open beyond the first-release deadline. It shares the model/checker and rejects no-hold and first-release runtime mutants. Native host and real provider are witnessed separately; their combined browser/provider schedule, three-or-more provider writers, provider failure/recovery and persistence wrappers remain outside these bounded receiving histories. The Collection lifecycle GC owners still use fresh disjoint optimistic keys. Before claiming the combined same-key rollback-cascade or confirming-sync-queue law with GC, this owner needs a generated receiving history that crosses those semantic owners at retention and cleanup checkpoints. The history owner's demand-change grammar (`demandChangeLifecycleHistoryArbitrary`, a fixed seed and the random campaign `subscription-lifecycle.demand-change`) adds a third demand, a `replace` command (`replaceSnapshots`: a held replacement whose own load did not fail covers the owners it replaces, law 10), and a `change` command that retires several owners through one `releaseSnapshot(...wheres)` call; the model judges an open replay once after the whole change (law 9). Declared reach: a change during a replay, a replacement covering a failed owner, and a change whose verdict between releases would differ. Per-release judging, first-release-alone, and batching only two-owner changes each fail it. A covered owner's failed replay load reports `loadSubset:error` whatever its settlement order (the model's existing reporting rule; suppressing the last settler fails it), and a prompt cancellation of a covered owner settles the replay as any settlement does. An aborted held replacement covers nothing: the abort ended its load before it applied (maintainer decision: an abort ends the load; law 10), so its replaced owner's failure keeps the replay failed (two 30-times counterexamples, now `keeps a replay failed when a replacement aborted after its replaced owner failed` and its peer-loaded twin; RED on the first simplification, which counted the aborted replacement as covering; a mutant that drops the check fails both). The controller always aborts and releases a segment together, so it never makes that case. An abort ends the load (maintainer decision): the history model's `abort`, and a truncate that replaces an attempt, stop that attempt's gating at once in both `manual` and `reject` cancellation; an owner whose attempt the abort ended before it settled owes the replay nothing, and a rejection after the abort decides nothing (a failure settled before the abort stays). The publication model finishes a replacement at an abort, and the replay oracle's newer attempt drops the older attempts' pending loads. A synchronous throw from `loadSubset` is a rejection (maintainer decision): an acquisition that core releases, also in the acquisition-cell and replay witnesses. RED on 2bc79fc7c: 18 lifecycle tests, 1 refinement test. Mutants that fail: a replay that waits for an aborted load (14), an aborted rejection reported as `loadSubset:error` (19), an aborted load that still holds readiness (17). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | | React paced-mutation render cuts | [render-cut oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx), [PR #2170 review](oracle-reviews/pr-2170-react-paced-render-cuts.md) | `usePacedMutations` publishes callbacks only from committed renders and does so before descendant layout effects, while callback-only commits preserve mutate identity. An independent committed-document model crosses ordinary and StrictMode schedules with mutation before or after a suspended candidate, mutation after the candidate commits, and delayed or immediate mutation from a descendant layout effect. It compares ordered callback traces and public rows at the mutation cut, the trace after persistence, and mutate identity at the commit cut. Render-time publication, layout-effect-only publication, and split insertion/layout publication mutants fail at the intended comparisons. A commit between transaction admission and persistence, arbitrary React schedules, server delivery, and other frameworks remain outside this receiving owner. | -| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [core property](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.property.test.ts) (`optimistic-history.core`), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | `optimistic-history.core` runs the core grammar through `oracleProperty`: the history resolves to a plan of concrete actions, the driver records each checkpoint, and the model owes allowed cuts, rows, outcomes, receipts, and tracked transactions, judged as named laws; the former settlement-boundary regressions are its pins, and the seven observation mutants are calibrations, each rejected by its named law. Its grammar does not yet generate pending manual edits, external completion, rejected handlers, reentrant drains, partial updates, ordered operations, or schema defaults; those dimensions are still covered only by pinned histories in the owners that drive `runOptimisticHistory` (open: move them into the grammar). Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, and delete through handler parameter and mutation-alias access. All calls explicitly refetch; former `refetch`/`clearError` coordinates remain only as historical replay labels, preserving all 12 identity and 12 mutation-overlap executions. The ordinary [health scenarios](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.test.ts) observe exact public causes and settlement. A held refetch retains the current public error. A failed initial load, including a failed initial Collection application, remains a loading error for that sync run regardless of later fetch results. After readiness, a pending retry retains the prior failure reason; failure reports the later cause, and successful result application clears it. Query retry attempts and an older held success are crossed with newer failures, including reuse of one Error value in one clock tick. These scenarios supplement the oracle; they do not claim removed utility fields or error counts. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session) | | Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. The `live-query-observer.hydration-terminal` property (`oracleProperty`, in the path-replay registry) generates histories of up to 10 steps that cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart. Its 11 declared states are model states (a status while the seed is active, attached or detached; attach to a failure; cleanup after a failure ended the seed; restart under the seed). Eight segments enter them, so every fixed and random lane reaches every state, and half of each lane's histories start with a segment; each lane prints its reach counts. Pins replay the crash, fail-then-cleanup, and crash-before-attach traces in both modes. The check judges laws `view` (status, flags, claimed rows) and `announcement` (last-read snapshot, granular rebuilt rows). The migration from the hand-built campaign kept every mutant kill and every gap-report outcome on `src/live-query-observer.ts` (seed 42); the old prefixed histories ran up to 13 steps. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests. | diff --git a/packages/db/package.json b/packages/db/package.json index 4d6d2f89a0..d10eaaebda 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,7 +23,7 @@ "test": "vitest --run", "test:dist": "vitest run --config vitest.dist.config.ts", "test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts", - "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/collection-gc-retention-oracle.property.test.ts tests/collection-gc-mutation-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-settlement-boundaries-oracle.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-cache-options-oracle.test.ts tests/mutation-confirmation-oracle.test.ts tests/mutation-handler-types-oracle.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-hydration-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/virtual-row-fields-input-oracle.test.ts tests/query/virtual-row-fields-effect-oracle.test.ts tests/query/virtual-row-fields-types-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/load-subset-joint-replay-oracle.property.test.ts", + "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/collection-gc-retention-oracle.property.test.ts tests/collection-gc-mutation-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-history-oracle.property.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-cache-options-oracle.test.ts tests/mutation-confirmation-oracle.test.ts tests/mutation-handler-types-oracle.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-hydration-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/virtual-row-fields-input-oracle.test.ts tests/query/virtual-row-fields-effect-oracle.test.ts tests/query/virtual-row-fields-types-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/load-subset-joint-replay-oracle.property.test.ts", "bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run", "test:indexed-db:package": "pnpm --filter @tanstack/db... build && vitest run --config vitest.indexed-db.package.config.ts", "test:collection-gc:browser": "playwright test --config playwright.collection-gc.config.ts", diff --git a/packages/db/tests/optimistic-settlement-boundaries-oracle.test.ts b/packages/db/tests/optimistic-history-oracle.property.test.ts similarity index 51% rename from packages/db/tests/optimistic-settlement-boundaries-oracle.test.ts rename to packages/db/tests/optimistic-history-oracle.property.test.ts index 5a275c1bc3..5462afb393 100644 --- a/packages/db/tests/optimistic-settlement-boundaries-oracle.test.ts +++ b/packages/db/tests/optimistic-history-oracle.property.test.ts @@ -1,12 +1,54 @@ -import { it } from 'vitest' -import { runOptimisticHistory } from './optimistic-history-oracle.js' -import type { HistoryRow, OptimisticStep } from './optimistic-history-oracle.js' +/** + * # Optimistic histories, as one property + * + * The laws are the ones the optimistic-history module states: an optimistic + * mutation is visible in the tick that makes it; a sync transaction committed + * while a mutation persists is accepted and queued, and applies in the + * publication that drops the settling mutation's optimistic state; a held + * completed row lives only while a committed sync transaction that has not + * applied touches its key; attribution, truncate, external completion, and + * open source transactions behave as the module header describes. Each law the + * check judges is named in `checkOptimisticHistory`. + * + * This property runs the core grammar (`optimisticHistory`) through the core + * production path. The history resolves to a plan of concrete actions; the + * driver records what production published and showed; the model owes, at + * each checkpoint, the allowed cuts, rows, outcomes, receipts, and tracked + * transactions. The seven observation mutants are calibrations. + * + * Limits: the core grammar has no partial-update lane, schema default, + * pending manual edit, external completion, rejected handler, reentrant drain, + * or ordered source operations. Those dimensions are exercised by the owners + * that still drive `runOptimisticHistory` with pinned histories (coverage map). + */ +import { oracleProperty } from './oracle-property.js' +import { + checkOptimisticHistory, + optimisticHistory, + optimisticHistoryCalibrations, + optimisticHistoryDriver, + optimisticHistoryModel, + optimisticHistoryReach, + optimisticHistoryStates, + resolveOptimisticHistory, +} from './optimistic-history-oracle.js' +import type { + HistoryRow, + OptimisticHistory, + OptimisticStep, +} from './optimistic-history-oracle.js' const row: HistoryRow = { id: 1, a: 0, b: 0, c: 0 } const edit = ( fields: Partial>, optimistic = true, -): OptimisticStep => ({ type: `edit`, key: 1, fields, optimistic }) +): OptimisticStep => ({ + type: `edit`, + key: 1, + fields, + optimistic, + inHandler: undefined, +}) const settle = (slot: number, success = true): OptimisticStep => ({ type: `settle`, slot, @@ -17,11 +59,10 @@ const sync = ( rows: Array, truncate = false, copies = 1, -): OptimisticStep => ({ type: `sync`, rows, truncate, copies }) +): OptimisticStep => ({ type: `sync`, rows, deletes: [], truncate, copies }) -// Every regression is a program for the same model, driver and checkpoint -// assertions used by generated histories. Membership work has its own generated -// law in query/derived-delete-reconciliation-oracle.test.ts. +// Regressions from the settlement-boundary owner, written in the grammar's +// own form so that they shrink like generated histories. const cases: Array<{ name: string initial: Array @@ -140,6 +181,29 @@ const cases: Array<{ }, ] -it.each(cases)(`oracle replay: $name`, async ({ initial, steps }) => { - await runOptimisticHistory(initial, steps) +oracleProperty({ + id: `optimistic-history.core`, + law: { + statement: `Optimistic and sync histories expose the rows, publications, request outcomes, receipts, and tracked transactions that the optimistic-history model owes at every step.`, + authority: `packages/db/tests/optimistic-history-oracle.ts header (the optimistic and sync history contract); the public transaction contract for commit admission and terminal outcomes`, + limits: `The core grammar and production path only: no partial-update lane, schema default, pending manual edit, external completion, rejected handler, reentrant drain, ordered source operations, or wrapping sync.`, + owner: `Optimistic state`, + }, + histories: { + arbitrary: optimisticHistory, + states: [], + measured: optimisticHistoryStates, + runs: 100, + fixedSeeds: [86103], + pins: cases.map(({ name, initial, steps }) => ({ + name, + history: { initial, steps } satisfies OptimisticHistory, + })), + }, + resolve: resolveOptimisticHistory, + model: optimisticHistoryModel, + reached: optimisticHistoryReach, + driver: optimisticHistoryDriver(), + check: checkOptimisticHistory, + calibrations: optimisticHistoryCalibrations, }) diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index 44d0a2e4e0..6ad5a86017 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -8,7 +8,7 @@ import { createLiveQueryCollection } from '../src/query/index.js' import { whenSyncAccepted } from '../src/sync-receipt.js' import { createTransaction } from '../src/transactions.js' import { declareOracle } from './oracle-declaration.js' -import { forwardCut, withinTurns } from './oracle-property.js' +import { forwardCut, pass, violation, withinTurns } from './oracle-property.js' import type { CollectionConfig, SyncConfig } from '../src/types.js' /** @@ -535,8 +535,14 @@ const observed = ( const sorted = (rows: Iterable) => [...rows].sort((a, b) => a.id - b.id) -/** Validate native deltas before their kind/old value are lost to Map.set. */ -export function expectHistoryEventSemantics( +/** + * Why a batch of native changes is invalid against the replica it applies + * to, or undefined when it is valid: an insert names an absent key, an update + * names a present key and carries its previous visible row, and a delete + * names a present key and carries the removed row. Validated before a + * change's kind and old value are lost to Map.set. + */ +export function eventSemanticsDifference( before: ReadonlyMap, changes: ReadonlyArray<{ key: unknown @@ -544,36 +550,43 @@ export function expectHistoryEventSemantics( value: T previousValue?: T }>, - label: string, -): void { +): string | undefined { const rows = new Map(before) for (const change of changes) { - const message = `${label}: event semantics ${change.type} ${String(change.key)}` + const event = `${change.type} ${String(change.key)}` if (change.type === `insert`) { - expect(rows.has(change.key), `${message}: absent old membership`).toBe( - false, - ) + if (rows.has(change.key)) return `${event}: the key is already present` rows.set(change.key, change.value) - } else { - expect(rows.has(change.key), `${message}: present old membership`).toBe( - true, - ) - const previous = rows.get(change.key) - if (change.type === `update`) { - expect( - change.previousValue, - `${message}: previous visible row`, - ).toStrictEqual(previous) - rows.set(change.key, change.value) - } else { - expect(change.type, message).toBe(`delete`) - expect(change.value, `${message}: removed visible row`).toStrictEqual( - previous, - ) - rows.delete(change.key) - } + continue } + if (!rows.has(change.key)) return `${event}: the key is absent` + const previous = rows.get(change.key) + if (change.type === `update`) { + if (!isDeepStrictEqual(change.previousValue, previous)) + return `${event}: previous value ${JSON.stringify(change.previousValue)}, visible row ${JSON.stringify(previous)}` + rows.set(change.key, change.value) + } else if (change.type === `delete`) { + if (!isDeepStrictEqual(change.value, previous)) + return `${event}: removed value ${JSON.stringify(change.value)}, visible row ${JSON.stringify(previous)}` + rows.delete(change.key) + } else return `${event}: unknown change type` } + return undefined +} + +/** Validate native deltas before their kind/old value are lost to Map.set. */ +export function expectHistoryEventSemantics( + before: ReadonlyMap, + changes: ReadonlyArray<{ + key: unknown + type: string + value: T + previousValue?: T + }>, + label: string, +): void { + const difference = eventSemanticsDifference(before, changes) + expect(difference, `${label}: event semantics`).toBeUndefined() } export type HistoryOutcome = @@ -653,6 +666,13 @@ export const historyRow = fc.record({ b: fc.integer({ min: -2, max: 2 }), c: fc.integer({ min: -2, max: 2 }), }) +const noDeletes = fc.constant(0).map( + (): Array => [], + (value) => { + if (Array.isArray(value) && value.length === 0) return 0 + throw new Error(`not an empty delete list`) + }, +) const sourceBatch = fc.record({ type: fc.constant(`sync` as const), rows: fc.uniqueArray(historyRow, { @@ -661,7 +681,9 @@ const sourceBatch = fc.record({ }), // The source may also delete keys, including keys it does not hold. deletes: fc.oneof( - { weight: 3, arbitrary: fc.constant>([]) }, + // A mapped constant, not `fc.constant([])`: fast-check compares a constant + // by identity, so a pinned `[]` would not be a member of the language. + { weight: 3, arbitrary: noDeletes }, { weight: 1, arbitrary: fc.uniqueArray(fc.integer({ min: 1, max: 3 }), { @@ -1716,3 +1738,1505 @@ export async function runOptimisticHistory( } declareOracle({ check: [runOptimisticHistory], model: [HistoryModel] }) + +// =========================================================================== +// The optimistic history as oracleProperty parts +// =========================================================================== +// +// The same laws, model, and production paths as `runOptimisticHistory`, with +// the jobs separated (rubric, ORC-003): +// +// - The history is the initial rows, the steps, and the lane's options. +// - `resolveOptimisticHistory` turns it into a plan of concrete actions. A +// step can name its target indirectly, such as "settle active transaction +// `slot`", and an edit can be a no-op; resolution replays the model's +// state to decide that, so it is part of what the history means, not an +// expectation. The plan holds only actions. +// - `optimisticHistoryModel` replays the same model over the plan and owes, +// at each checkpoint, the allowed publication cuts, the visible rows, each +// request's outcome and state, the receipts that must still wait, and the +// unsettled transactions. +// - `optimisticHistoryDriver(lane)` runs the plan through a real Collection +// and records, at each checkpoint, what production published and showed. +// It decides nothing. +// - `checkOptimisticHistory` compares them, law by law. + +/** One history: rows, steps, and the lane's row options. */ +export type OptimisticHistory = { + initial: Array + steps: ReadonlyArray + /** The default `partial` row update mode, so a source update may omit `c`. */ + partialUpdates?: boolean + /** A schema default for `c` on an insert that omits it. */ + insertDefault?: number +} + +/** A source transaction and the message kind of each of its operations. */ +type PlannedBatch = { + batch: SourceBatch + /** `insert` or `update` for a row, by operation index. */ + rowKinds: Map + /** Operation indexes of deletes of keys the source did not hold. */ + absentDeletes: Set + /** Partial updates whose omitted `c` differs from the source's held row. */ + distinguishingPartials: number +} + +type PlanAction = + | { + type: `edit` + position: number + step: Extract + /** The request's index among the history's requests. */ + index: number + kind: `insert` | `update` | `delete` + /** The row the request authors. */ + row: HistoryRow + manual: boolean + handler?: PlannedBatch & { awaitReceipt: boolean } + } + | { type: `complete`; position: number; index: number } + | { + type: `settle` + position: number + index: number + success: boolean + failure: `rollback` | `reject` | undefined + cascade: boolean + } + | { type: `hold-store`; position: number } + | { type: `release-store`; position: number | `final` } + | { type: `abort-deferred`; position: number; batch: SourceBatch } + | { + type: `reentrant` + position: number + trigger: PlannedBatch + batches: Array + } + | { type: `open`; position: number; batch: PlannedBatch } + | { type: `close`; position: number; commit: boolean } + | { type: `sync`; position: number; batch: PlannedBatch } + +export type OptimisticPlan = { + history: OptimisticHistory + actions: Array +} + +const checkpointOf = (action: PlanAction) => + action.position === `final` + ? `final release-store` + : `step ${action.position} (${action.type})` + +/** + * Replays the model over a history. Resolution keeps the actions; the model + * keeps what each checkpoint owes. Both read one replay, so a step means the + * same thing to the driver and to the judgment. + */ +function replayOptimisticHistory(history: OptimisticHistory) { + const { initial, steps } = history + const partialUpdates = history.partialUpdates === true + const model = new HistoryModel(initial, history.insertDefault, partialUpdates) + const actions: Array = [] + const owed = new Map() + const states = new Set() + // The source admits each message against its own rows, including queued + // ones: an insert names an absent key, an update a present one; it may + // delete a key it does not hold. + const sourceKeys = new Set(initial.map((row) => row.id)) + const sourceRows = new Map(initial.map((row) => [row.id, row])) + let open: + | { batch: PlannedBatch; keys: Set; rows: Map } + | undefined + // Expected outcome of each request, by index. + const outcomes: Array = [] + // Each request's authored changes, computed when it is made. + const changes: Array = [] + const plan = (batch: SourceBatch): PlannedBatch => { + if (batch.operations && batch.copies !== 1) + throw new Error(`Ordered source batches have one copy`) + if (batch.operations && (batch.rows.length || batch.deletes?.length)) + throw new Error(`Ordered source batches use operations alone`) + if (batch.truncate) { + sourceKeys.clear() + sourceRows.clear() + states.add(`truncate`) + } + const planned: PlannedBatch = { + batch, + rowKinds: new Map(), + absentDeletes: new Set(), + distinguishingPartials: 0, + } + for (const [index, operation] of sourceOperations(batch).entries()) { + if (operation.type === `delete`) { + if (!sourceKeys.delete(operation.key)) { + planned.absentDeletes.add(index) + states.add(`absent-source-delete`) + } + sourceRows.delete(operation.key) + states.add(`source-delete`) + continue + } + const { row } = operation + const kind = sourceKeys.has(row.id) ? `update` : `insert` + planned.rowKinds.set(index, kind) + if (kind === `insert`) states.add(`source-insert`) + sourceKeys.add(row.id) + const held = sourceRows.get(row.id) + if (kind === `update` && partialUpdates && batch.partial) { + if (!held) throw new Error(`Partial update of unknown source row`) + if (held.c !== row.c) { + planned.distinguishingPartials++ + states.add(`distinguishing-partial-update`) + } + sourceRows.set(row.id, { ...row, c: held.c }) + } else sourceRows.set(row.id, row) + } + return planned + } + const persisting = () => + model.transactions.some((entry) => entry.state === `persisting`) + const committed = (batch: SourceBatch) => { + if (persisting()) + states.add(batch.truncate ? `snapshot-override` : `queued`) + } + const visible = () => sorted(model.visible().values()) + let cuts = [visible()] + const owe = (checkpoint: string, extra: Partial = {}) => { + owed.set(checkpoint, { + cuts, + visible: visible(), + outcomes: outcomes.map((outcome) => ({ ...outcome })), + requests: model.transactions.map((entry, index) => ({ + state: entry.state, + row: plain(entry.row), + changes: changes[index]!, + })), + waiting: [...model.queue, ...model.deferred], + unsettled: model.transactions.flatMap((entry, index) => + entry.state === `pending` || entry.state === `persisting` + ? [index] + : [], + ), + ...extra, + }) + } + owe(`initial`) + for (const [position, step] of steps.entries()) { + const before = visible() + if (step.type === `edit` || step.type === `delete`) { + if (step.type === `edit` && step.pending && step.inHandler) + throw new Error(`A pending edit cannot write from its handler yet`) + const index = model.author(step) + if (index === undefined) continue + const intent = model.transactions[index]! + states.add(step.type === `delete` ? `delete` : `edit`) + if (step.type === `edit` && step.pending) states.add(`manual`) + outcomes.push({ status: `pending` }) + const schemaInsert = + history.insertDefault !== undefined && + intent.kind === `insert` && + step.type === `edit` && + step.fields.c === undefined + changes.push( + step.type === `delete` + ? plain(intent.row) + : intent.kind === `insert` + ? schemaInsert + ? { id: intent.key, a: intent.row.a, b: intent.row.b } + : plain(intent.row) + : Object.fromEntries( + Object.entries(step.fields).filter( + ([key, value]) => + before.find((row) => row.id === intent.key)?.[ + key as keyof Fields + ] !== value, + ), + ), + ) + cuts = [visible()] + let handler: (PlannedBatch & { awaitReceipt: boolean }) | undefined + if (step.inHandler && !open) { + const awaitReceipt = step.inHandler.awaitReceipt === true + // Acceptance waits for the wrapping sync's durable step, so a + // handler awaiting it would wait for the held store. + if (awaitReceipt && (model.storeHeld || model.holdNext)) + throw new Error(`A held store excludes awaited handler receipts`) + handler = { ...plan(step.inHandler), awaitReceipt } + model.sync(step.inHandler) + committed(step.inHandler) + cuts.push(visible()) + states.add(`handler-batch`) + if (awaitReceipt) states.add(`awaited-receipt`) + } + const action: PlanAction = { + type: `edit`, + position, + step, + index, + kind: intent.kind, + row: plain(intent.row), + manual: step.type === `edit` && step.pending === true, + handler, + } + actions.push(action) + owe(checkpointOf(action), { + live: step.optimistic + ? step.type === `delete` + ? null + : plain(intent.row) + : undefined, + captured: plain(intent.row), + }) + continue + } + if (step.type === `complete`) { + if (!model.transactions.length) continue + const index = step.slot % model.transactions.length + const admitted = model.transactions[index]!.state === `pending` + states.add(admitted ? `completion` : `completion-rejection`) + if (admitted) { + model.settle(index, true, false) + outcomes[index] = { status: `fulfilled` } + cuts = [visible()] + } + const action: PlanAction = { type: `complete`, position, index } + actions.push(action) + owe(checkpointOf(action), { + completion: admitted ? `admitted` : `rejected`, + settledRows: admitted ? cuts[0] : undefined, + }) + continue + } + if (step.type === `settle`) { + const active = model.transactions.flatMap((entry, index) => + entry.state === `persisting` || entry.state === `pending` + ? [index] + : [], + ) + if (!active.length) continue + const index = active[step.slot % active.length]! + const canceled = model.settle( + index, + step.success, + step.failure === `reject` || step.cascade, + ) + for (const peer of canceled) outcomes[peer] = { status: `rejected` } + outcomes[index] = step.success + ? { status: `fulfilled` } + : step.failure === `reject` + ? { status: `rejected`, reason: `handler error` } + : { status: `rejected` } + states.add(step.success ? `settlement` : `failure`) + // The drop and the queued sync transactions publish together. + cuts = [visible()] + const action: PlanAction = { + type: `settle`, + position, + index, + success: step.success, + failure: step.failure, + cascade: step.cascade, + } + actions.push(action) + owe(checkpointOf(action), { + settledRows: cuts[0], + // A settlement that changes no visible row publishes nothing. + quiet: JSON.stringify(before) === JSON.stringify(visible()), + }) + continue + } + if (step.type === `hold-store`) { + model.holdStore() + const action: PlanAction = { type: `hold-store`, position } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `release-store`) { + cuts = [visible()] + model.releaseStore(() => cuts.push(visible())) + const action: PlanAction = { type: `release-store`, position } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `abort-deferred`) { + const aborted = model.abortDeferred(step.slot) + if (!aborted) continue + states.add(`aborted-deferred`) + const action: PlanAction = { + type: `abort-deferred`, + position, + batch: aborted, + } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `reentrant`) { + if (open) continue + if (!step.trigger.truncate) + throw new Error(`Reentrant trigger must publish a truncate`) + const trigger = plan(step.trigger) + const batches = step.batches.map(plan) + if (!batches.some(({ batch }) => batch.truncate)) + throw new Error(`Reentrant suffix must include a truncate`) + model.sync(step.trigger) + committed(step.trigger) + cuts = [visible()] + model.syncTogether(step.batches) + for (const batch of step.batches) committed(batch) + cuts.push(visible()) + states.add(`reentrant`) + const action: PlanAction = { + type: `reentrant`, + position, + trigger, + batches, + } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `open`) { + if (open || step.batch.truncate) continue + const keys = new Set(sourceKeys) + const rows = new Map(sourceRows) + open = { batch: plan(step.batch), keys, rows } + states.add(`open-batch`) + const action: PlanAction = { type: `open`, position, batch: open.batch } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `close`) { + if (!open) continue + const { batch, keys, rows } = open + open = undefined + if (step.commit) { + model.sync(batch.batch) + committed(batch.batch) + cuts = [visible()] + } else { + // Aborted before acceptance: its writes never apply. + sourceKeys.clear() + for (const key of keys) sourceKeys.add(key) + sourceRows.clear() + for (const [key, row] of rows) sourceRows.set(key, row) + states.add(`aborted-batch`) + } + const action: PlanAction = { + type: `close`, + position, + commit: step.commit, + } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (open) continue + const batch = plan(step) + model.sync(step) + committed(step) + cuts = [visible()] + const action: PlanAction = { type: `sync`, position, batch } + actions.push(action) + owe(checkpointOf(action)) + } + // Every history ends with its deferred transactions applied. + if (model.storeHeld || model.holdNext) { + cuts = [visible()] + model.releaseStore(() => cuts.push(visible())) + const action: PlanAction = { type: `release-store`, position: `final` } + actions.push(action) + owe(checkpointOf(action)) + } + if (model.deferredHoldsAcrossDrain > 0) states.add(`deferred-hold`) + if (model.abortReleases > 0) states.add(`abort-release`) + if (model.abortAttributionReleases > 0) + states.add(`abort-attribution-release`) + // At cleanup every request still pending is rolled back, and every receipt + // settles: an aborted transaction's rejects, every other one fulfills. + const final = { + outcomes: outcomes.map((outcome) => + outcome.status === `pending` ? { status: `rejected` as const } : outcome, + ), + aborted: model.aborted, + } + return { actions, owed, states, final } +} + +type OwedOutcome = + | { status: `pending` } + | { status: `fulfilled` } + | { status: `rejected`; reason?: `handler error` } + +/** What one checkpoint owes. */ +type OwedCheckpoint = { + /** The visible rows a publication may show, in order. */ + cuts: Array> + visible: Array + outcomes: Array + requests: Array<{ + state: ModelTransaction[`state`] + row: HistoryRow + changes: object + }> + /** Committed source transactions that have not applied. */ + waiting: Array + unsettled: Array + /** The row an optimistic mutation shows in the tick that makes it. */ + live?: HistoryRow | null + /** The row the request captured when it was made. */ + captured?: HistoryRow + completion?: `admitted` | `rejected` + settledRows?: Array + quiet?: boolean +} + +export function resolveOptimisticHistory( + history: OptimisticHistory, +): OptimisticPlan { + return { history, actions: replayOptimisticHistory(history).actions } +} + +export function optimisticHistoryModel(plan: OptimisticPlan) { + const { owed, states, final } = replayOptimisticHistory(plan.history) + return { owed, states, final } +} + +/** The states a history entered, which the model names. */ +export function optimisticHistoryReach( + _plan: OptimisticPlan, + _trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +): Iterable { + return expected.states +} + +export const optimisticHistoryStates = [ + `edit`, + `delete`, + `manual`, + `settlement`, + `failure`, + `completion`, + `completion-rejection`, + `queued`, + `snapshot-override`, + `truncate`, + `handler-batch`, + `awaited-receipt`, + `open-batch`, + `aborted-batch`, + `source-insert`, + `source-delete`, + `absent-source-delete`, + `distinguishing-partial-update`, + `reentrant`, + `aborted-deferred`, + `deferred-hold`, + `abort-release`, + `abort-attribution-release`, +] as const + +/** Each source transaction in the plan, numbered in plan order. */ +function batchIds(plan: OptimisticPlan): Map { + const ids = new Map() + const add = (batch: SourceBatch) => { + if (!ids.has(batch)) ids.set(batch, ids.size) + } + for (const action of plan.actions) { + if (action.type === `edit` && action.handler) add(action.handler.batch) + if (action.type === `reentrant`) { + add(action.trigger.batch) + for (const { batch } of action.batches) add(batch) + } + if (action.type === `open` || action.type === `sync`) + add(action.batch.batch) + } + return ids +} + +type Change = { + key: unknown + type: string + value: ObservedRow + previousValue?: ObservedRow +} +/** A publication to the subscriber that started with the initial rows. */ +type RecordedPublication = { + before: Array<[unknown, ObservedRow]> + batch: Array + /** The subscriber's replica after the batch. */ + replica: Array + /** The Collection's rows when the callback ran. */ + source: Array +} +/** A publication to the subscriber that started from the visible rows. */ +type RawPublication = { + before: Array<[unknown, ObservedRow]> + batch: Array +} +type RecordedOutcome = { + status: `pending` | `fulfilled` | `rejected` + /** Whether a fulfilled request resolved to its own transaction. */ + ownTransaction?: boolean + /** Whether a rejected request carries the error its handler threw. */ + handlerError?: boolean +} +/** What production showed at one checkpoint. */ +export type OptimisticObservation = { + publications: Array + raw: Array + requests: Array<{ + outcome: RecordedOutcome + state: string + modified: HistoryRow + changes: object + }> + /** Receipt status of each source transaction that returned one, by id. */ + receipts: Array<[number, `pending` | `fulfilled` | `rejected`]> + rows: Array + replica: Array + rawReplica: Array + downstream: Array + /** The Collection's tracked transactions, as request indexes (-1: unknown). */ + tracked: Array + deliveries: number + /** The row right after the mutation call, before any await. */ + live?: HistoryRow | null + captured?: HistoryRow + /** Whether the handler wrote its source transaction before it returned. */ + handlerWrote?: boolean + completion?: `admitted` | `rejected` + settledRows?: Array + /** Whether a reentrant subscriber committed the suffix during the trigger. */ + suffixCommitted?: boolean + /** An aborted open transaction: its receipt and its acceptance reject. */ + abortedOpen?: { receiptRejected: boolean; acceptanceRejected: boolean } +} +export type OptimisticCleanup = { + outcomes: Array + receipts: Array<[number, `pending` | `fulfilled` | `rejected`]> +} + +/** + * The production driver for one lane: core, or a wrapping sync such as + * persistence. `fullRows` sets up the partial-update lane in `full` row update + * mode, a hostile configuration that a calibration uses. + */ +export function optimisticHistoryDriver( + lane: { wrapping?: WrappingSyncLane; fullRows?: boolean } = {}, +) { + return async ( + plan: OptimisticPlan, + observe: (checkpoint: string, value: unknown) => void, + ): Promise => { + const { history } = plan + const { wrapping } = lane + const partialUpdates = history.partialUpdates === true + const ids = batchIds(plan) + const db: HistoryDb = wrapping?.db ?? { + createCollection, + createLiveQueryCollection, + createTransaction, + whenSyncAccepted, + } + if (wrapping && history.insertDefault !== undefined) + throw new Error(`The wrapping-sync lane has no schema lane`) + if ( + !wrapping && + plan.actions.some( + (action) => + action.type === `hold-store` || + action.type === `release-store` || + action.type === `abort-deferred`, + ) + ) + throw new Error(`Store steps need a wrapping sync`) + if (wrapping && plan.actions.some((action) => action.type === `reentrant`)) + throw new Error(`The wrapping-sync lane has no reentrant drain`) + let sync!: Parameters[`sync`]>[0] + let starting: ReturnType> | undefined + // The next handler call writes this batch before it returns. + let handlerBatch: (PlannedBatch & { awaitReceipt: boolean }) | undefined + // The suffix a subscriber commits during the next publication. An object, + // so the driver reads what the callback clears. + const reentrant: { batches?: Array } = {} + const pendingSuffix = () => reentrant.batches + const handler = async () => { + const batch = handlerBatch + const done = starting!.promise + handlerBatch = undefined + if (batch) { + const receipt = writeSourceBatch(batch) + // Handler-facing writes wait for acceptance. Waiting for visibility + // would wait for this handler's own transaction to settle. + if (batch.awaitReceipt) await db.whenSyncAccepted(receipt) + } + return done + } + const config: CollectionConfig = { + getKey: (row) => row.id, + onInsert: handler, + onUpdate: handler, + onDelete: handler, + sync: { + // The partial-update lane keeps the default row update mode. + ...(partialUpdates && !lane.fullRows + ? {} + : { rowUpdateMode: `full` as const }), + sync: (actions) => { + sync = actions + actions.begin() + for (const row of history.initial) + actions.write({ type: `insert`, value: { ...row } }) + actions.commit() + actions.markReady() + }, + }, + } + const schemaCollection = + history.insertDefault === undefined + ? undefined + : db.createCollection({ + ...config, + schema: z.object({ + id: z.number(), + a: z.number(), + b: z.number(), + c: z.number().default(history.insertDefault), + }), + }) + const collection = + schemaCollection ?? + db.createCollection(wrapping ? wrapping.wrap(config) : config) + const downstream = db.createLiveQueryCollection({ + query: (q) => q.from({ row: collection }), + }) + let sub: ReturnType | undefined + let rawSub: ReturnType | undefined + const requests: Array<{ + tx: + | ReturnType + | ReturnType + | ReturnType> + done: ReturnType> + outcome: ReturnType> + settledRows?: Array + handlerError?: Error + manual: boolean + }> = [] + const receipts: Array<{ + id: number + outcome: ReturnType> + }> = [] + const sourceRows = new Map(history.initial.map((row) => [row.id, row])) + // An open source transaction and the source's rows before it began. + let opened: + { batch: PlannedBatch; rows: Map } | undefined + function beginSourceBatch({ batch, rowKinds }: PlannedBatch) { + sync.begin() + if (batch.truncate) { + sync.truncate() + sourceRows.clear() + } + for (const [index, operation] of sourceOperations(batch).entries()) { + if (operation.type === `delete`) { + sync.write({ type: `delete`, key: operation.key }) + sourceRows.delete(operation.key) + continue + } + const { row } = operation + const type = rowKinds.get(index)! + const held = sourceRows.get(row.id) + if (type === `update` && partialUpdates && batch.partial && held) { + const { c: _omitted, ...partialRow } = row + sync.write({ type, value: partialRow as HistoryRow }) + sourceRows.set(row.id, { ...row, c: held.c }) + } else { + sync.write({ type, value: { ...row } }) + sourceRows.set(row.id, row) + } + } + } + // In a wrapping-sync lane each source transaction carries its own signal, + // so a history can abort it while it is deferred. + const controllers = new Map() + function commitSourceBatch({ batch }: PlannedBatch) { + let signal: AbortSignal | undefined + if (wrapping) { + const controller = new AbortController() + controllers.set(batch, controller) + signal = controller.signal + } + const receipt = sync.commit(signal) + if (receipt !== true) + receipts.push({ + id: ids.get(batch)!, + outcome: observeHistoryPromise(receipt), + }) + return receipt + } + function writeSourceBatch(batch: PlannedBatch) { + beginSourceBatch(batch) + return commitSourceBatch(batch) + } + const outcomeOf = (request: (typeof requests)[number]): RecordedOutcome => { + const current = request.outcome.read() + if (current.status === `fulfilled`) + return { + status: `fulfilled`, + ownTransaction: current.value === request.tx, + } + if (current.status === `rejected`) + return { + status: `rejected`, + handlerError: + request.handlerError !== undefined && + current.reason === request.handlerError, + } + return { status: `pending` } + } + return withHistoryCleanup( + async () => { + await downstream.preload() + await wrapping?.settle() + const replica = new Map() + let deliveries = 0 + let publications: Array = [] + const capture = ( + batch: ReadonlyArray<{ + key: unknown + type: string + value: HistoryRow + previousValue?: HistoryRow + }>, + ): Array => + batch.map((change) => ({ + key: change.key, + type: change.type, + value: observed(change.value as ObservedRow), + ...(`previousValue` in change + ? { + previousValue: + change.previousValue === undefined + ? undefined + : observed(change.previousValue as ObservedRow), + } + : {}), + })) + sub = collection.subscribeChanges( + (batch) => { + deliveries += batch.length + const captured = capture(batch) + const before = [...replica] + for (const change of captured) { + if (change.type === `delete`) replica.delete(change.key as number) + else replica.set(change.key as number, change.value) + } + publications.push({ + before, + batch: captured, + replica: sorted(replica.values()), + source: sorted([...collection.values()].map(observed)), + }) + if (reentrant.batches) { + const batches = reentrant.batches + reentrant.batches = undefined + for (const suffix of batches) writeSourceBatch(suffix) + } + }, + { includeInitialState: true }, + ) + // A raw subscriber starts from the visible rows and receives only later + // changes. It has no sent-key filter to hide an invalid message. + const rawReplica = new Map( + [...collection.values()].map((row) => [ + row.id, + observed(row as ObservedRow), + ]), + ) + let raw: Array = [] + rawSub = collection.subscribeChanges( + (batch) => { + const before = [...rawReplica] + const captured = capture(batch) + for (const change of captured) { + if (change.type === `delete`) rawReplica.delete(change.key) + else rawReplica.set(change.key, change.value) + } + raw.push({ before, batch: captured }) + }, + { includeInitialState: false }, + ) + const record = ( + checkpoint: string, + extra: Partial = {}, + ) => { + const observation: OptimisticObservation = { + publications, + raw, + requests: requests.map((request) => ({ + outcome: outcomeOf(request), + state: request.tx.state, + modified: plain(request.tx.mutations[0]!.modified as HistoryRow), + changes: request.tx.mutations[0]!.changes, + })), + receipts: receipts.map(({ id, outcome }) => [ + id, + outcome.read().status, + ]), + rows: sorted([...collection.values()].map(observed)), + replica: sorted(replica.values()), + rawReplica: sorted(rawReplica.values()), + downstream: sorted([...downstream.values()].map(plain)), + tracked: [...collection._state.transactions.values()].map((tx) => + requests.findIndex((request) => request.tx === tx), + ), + deliveries, + ...extra, + } + publications = [] + raw = [] + observe(checkpoint, observation) + } + record(`initial`) + for (const action of plan.actions) { + const checkpoint = checkpointOf(action) + if (action.type === `edit`) { + const { step } = action + handlerBatch = action.handler + const done = createDeferred() + // Rejection can precede a manual commit. Keep that controlled + // failure from becoming an unhandled promise during diagnosis. + void done.promise.catch(() => undefined) + starting = done + const performEdit = () => + step.type === `delete` + ? collection.delete(step.key, { optimistic: step.optimistic }) + : action.kind === `insert` + ? schemaCollection && step.fields.c === undefined + ? schemaCollection.insert( + { id: action.row.id, a: action.row.a, b: action.row.b }, + { optimistic: step.optimistic }, + ) + : collection.insert( + { ...action.row }, + { optimistic: step.optimistic }, + ) + : collection.update( + step.key, + { optimistic: step.optimistic }, + (draft) => Object.assign(draft, step.fields), + ) + const tx = action.manual + ? db.createTransaction({ + autoCommit: false, + // A manual commit can run after later edits replace `starting`. + mutationFn: () => done.promise, + }) + : performEdit() + if (action.manual) tx.mutate(() => void performEdit()) + // An optimistic mutation is visible in the tick that makes it. + const current = collection.get(step.key) + const live = step.optimistic + ? current + ? plain(current) + : null + : undefined + const request: (typeof requests)[number] = { + tx, + done, + manual: action.manual, + outcome: observeHistoryPromise( + tx.when(`settled`).finally(() => { + request.settledRows = sorted( + [...collection.values()].map(observed), + ) + }), + ), + } + requests.push(request) + const captured = plain(tx.mutations[0]!.modified as HistoryRow) + const handlerWrote = handlerBatch === undefined + await wrapping?.settle() + record(checkpoint, { live, captured, handlerWrote }) + continue + } + if (action.type === `complete`) { + const request = requests[action.index]! + let completion: `admitted` | `rejected` = `admitted` + try { + request.tx.completeWithoutCommit() + } catch { + completion = `rejected` + } + if (completion === `admitted`) { + request.tx.touchCollection() + await withinTurns(request.outcome.settled, 10) + } + await wrapping?.settle() + record(checkpoint, { completion, settledRows: request.settledRows }) + continue + } + if (action.type === `settle`) { + const request = requests[action.index]! + if (action.success) { + if (request.manual) + void request.tx.commit().catch(() => undefined) + request.done.resolve() + } else if (action.failure === `reject`) { + request.handlerError = new Error( + `Mutation rejected at step ${action.position}`, + ) + if (request.manual) + void request.tx.commit().catch(() => undefined) + request.done.reject(request.handlerError) + } else { + request.tx.rollback({ isSecondaryRollback: !action.cascade }) + request.done.resolve() + } + // A receipt that waited for visibility would deadlock the handler, + // so the wait is bounded in event-loop turns, not milliseconds. + await withinTurns(request.outcome.settled, 10) + await Promise.resolve() + const settledRows = request.settledRows + await wrapping?.settle() + record(checkpoint, { settledRows }) + continue + } + if (action.type === `hold-store`) wrapping!.holdNextStore() + else if (action.type === `release-store`) wrapping!.releaseStore() + else if (action.type === `abort-deferred`) + controllers.get(action.batch)!.abort() + else if (action.type === `reentrant`) { + reentrant.batches = action.batches + writeSourceBatch(action.trigger) + const suffixCommitted = pendingSuffix() === undefined + await wrapping?.settle() + record(checkpoint, { suffixCommitted }) + continue + } else if (action.type === `open`) { + opened = { batch: action.batch, rows: new Map(sourceRows) } + beginSourceBatch(action.batch) + } else if (action.type === `close`) { + const { batch, rows } = opened! + opened = undefined + if (action.commit) commitSourceBatch(batch) + else { + // Aborted before acceptance: the source's rows return to their + // state before the transaction began. + sourceRows.clear() + for (const [key, row] of rows) sourceRows.set(key, row) + const controller = new AbortController() + controller.abort() + const receipt = sync.commit(controller.signal) + const isAbort = (error: unknown) => + (error as { name?: unknown } | null)?.name === `AbortError` + const receiptRejected = + receipt instanceof Promise && + (await receipt.then(() => false, isAbort)) + const acceptanceRejected = await Promise.resolve( + db.whenSyncAccepted(receipt), + ).then(() => false, isAbort) + await wrapping?.settle() + record(checkpoint, { + abortedOpen: { receiptRejected, acceptanceRejected }, + }) + continue + } + } else writeSourceBatch(action.batch) + await wrapping?.settle() + record(checkpoint) + } + }, + () => [ + () => wrapping?.releaseStore(), + // A request still pending at the end is rolled back, one at a time. + ...requests.flatMap((request) => [ + () => { + if ( + request.tx.state === `pending` || + request.tx.state === `persisting` + ) + request.tx.rollback({ isSecondaryRollback: true }) + }, + () => request.done.resolve(), + // Bounded, so a request that never settles fails the cleanup law + // instead of hanging the run. + () => withinTurns(request.outcome.settled, 10), + ]), + () => sub?.unsubscribe(), + () => rawSub?.unsubscribe(), + () => downstream.cleanup(), + () => collection.cleanup(), + async () => { + for (const { outcome } of receipts) + await withinTurns(outcome.settled, 10) + const cleanup: OptimisticCleanup = { + outcomes: requests.map(outcomeOf), + receipts: receipts.map(({ id, outcome }) => { + return [id, outcome.read().status] + }), + } + observe(`cleanup`, cleanup) + }, + ], + ) + } +} + +const same = isDeepStrictEqual + +function outcomeMatches(seen: RecordedOutcome, owed: OwedOutcome): boolean { + if (seen.status !== owed.status) return false + if (owed.status === `fulfilled`) return seen.ownTransaction === true + if (owed.status === `rejected` && owed.reason === `handler error`) + return seen.handlerError === true + return true +} + +/** + * Compares each checkpoint with what the model owes. The laws, in the order + * a checkpoint is judged: + * - `same-tick`: an optimistic mutation is visible in the tick that makes it. + * - `request-snapshot`: a request captures the row it authors. + * - `handler-batch`: a handler writes its source transaction before it returns. + * - `external-completion`: external completion admits only a pending request. + * - `settled-rows`: `when('settled')` settles after the publication that + * drops the request's optimistic state. + * - `reentrant-suffix` and `aborted-open-batch`: the reentrant subscriber + * commits its suffix during the trigger, and an aborted open transaction's + * receipt and acceptance both reject. + * - `callback-shape`: a callback key is the row's numeric id. + * - `event-semantics`: every batch is valid for its subscriber's replica. + * - `forward-cuts`: each publication shows an allowed state at or after the + * previous one, and the Collection shows the same state when it runs. + * - `outcomes`, `transaction-state`, `authored-changes`: each request's + * outcome, state, immutable row, and authored changes. + * - `receipts`: a committed source transaction's receipt waits until it + * applies. + * - `rows`: the Collection, both replicas, and the downstream query show the + * visible rows. + * - `tracked-transactions`: the Collection tracks exactly its unsettled + * transactions, by identity. + * - `quiet-settlement`: a settlement that changes no visible row publishes + * nothing. + * - `cleanup`: at the end every request and receipt settles as owed. + * The trace must cover every checkpoint (`trace`); that is judged last. + */ +export function checkOptimisticHistory( + plan: OptimisticPlan, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +) { + const ids = batchIds(plan) + let deliveries = 0 + for (const { checkpoint, value } of trace) { + if (checkpoint === `cleanup`) { + const cleanup = value as OptimisticCleanup + for (const [index, outcome] of cleanup.outcomes.entries()) { + const owed = expected.final.outcomes[index] + if (!owed || !outcomeMatches(outcome, owed)) + return violation( + checkpoint, + `request ${index} ended ${JSON.stringify(outcome)}, owed ${JSON.stringify(owed)}`, + `cleanup`, + { actual: outcome, expected: owed }, + ) + } + const aborted = new Set( + [...expected.final.aborted].map((batch) => ids.get(batch)), + ) + for (const [id, status] of cleanup.receipts) { + const owed = aborted.has(id) ? `rejected` : `fulfilled` + if (status !== owed) + return violation( + checkpoint, + `receipt of source transaction ${id} ${status}, owed ${owed}`, + `cleanup`, + ) + } + continue + } + const owed = expected.owed.get(checkpoint) + if (!owed) + return violation( + checkpoint, + `the driver recorded a checkpoint the plan does not have`, + `trace`, + ) + const seen = value as OptimisticObservation + if (owed.live !== undefined && !same(seen.live, owed.live)) + return violation( + checkpoint, + `the row right after the call is ${JSON.stringify(seen.live)}, owed ${JSON.stringify(owed.live)}`, + `same-tick`, + { actual: seen.live, expected: owed.live }, + ) + if (owed.captured && !same(seen.captured, owed.captured)) + return violation( + checkpoint, + `the request captured ${JSON.stringify(seen.captured)}, owed ${JSON.stringify(owed.captured)}`, + `request-snapshot`, + { actual: seen.captured, expected: owed.captured }, + ) + if (seen.handlerWrote === false) + return violation( + checkpoint, + `the handler returned before writing its source transaction`, + `handler-batch`, + ) + if (owed.completion && seen.completion !== owed.completion) + return violation( + checkpoint, + `external completion was ${seen.completion}, owed ${owed.completion}`, + `external-completion`, + ) + if (owed.settledRows && !same(seen.settledRows, owed.settledRows)) + return violation( + checkpoint, + `rows when settlement completed ${JSON.stringify(seen.settledRows)}, owed ${JSON.stringify(owed.settledRows)}`, + `settled-rows`, + { actual: seen.settledRows, expected: owed.settledRows }, + ) + if (seen.suffixCommitted === false) + return violation( + checkpoint, + `the subscriber did not commit the suffix during the trigger`, + `reentrant-suffix`, + ) + if ( + seen.abortedOpen && + !(seen.abortedOpen.receiptRejected && seen.abortedOpen.acceptanceRejected) + ) + return violation( + checkpoint, + `an aborted open transaction: ${JSON.stringify(seen.abortedOpen)}`, + `aborted-open-batch`, + ) + for (const publication of seen.raw) { + const difference = eventSemanticsDifference( + new Map(publication.before), + publication.batch, + ) + if (difference) + return violation( + checkpoint, + `raw subscriber: ${difference}`, + `event-semantics`, + ) + } + let cut = 0 + for (const publication of seen.publications) { + // Shape first: a key of the wrong type would otherwise read as absent. + for (const change of publication.batch) + if (typeof change.key !== `number` || change.key !== change.value.id) + return violation( + checkpoint, + `callback key ${JSON.stringify(change.key)} for row ${change.value.id}`, + `callback-shape`, + ) + const difference = eventSemanticsDifference( + new Map(publication.before), + publication.batch, + ) + if (difference) + return violation(checkpoint, difference, `event-semantics`) + const next = forwardCut(owed.cuts, publication.replica, cut, same) + if (next < 0) + return violation( + checkpoint, + `publication shows ${JSON.stringify(publication.replica)}; allowed from cut ${cut}: ${JSON.stringify(owed.cuts)}`, + `forward-cuts`, + { actual: publication.replica, expected: owed.cuts.slice(cut) }, + ) + if (!same(publication.source, owed.cuts[next])) + return violation( + checkpoint, + `the Collection showed ${JSON.stringify(publication.source)} while publishing ${JSON.stringify(owed.cuts[next])}`, + `forward-cuts`, + { actual: publication.source, expected: owed.cuts[next] }, + ) + cut = next + } + if (seen.requests.length !== owed.requests.length) + return violation( + checkpoint, + `${seen.requests.length} requests recorded, ${owed.requests.length} owed`, + `trace`, + ) + for (const [index, request] of seen.requests.entries()) { + const owedRequest = owed.requests[index]! + if (!outcomeMatches(request.outcome, owed.outcomes[index]!)) + return violation( + checkpoint, + `request ${index} is ${JSON.stringify(request.outcome)}, owed ${JSON.stringify(owed.outcomes[index])}`, + `outcomes`, + { actual: request.outcome, expected: owed.outcomes[index] }, + ) + if (request.state !== owedRequest.state) + return violation( + checkpoint, + `request ${index} state ${request.state}, owed ${owedRequest.state}`, + `transaction-state`, + ) + if (!same(request.modified, owedRequest.row)) + return violation( + checkpoint, + `request ${index} holds ${JSON.stringify(request.modified)}, owed ${JSON.stringify(owedRequest.row)}`, + `transaction-state`, + { actual: request.modified, expected: owedRequest.row }, + ) + if (!same(request.changes, owedRequest.changes)) + return violation( + checkpoint, + `request ${index} authored ${JSON.stringify(request.changes)}, owed ${JSON.stringify(owedRequest.changes)}`, + `authored-changes`, + { actual: request.changes, expected: owedRequest.changes }, + ) + } + const waiting = new Set(owed.waiting.map((batch) => ids.get(batch))) + for (const [id, status] of seen.receipts) + if (waiting.has(id) && status !== `pending`) + return violation( + checkpoint, + `receipt of source transaction ${id} ${status} before it applied`, + `receipts`, + ) + for (const [name, rows] of [ + [`Collection`, seen.rows], + [`event replica`, seen.replica], + [`raw event replica`, seen.rawReplica], + ] as const) + if (!same(rows, owed.visible)) + return violation( + checkpoint, + `${name} shows ${JSON.stringify(rows)}, owed ${JSON.stringify(owed.visible)}`, + `rows`, + { actual: rows, expected: owed.visible }, + ) + if (!same(seen.downstream, owed.visible.map(plain))) + return violation( + checkpoint, + `downstream shows ${JSON.stringify(seen.downstream)}`, + `rows`, + { actual: seen.downstream, expected: owed.visible.map(plain) }, + ) + const tracked = [...seen.tracked].sort((a, b) => a - b) + if (!same(tracked, owed.unsettled)) + return violation( + checkpoint, + `tracked transactions ${JSON.stringify(tracked)}, unsettled ${JSON.stringify(owed.unsettled)}`, + `tracked-transactions`, + ) + if (owed.quiet && seen.deliveries !== deliveries) + return violation( + checkpoint, + `${seen.deliveries - deliveries} deliveries for a settlement that changed no visible row`, + `quiet-settlement`, + ) + deliveries = seen.deliveries + } + const recorded = new Set(trace.map(({ checkpoint }) => checkpoint)) + for (const checkpoint of [...expected.owed.keys(), `cleanup`]) + if (!recorded.has(checkpoint)) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}" checkpoint`, + `trace`, + ) + return pass +} + +// -- Calibrations -------------------------------------------------------------- +// +// The observation mutants, as corruptions of a recorded trace. Each alters a +// copy of a real publication or reading at the first checkpoint where it +// applies, and names the law that must reject it. + +type Entry = { checkpoint: string; value: unknown } + +/** Replaces the observation at the first checkpoint `change` alters. */ +function corruptFirst( + trace: ReadonlyArray, + change: ( + seen: OptimisticObservation, + checkpoint: string, + ) => OptimisticObservation | undefined, +): Array | undefined { + for (const [index, entry] of trace.entries()) { + if (entry.checkpoint === `cleanup`) continue + const changed = change( + entry.value as OptimisticObservation, + entry.checkpoint, + ) + if (changed) + return trace.map((other, at) => + at === index ? { ...other, value: changed } : other, + ) + } + return undefined +} + +function appliedTo( + before: Array<[unknown, ObservedRow]>, + batch: Array, +): Array<[unknown, ObservedRow]> { + const rows = new Map(before) + for (const change of batch) + if (change.type === `delete`) rows.delete(change.key) + else rows.set(change.key, change.value) + return [...rows] +} + +/** Inserts a corrupted copy before the first later publication `pick` accepts. */ +function injectBefore( + pick: (publication: RecordedPublication) => Array | undefined, +) { + return (trace: ReadonlyArray) => + corruptFirst(trace, (seen, checkpoint) => { + if (checkpoint === `initial`) return undefined + const at = seen.publications.findIndex((publication) => pick(publication)) + if (at < 0) return undefined + const real = seen.publications[at]! + const batch = pick(real)! + const fake: RecordedPublication = { + before: real.before, + batch, + replica: sorted(appliedTo(real.before, batch).map(([, row]) => row)), + source: real.source, + } + const publications = [...seen.publications] + publications.splice(at, 1, fake, { + ...real, + before: appliedTo(real.before, batch), + }) + return { ...seen, publications } + }) +} + +/** Replaces the first later publication `change` alters. */ +function alterPublication( + change: (publication: RecordedPublication) => RecordedPublication | undefined, +) { + return (trace: ReadonlyArray) => + corruptFirst(trace, (seen, checkpoint) => { + if (checkpoint === `initial`) return undefined + for (const [at, publication] of seen.publications.entries()) { + const changed = change(publication) + if (changed) { + const publications = [...seen.publications] + publications[at] = changed + return { ...seen, publications } + } + } + return undefined + }) +} + +export const optimisticHistoryCalibrations = [ + { + name: `wrong key`, + law: `callback-shape`, + corrupt: alterPublication((publication) => + publication.batch.length + ? { + ...publication, + batch: publication.batch.map((change) => ({ + ...change, + key: String(change.key), + })), + } + : undefined, + ), + }, + { + name: `transient field`, + law: `forward-cuts`, + corrupt: injectBefore((publication) => + publication.batch.length + ? publication.batch.map((change) => ({ + ...change, + value: { ...change.value, c: change.value.c + 100 }, + })) + : undefined, + ), + }, + { + name: `partial batch`, + law: `forward-cuts`, + corrupt: injectBefore((publication) => + publication.batch.length > 1 ? publication.batch.slice(0, 1) : undefined, + ), + }, + { + name: `stale previous value`, + law: `event-semantics`, + corrupt: alterPublication((publication) => + publication.batch.some((change) => change.type === `update`) + ? { + ...publication, + batch: publication.batch.map((change) => + change.type === `update` + ? { + ...change, + previousValue: { ...change.previousValue!, c: 999999 }, + } + : change, + ), + } + : undefined, + ), + }, + { + name: `update as insert`, + law: `event-semantics`, + corrupt: alterPublication((publication) => + publication.batch.some((change) => change.type === `update`) + ? { + ...publication, + batch: publication.batch.map((change) => + change.type === `update` ? { ...change, type: `insert` } : change, + ), + } + : undefined, + ), + }, + { + // Replay the initial publication after a later settlement's cut. A + // healthy runtime may coalesce an intermediate cut, but never go back. + name: `backwards cuts`, + law: `forward-cuts`, + corrupt: (trace: ReadonlyArray) => { + const initial = ( + trace.find(({ checkpoint }) => checkpoint === `initial`)?.value as + OptimisticObservation | undefined + )?.publications.at(-1) + if (!initial) return undefined + return corruptFirst(trace, (seen, checkpoint) => + checkpoint.endsWith(`(settle)`) && + seen.publications.length > 0 && + !same(initial.replica, seen.publications.at(-1)!.replica) + ? { ...seen, publications: [...seen.publications, initial] } + : undefined, + ) + }, + }, + { + name: `retained default`, + law: `rows`, + corrupt: (trace: ReadonlyArray) => + corruptFirst(trace, (seen, checkpoint) => + checkpoint.endsWith(`(settle)`) && seen.rows.length + ? { + ...seen, + rows: [{ ...seen.rows[0]!, c: -999999 }, ...seen.rows.slice(1)], + } + : undefined, + ), + }, +] diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 0605923a9b..591fd748b4 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -279,6 +279,7 @@ const staticOracleProperties = [ `flat-change-tracking.equivalence`, `local-storage.value-fidelity`, `live-query-observer.hydration-terminal`, + `optimistic-history.core`, `derived-publication.membership-work`, `collection-publication.metadata-cancellation`, `collection-publication.metadata-only`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 7017e32aa7..50a8b550c2 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -557,6 +557,11 @@ const ownerGroups: ReadonlyArray< `ordered-window`, ], [`db/tests/reverse-index-oracle.property.test.ts`, `reverse-index`, `reads`], + [ + `db/tests/optimistic-history-oracle.property.test.ts`, + `optimistic-history`, + `core`, + ], [ `db/tests/live-query-observer-hydration-oracle.property.test.ts`, `live-query-observer`, From e9e5ebb47b14a16052933bd6409290d783f628a5 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 08:09:34 -0700 Subject: [PATCH 009/168] test(oracles): add a self-oracle for oracleProperty The framework's own promises now have an oracle of the same shape: synthetic properties (histories of one to three integers; 3 and 4 violate laws, 6 makes the driver throw, 0-2 mark reach states) are written to temporary test files and run under real Vitest in a child process with a reporter that keeps each failure's cause. An independent model predicts each test from the spec and the histories the synthetic driver logged: registration, verdict (law, setup error, or a violation that outranks a later driver error), shrinking, membership, reach, calibration, and determinism. The driver also probes replay (a failing fixed lane's seed and path through the random lane) and rerun (a failure's printed -t command). Child runs fix the random lane's seed, so the self-oracle's own determinism test holds. RED on the unchanged framework: a lane whose first failure was a driver error reported a shrunk violation instead (shrinking kept only laws), and the determinism test timed out at Vitest's 5 s default (only lanes had a limit). Fixes: shrinking keeps the first failure's kind (setup error, missed segment state, or one law), and a property-level `timeout` (120 s by default) applies to every test it registers. GREEN: 11 of 11. Framework mutants: the driver error masking a violation, any law satisfying a calibration, a determinism test that never compares, and shrinking that keeps only laws are rejected. Unescaped rerun commands, random-lane segments seeded from the clock, and unchecked pin reach survive for now (open: the pins do not yet distinguish them). Co-authored-by: Isaac --- packages/db/tests/oracle-config.ts | 5 + .../oracle-property-self-oracle-fixture.ts | 180 ++++ .../oracle-property-self-oracle-reporter.mjs | 41 + .../tests/oracle-property-self-oracle.test.ts | 816 ++++++++++++++++++ packages/db/tests/oracle-property.ts | 47 +- packages/db/tests/oracle-replay-manifest.ts | 6 + 6 files changed, 1077 insertions(+), 18 deletions(-) create mode 100644 packages/db/tests/oracle-property-self-oracle-fixture.ts create mode 100644 packages/db/tests/oracle-property-self-oracle-reporter.mjs create mode 100644 packages/db/tests/oracle-property-self-oracle.test.ts diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 591fd748b4..719fc17ffd 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -280,6 +280,11 @@ const staticOracleProperties = [ `local-storage.value-fidelity`, `live-query-observer.hydration-terminal`, `optimistic-history.core`, + `self-oracle.synthetic-0`, + `self-oracle.synthetic-1`, + `self-oracle.synthetic-2`, + `self-oracle.synthetic-3`, + `self-oracle.framework`, `derived-publication.membership-work`, `collection-publication.metadata-cancellation`, `collection-publication.metadata-only`, diff --git a/packages/db/tests/oracle-property-self-oracle-fixture.ts b/packages/db/tests/oracle-property-self-oracle-fixture.ts new file mode 100644 index 0000000000..c93777e5ab --- /dev/null +++ b/packages/db/tests/oracle-property-self-oracle-fixture.ts @@ -0,0 +1,180 @@ +/** + * Synthetic properties for the oracleProperty self-oracle. + * + * `syntheticProperty(spec)` registers one `oracleProperty` whose parts are + * small enough that the self-oracle's own model can predict every outcome. + * A history is a list of one to three integers: + * + * - `0`, `1`, `2` are ordinary items; a history that holds `k` reaches the + * state `saw-k`. + * - `3` and `4` violate the laws `three` and `four`. The check reports the + * first of them in the history. + * - `6` makes the driver throw right after it records that item. + * + * A segment builds its history as its prefix followed by a generated history, + * cut to three items. The driver appends every history it runs, with the running test's name, to + * the file named by SELF_ORACLE_LOG, so the self-oracle knows which + * histories each lane ran. A `nondeterministic` spec also records a counter + * that changes on every run. + * + * The synthetic ids come from a fixed pool, registered in oracle-config. + */ +import { appendFileSync } from 'node:fs' +import fc from 'fast-check' +import { expect } from 'vitest' +import { oracleProperty, pass, violation } from './oracle-property.js' +import type { Calibration } from './oracle-property.js' + +export const syntheticIds = [ + `self-oracle.synthetic-0`, + `self-oracle.synthetic-1`, + `self-oracle.synthetic-2`, + `self-oracle.synthetic-3`, +] as const + +export type SyntheticHistory = ReadonlyArray + +export type SyntheticSpec = { + id: (typeof syntheticIds)[number] + source: + | { kind: `generated`; runs: number; seed: number } + | { kind: `enumerated`; histories: ReadonlyArray } + /** Required states; each must be entered by a segment. */ + states: ReadonlyArray + segments: ReadonlyArray<{ + name: string + prefix: SyntheticHistory + enters: ReadonlyArray + }> + pins: ReadonlyArray<{ + name: string + history: SyntheticHistory + enters?: ReadonlyArray + }> + nondeterministic: boolean + calibrations: ReadonlyArray<{ + name: string + kind: `right` | `wrong-law` | `no-op` + }> +} + +/** The synthetic language: one to three items, mostly ordinary ones. */ +export const syntheticArbitrary: fc.Arbitrary = fc.array( + fc.oneof( + { arbitrary: fc.integer({ min: 0, max: 2 }), weight: 6 }, + { arbitrary: fc.constantFrom(3, 4, 6), weight: 1 }, + ), + { minLength: 1, maxLength: 3 }, +) + +const allStates = [`saw-0`, `saw-1`, `saw-2`] +let nonce = 0 + +function syntheticDriver(spec: SyntheticSpec) { + return ( + history: SyntheticHistory, + observe: (checkpoint: string, value: unknown) => void, + ) => { + const log = process.env.SELF_ORACLE_LOG + if (log) + appendFileSync( + log, + `${JSON.stringify({ id: spec.id, test: expect.getState().currentTestName ?? ``, history })}\n`, + ) + if (spec.nondeterministic) observe(`nonce`, nonce++) + for (const [index, item] of history.entries()) { + observe(`item ${index}`, item) + if (item === 6) + throw new Error(`synthetic driver failed at item ${index}`) + } + } +} + +function syntheticCheck( + history: SyntheticHistory, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, +) { + for (const { checkpoint, value } of trace) { + // Only items carry the law; a nonce is not an item. + if (!checkpoint.startsWith(`item`)) continue + if (value === 3) return violation(checkpoint, `item is 3`, `three`) + if (value === 4) return violation(checkpoint, `item is 4`, `four`) + } + const items = trace.filter(({ checkpoint }) => checkpoint.startsWith(`item`)) + if (items.length !== history.length) + return violation( + `trace`, + `${items.length} of ${history.length} items recorded`, + `trace`, + ) + return pass +} + +function syntheticCalibrations( + spec: SyntheticSpec, +): Array> { + return spec.calibrations.map(({ name, kind }) => ({ + name, + law: kind === `wrong-law` ? `four` : `three`, + corrupt: (trace) => { + const at = trace.findIndex(({ checkpoint }) => + checkpoint.startsWith(`item`), + ) + if (at < 0) return undefined + if (kind === `no-op`) return [...trace] + return trace.map((entry, index) => + index === at ? { ...entry, value: 3 } : entry, + ) + }, + })) +} + +export function syntheticProperty(spec: SyntheticSpec): void { + const parts = { + model: () => undefined, + reached: (history: SyntheticHistory) => + allStates.filter((state) => history.includes(Number(state.slice(4)))), + driver: syntheticDriver(spec), + calibrations: syntheticCalibrations(spec), + } + const law = { + statement: `A synthetic history holds no 3 and no 4.`, + authority: `the oracleProperty self-oracle's synthetic specification`, + limits: `Synthetic; histories of one to three integers.`, + owner: `oracleProperty framework`, + } + if (spec.source.kind === `enumerated`) { + oracleProperty({ + id: spec.id, + law, + histories: spec.source.histories, + // A label with `|`, which a rerun command must escape. + label: (history) => `items ${history.join(` | `)}`, + ...parts, + check: syntheticCheck, + }) + return + } + oracleProperty({ + id: spec.id, + law, + histories: { + arbitrary: syntheticArbitrary, + states: spec.states, + measured: allStates.filter((state) => !spec.states.includes(state)), + runs: spec.source.runs, + fixedSeeds: [spec.source.seed], + segments: spec.segments.map(({ name, prefix, enters }) => ({ + name, + enters, + // A segment extends a generated history, so its histories depend on + // the lane's seed, as real segments do. + build: (generated: SyntheticHistory) => + [...prefix, ...generated].slice(0, 3), + })), + pins: spec.pins, + }, + ...parts, + check: syntheticCheck, + }) +} diff --git a/packages/db/tests/oracle-property-self-oracle-reporter.mjs b/packages/db/tests/oracle-property-self-oracle-reporter.mjs new file mode 100644 index 0000000000..64b26198e3 --- /dev/null +++ b/packages/db/tests/oracle-property-self-oracle-reporter.mjs @@ -0,0 +1,41 @@ +// A Vitest reporter for the oracleProperty self-oracle. It writes every test's +// state and errors, with their causes (fast-check attaches a property's error +// as the cause of its failure), to the file named by SELF_ORACLE_REPORT. +import { writeFileSync } from 'node:fs' + +/** + * @param {unknown} error + * @returns {{ name?: string, message?: string, cause?: unknown } | undefined} + */ +const serialize = (error) => { + if (error === undefined) return undefined + if (!error || typeof error !== `object`) return { message: String(error) } + const { name, message, cause } = + /** @type {{ name?: string, message?: string, cause?: unknown }} */ (error) + return { name, message, cause: serialize(cause) } +} + +export default class SelfOracleReporter { + /** + * @param {ReadonlyArray} testModules + * @param {ReadonlyArray} unhandledErrors + */ + onTestRunEnd(testModules, unhandledErrors) { + const files = testModules.map((module) => ({ + file: module.moduleId, + errors: (module.errors?.() ?? []).map(serialize), + tests: [...module.children.allTests()].map((test) => ({ + name: test.fullName, + state: test.result().state, + errors: (test.result().errors ?? []).map(serialize), + })), + })) + writeFileSync( + process.env.SELF_ORACLE_REPORT ?? `self-oracle-report.json`, + JSON.stringify({ + files, + unhandled: (unhandledErrors ?? []).map(serialize), + }), + ) + } +} diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts new file mode 100644 index 0000000000..4572a13b90 --- /dev/null +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -0,0 +1,816 @@ +/** + * # The oracleProperty self-oracle + * + * `oracleProperty` makes promises to the people who write oracles with it. + * This oracle checks them the way every other oracle checks production: it + * writes synthetic properties, runs them under real Vitest in a child + * process, and compares what Vitest reports with what an independent model + * predicts. The laws, each judged per test: + * + * - `registration`: a property registers its lanes, pins, calibrations, and + * determinism test, or fails to load when a required state has no segment + * or a segment names an undeclared state. + * - `verdict`: a test fails with a violation of law L exactly when its first + * failing history violates L; a driver error fails it as a setup error + * unless a recorded item already violated a law, which then wins. + * - `shrink`: a lane's reported failure keeps the kind and law of its first + * failure, and its counterexample still has them. + * - `membership` and `reach`: a pin or segment outside the language fails, + * and a pin or segment that does not enter a state it names fails. + * - `calibration`: a calibration passes only when its candidate's clean trace + * passes and the corrupted one fails with the named law. + * - `determinism`: the determinism test fails for a driver whose trace + * changes between runs of one history, and passes otherwise. + * - `replay`: replaying a fixed lane's reported seed and path runs only the + * random lane of that property and reproduces the same counterexample. + * - `rerun`: a failure's printed rerun command runs exactly that test. + * + * Authority: the contract in the header of `oracle-property.ts`, and the + * oracle rubric's replay (ORC-007), calibration (ORC-006), and declared-reach + * (ORC-015) rules. + * + * Limits: synthetic properties only, with histories of one to three + * integers; one or two properties per run; the model predicts which histories + * a lane fails on from the histories the synthetic driver logged, not the + * order fast-check draws them in. + */ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import fc from 'fast-check' +import { oracleProperty, pass, violation } from './oracle-property.js' +import { + syntheticArbitrary, + syntheticIds, +} from './oracle-property-self-oracle-fixture.js' +import type { + SyntheticHistory, + SyntheticSpec, +} from './oracle-property-self-oracle-fixture.js' + +// --------------------------------------------------------------------------- +// History: a suite of one or two synthetic property drafts +// --------------------------------------------------------------------------- + +const allStates = [`saw-0`, `saw-1`, `saw-2`] +const calibrationKinds = [`right`, `wrong-law`, `no-op`] as const +type Draft = { + source: + | { kind: `generated`; runs: number; seed: number } + | { kind: `enumerated`; histories: ReadonlyArray } + /** `exact` declares the segments' states; the others break registration. */ + statesMode: `exact` | `extra` | `missing` + segments: Array<{ prefix: SyntheticHistory; enters: Array }> + pins: Array<{ history: SyntheticHistory; enters: Array | null }> + nondeterministic: boolean + calibrations: Array<(typeof calibrationKinds)[number]> +} +type Suite = { drafts: Array } + +// A pin may lie outside the synthetic language: an item 5, or four items. +const outside = fc.oneof( + fc.constantFrom(5).map( + (item) => [item], + (value) => { + if (Array.isArray(value) && value.length === 1 && value[0] === 5) return 5 + throw new Error(`not [5]`) + }, + ), + fc.array(fc.integer({ min: 0, max: 2 }), { minLength: 4, maxLength: 4 }), +) +const draft: fc.Arbitrary = fc.record({ + source: fc.oneof( + fc.record({ + kind: fc.constant(`generated` as const), + runs: fc.integer({ min: 2, max: 3 }), + seed: fc.integer({ min: 1, max: 999 }), + }), + fc.record({ + kind: fc.constant(`enumerated` as const), + histories: fc.uniqueArray(syntheticArbitrary, { + minLength: 1, + maxLength: 4, + selector: (history) => history.join(`,`), + }), + }), + ), + statesMode: fc.oneof( + { arbitrary: fc.constant(`exact` as const), weight: 6 }, + { + arbitrary: fc.constantFrom(`extra` as const, `missing` as const), + weight: 1, + }, + ), + segments: fc.array( + fc.record({ + prefix: syntheticArbitrary, + enters: fc.subarray(allStates), + }), + { maxLength: 2 }, + ), + pins: fc.array( + fc.record({ + history: fc.oneof( + { + arbitrary: syntheticArbitrary, + weight: 4, + }, + { arbitrary: outside, weight: 1 }, + ), + enters: fc.option(fc.subarray(allStates), { freq: 2 }), + }), + { maxLength: 2 }, + ), + nondeterministic: fc.oneof( + { arbitrary: fc.constant(false), weight: 4 }, + { arbitrary: fc.constant(true), weight: 1 }, + ), + calibrations: fc.array(fc.constantFrom(...calibrationKinds), { + maxLength: 3, + }), +}) +const suite: fc.Arbitrary = fc.record({ + drafts: fc.array(draft, { minLength: 1, maxLength: 2 }), +}) + +// --------------------------------------------------------------------------- +// Resolution: ids, names, and declared states +// --------------------------------------------------------------------------- +// +// Each draft gets a pooled id, and its segments, pins, and calibrations get +// names. `statesMode` decides the declared states: exactly the states the +// segments enter, one more (a state without a segment), or one fewer (a +// segment that names an undeclared state). An enumerated draft has no +// segments or pins. + +type Plan = { specs: Array } + +function resolveSuite({ drafts }: Suite): Plan { + return { + specs: drafts.map((d, index) => { + const generated = d.source.kind === `generated` + const segments = generated + ? d.segments.map((segment, at) => ({ name: `seg-${at}`, ...segment })) + : [] + const entered = [ + ...new Set(segments.flatMap((segment) => segment.enters)), + ].sort() + const unused = allStates.filter((state) => !entered.includes(state)) + const states = + d.statesMode === `extra` && unused.length + ? [...entered, unused[0]!] + : d.statesMode === `missing` && entered.length + ? entered.slice(1) + : entered + return { + id: syntheticIds[index]!, + source: d.source, + states, + segments, + pins: generated + ? d.pins.map((pin, at) => ({ + name: `pin-${at}`, + history: pin.history, + ...(pin.enters ? { enters: pin.enters } : {}), + })) + : [], + nondeterministic: d.nondeterministic, + calibrations: d.calibrations.map((kind, at) => ({ + name: `cal-${at}`, + kind, + })), + } + }), + } +} + +// --------------------------------------------------------------------------- +// Model: what Vitest should report for each synthetic property +// --------------------------------------------------------------------------- +// +// The synthetic laws are simple enough to state directly: the first 3, 4, or +// 6 in a history decides it (law `three`, law `four`, or a driver error), and +// a history reaches `saw-k` when it holds k. The model reads the histories +// the synthetic driver logged for a test only to learn which histories that +// test ran; it never reads what Vitest reported. + +type Kind = + | `pass` + | `violation` + | `setup` + | `pin-membership` + | `segment-membership` + | `pin-reach` + | `segment-reach` + | `determinism` + | `calibration-passed` + | `calibration-wrong-law` +type Outcome = { kind: Kind; law?: `three` | `four` } +type LogEntry = { id: string; test: string; history: SyntheticHistory } + +const member = (history: SyntheticHistory) => + history.length >= 1 && + history.length <= 3 && + history.every((item) => [0, 1, 2, 3, 4, 6].includes(item)) +function verdictOf(history: SyntheticHistory): Outcome { + for (const item of history) { + if (item === 3) return { kind: `violation`, law: `three` } + if (item === 4) return { kind: `violation`, law: `four` } + if (item === 6) return { kind: `setup` } + } + return { kind: `pass` } +} +const reaches = (history: SyntheticHistory, states: ReadonlyArray) => + states.every((state) => history.includes(Number(state.slice(4)))) +const label = (history: SyntheticHistory) => `items ${history.join(` | `)}` + +type SpecExpectation = + { loads: false } | { loads: true; tests: Map } + +function predictSpec( + spec: SyntheticSpec, + logged: ReadonlyArray, +): SpecExpectation { + const ran = (test: string) => + logged + .filter( + (entry) => + entry.id === spec.id && entry.test === `${spec.id} > ${test}`, + ) + .map((entry) => entry.history) + const tests = new Map() + const calibrate = (name: string, kind: (typeof calibrationKinds)[number]) => { + const first = ran(`calibration: ${name}`)[0] + if (!first) return { kind: `pass` } as Outcome + const clean = verdictOf(first) + if (clean.kind !== `pass`) return clean + if (kind === `right`) return { kind: `pass` } as Outcome + return { + kind: kind === `no-op` ? `calibration-passed` : `calibration-wrong-law`, + } as Outcome + } + // The determinism test runs each of its histories twice, in order; the + // log names them, including segment histories built from generated ones. + const determinism = (): Outcome => { + const histories = ran(`determinism`).filter((_, at) => at % 2 === 0) + for (const history of histories) { + if (history.includes(6)) return { kind: `setup` } + if (spec.nondeterministic) return { kind: `determinism` } + } + return { kind: `pass` } + } + if (spec.source.kind === `enumerated`) { + const { histories } = spec.source + for (const history of histories) + tests.set(label(history), verdictOf(history)) + for (const { name, kind } of spec.calibrations) + tests.set(`calibration: ${name}`, calibrate(name, kind)) + tests.set(`determinism`, determinism()) + return { loads: true, tests } + } + const declared = new Set(spec.states) + const entered = new Set(spec.segments.flatMap((segment) => segment.enters)) + if ( + spec.states.some((state) => !entered.has(state)) || + [...entered].some((state) => !declared.has(state)) + ) + return { loads: false } + const lane = (test: string): Outcome => { + if (spec.segments.some((segment) => !member(segment.prefix))) + return { kind: `segment-membership` } + for (const [at, history] of ran(test).entries()) { + const verdict = verdictOf(history) + if (verdict.kind === `setup`) return verdict + const segment = spec.segments[at] + if ( + at < spec.segments.length && + segment && + !reaches(history, segment.enters) + ) + return { kind: `segment-reach` } + if (verdict.kind === `violation`) return verdict + } + return { kind: `pass` } + } + tests.set( + `fixed seed ${spec.source.seed}`, + lane(`fixed seed ${spec.source.seed}`), + ) + tests.set(`random seed`, lane(`random seed`)) + for (const pin of spec.pins) { + const verdict = verdictOf(pin.history) + tests.set( + `pin: ${pin.name}`, + !member(pin.history) + ? { kind: `pin-membership` } + : verdict.kind !== `pass` + ? verdict + : !reaches(pin.history, pin.enters ?? []) + ? { kind: `pin-reach` } + : { kind: `pass` }, + ) + } + for (const { name, kind } of spec.calibrations) + tests.set(`calibration: ${name}`, calibrate(name, kind)) + tests.set(`determinism`, determinism()) + return { loads: true, tests } +} + +function suiteModel( + plan: Plan, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, +) { + const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as + SuiteRun | undefined + const logged = run?.log ?? [] + return new Map(plan.specs.map((spec) => [spec.id, predictSpec(spec, logged)])) +} + +// --------------------------------------------------------------------------- +// Production driver: real Vitest in a child process +// --------------------------------------------------------------------------- +// +// The driver writes each spec to its own test file in a fresh directory +// under tests/, runs Vitest once over them with the self-oracle reporter, and +// records each file's load errors and each test's state and failure. It then +// probes replay (one failing fixed lane's seed and path) and rerun (one +// failing test's printed `-t` command), and records what ran. + +type ReportedError = { name?: string; message?: string; cause?: ReportedError } +type ReportedTest = { + name: string + state: string + errors: Array +} +type ReportedFile = { + file: string + errors: Array + tests: Array +} +type SuiteRun = { + files: Record< + string, + { + loaded: boolean + tests: Array<{ name: string; state: string; failure?: Failure }> + } + > + log: Array +} +type Failure = { + kind: Kind | `other` + law?: string + counterexample?: string + seed?: number + path?: string + rerun?: string +} +type ProbeRun = { + id: string + /** Each test's state in the probe run, by name without the property prefix. */ + states: Record + failure?: Failure + /** The probe target's failure in the first run. */ + target: string + original?: Failure +} + +const here = dirname(fileURLToPath(import.meta.url)) +const packageDirectory = dirname(here) +const vitest = join( + dirname(createRequire(import.meta.url).resolve(`vitest/package.json`)), + `vitest.mjs`, +) +let runCount = 0 + +function classify(error: ReportedError | undefined): Failure { + if (!error) return { kind: `other` } + // fast-check reports a property failure and attaches the property's error + // as its cause; the cause carries the framework's own failure. + const outer = error.message ?? `` + // Only fast-check's wrapper carries the framework's failure as its cause; + // the framework's own errors may carry a driver error as theirs. + const inner = + outer.startsWith(`Property failed`) && error.cause ? error.cause : error + const text = inner.message ?? `` + const seed = /seed: (-?\d+)/.exec(outer)?.[1] + const path = /path: "([\d:]+)"/.exec(outer)?.[1] + const counterexample = /Counterexample: (.*)/.exec(outer)?.[1] + const rerun = /Rerun: -t ("(?:[^"\\]|\\.)*")/.exec(text)?.[1] + const facts = { + ...(seed !== undefined ? { seed: Number(seed) } : {}), + ...(path !== undefined ? { path } : {}), + ...(counterexample !== undefined ? { counterexample } : {}), + ...(rerun !== undefined ? { rerun: JSON.parse(rerun) as string } : {}), + } + const law = /\(law: ([^)]+)\)/.exec(text)?.[1] + const kind: Kind | `other` = + inner.name === `OracleViolation` + ? `violation` + : text.includes(`is not a history the arbitrary can generate`) + ? `pin-membership` + : text.includes(`built a history the arbitrary cannot generate`) + ? `segment-membership` + : text.includes(`did not enter`) && text.includes(`: pin "`) + ? `pin-reach` + : text.includes(`did not enter`) && text.includes(`: segment "`) + ? `segment-reach` + : text.includes(`two runs of`) + ? `determinism` + : text.includes(`but the check passed it`) + ? `calibration-passed` + : text.includes(`but the check reported law`) || + text.includes(`found no history`) + ? `calibration-wrong-law` + : text.includes(`synthetic driver failed`) + ? `setup` + : `other` + return { kind, ...(law ? { law } : {}), ...facts } +} + +function runVitest( + directory: string, + files: Array, + env: Record, + args: Array = [], +) { + const report = join(directory, `report-${runCount++}.json`) + const childEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + !key.startsWith(`TANSTACK_DB_ORACLE_`) && + !key.startsWith(`ORACLE_GAPS`), + ), + ) + const run = spawnSync( + process.execPath, + [ + vitest, + `run`, + `--coverage.enabled=false`, + `--typecheck.enabled=false`, + `--reporter=${join(here, `oracle-property-self-oracle-reporter.mjs`)}`, + ...args, + ...files, + ], + { + cwd: packageDirectory, + // A seed without a path selects no replay; it only fixes the random + // lane's seed, so a child run records the same facts every time. + env: { + ...childEnv, + TANSTACK_DB_ORACLE_SEED: `20251103`, + ...env, + SELF_ORACLE_REPORT: report, + }, + encoding: `utf8`, + timeout: 240_000, + maxBuffer: 64 * 1024 * 1024, + }, + ) + if (run.error) throw run.error + if (!existsSync(report)) + throw new Error( + `the child Vitest run wrote no report:\n${run.stdout}\n${run.stderr}`, + ) + return JSON.parse(readFileSync(report, `utf8`)) as { + files: Array + } +} + +const short = (id: string, name: string) => name.replace(`${id} > `, ``) + +async function driveSuite( + plan: Plan, + observe: (checkpoint: string, value: unknown) => void, +) { + const directory = join(here, `.self-oracle-${process.pid}-${runCount++}`) + mkdirSync(directory, { recursive: true }) + try { + const log = join(directory, `log.jsonl`) + const files = plan.specs.map((spec, index) => { + const file = join(directory, `spec-${index}-oracle.test.ts`) + writeFileSync( + file, + `import { syntheticProperty } from '../oracle-property-self-oracle-fixture'\nsyntheticProperty(${JSON.stringify(spec)})\n`, + ) + return file + }) + const report = runVitest(directory, files, { SELF_ORACLE_LOG: log }) + const logged: Array = existsSync(log) + ? readFileSync(log, `utf8`) + .trim() + .split(`\n`) + .filter(Boolean) + .map((line) => JSON.parse(line) as LogEntry) + : [] + const run: SuiteRun = { files: {}, log: logged } + for (const [index, spec] of plan.specs.entries()) { + const reported = report.files.find((file) => file.file === files[index]) + run.files[spec.id] = { + loaded: reported !== undefined && reported.errors.length === 0, + tests: (reported?.tests ?? []).map((test) => ({ + name: short(spec.id, test.name), + state: test.state, + ...(test.state === `failed` + ? { failure: classify(test.errors[0]) } + : {}), + })), + } + } + observe(`run`, run) + // Replay: the first failing fixed lane that reported a seed and path. + for (const [index, spec] of plan.specs.entries()) { + const lane = run.files[spec.id]!.tests.find( + (test) => + test.name.startsWith(`fixed seed`) && + test.failure?.path !== undefined, + ) + if (!lane) continue + const replay = runVitest(directory, [files[index]!], { + TANSTACK_DB_ORACLE_SEED: String(lane.failure!.seed), + TANSTACK_DB_ORACLE_PATH: lane.failure!.path!, + TANSTACK_DB_ORACLE_PROPERTY: spec.id, + }).files[0] + const states = Object.fromEntries( + (replay?.tests ?? []).map((test) => [ + short(spec.id, test.name), + test.state, + ]), + ) + const random = replay?.tests.find((test) => + test.name.endsWith(`random seed`), + ) + const probe: ProbeRun = { + id: spec.id, + states, + target: lane.name, + original: lane.failure, + ...(random?.state === `failed` + ? { failure: classify(random.errors[0]) } + : {}), + } + observe(`replay`, probe) + break + } + // Rerun: the first failing pin or enumerated history with a rerun command. + for (const [index, spec] of plan.specs.entries()) { + const target = run.files[spec.id]!.tests.find( + (test) => test.failure?.rerun !== undefined, + ) + if (!target) continue + const pattern = target.failure!.rerun! + const rerun = runVitest(directory, [files[index]!], {}, [`-t`, pattern]) + .files[0] + const states = Object.fromEntries( + (rerun?.tests ?? []).map((test) => [ + short(spec.id, test.name), + test.state, + ]), + ) + const ran = rerun?.tests.find((test) => test.state === `failed`) + const probe: ProbeRun = { + id: spec.id, + states, + target: target.name, + original: target.failure, + ...(ran ? { failure: classify(ran.errors[0]) } : {}), + } + observe(`rerun`, probe) + break + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- + +const lawOfTest = (name: string) => + name.startsWith(`calibration:`) + ? `calibration` + : name === `determinism` + ? `determinism` + : `verdict` + +function checkSuite( + plan: Plan, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +) { + const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as + SuiteRun | undefined + if (!run) return violation(`run`, `the driver recorded no run`, `trace`) + for (const spec of plan.specs) { + const owed = expected.get(spec.id)! + const seen = run.files[spec.id]! + if (!owed.loads) { + if (seen.loaded) + return violation( + `run`, + `${spec.id} loaded, but a required state has no segment or a segment names an undeclared state`, + `registration`, + ) + continue + } + if (!seen.loaded) + return violation(`run`, `${spec.id} failed to load`, `registration`) + const names = [...owed.tests.keys()].sort() + const seenNames = seen.tests.map((test) => test.name).sort() + if (JSON.stringify(names) !== JSON.stringify(seenNames)) + return violation( + `run`, + `${spec.id} registered ${JSON.stringify(seenNames)}, owed ${JSON.stringify(names)}`, + `registration`, + { actual: seenNames, expected: names }, + ) + for (const test of seen.tests) { + const outcome = owed.tests.get(test.name)! + const failure = test.failure + const observedKind = + test.state === `passed` ? `pass` : (failure?.kind ?? `other`) + if ( + observedKind !== outcome.kind || + (outcome.law !== undefined && failure?.law !== outcome.law) + ) + return violation( + `run`, + `${spec.id} "${test.name}" ${test.state} as ${JSON.stringify(failure ?? null)}, owed ${JSON.stringify(outcome)}`, + test.name.startsWith(`fixed seed`) || test.name === `random seed` + ? `shrink` + : lawOfTest(test.name), + { actual: failure ?? { kind: `pass` }, expected: outcome }, + ) + // A lane's counterexample keeps the first failure's law. + if (failure?.counterexample && outcome.law) { + const shrunk = JSON.parse(failure.counterexample) as [SyntheticHistory] + if (verdictOf(shrunk[0]).law !== outcome.law) + return violation( + `run`, + `${spec.id} "${test.name}" reported counterexample ${failure.counterexample}, which does not violate law ${outcome.law}`, + `shrink`, + ) + } + } + } + for (const { checkpoint, value } of trace) { + if (checkpoint !== `replay` && checkpoint !== `rerun`) continue + const probe = value as ProbeRun + const others = Object.entries(probe.states).filter(([name]) => + checkpoint === `replay` ? name !== `random seed` : name !== probe.target, + ) + if (others.some(([, state]) => state !== `skipped`)) + return violation( + checkpoint, + `${probe.id}: other tests ran: ${JSON.stringify(Object.fromEntries(others))}`, + checkpoint, + ) + if (!probe.failure) + return violation( + checkpoint, + `${probe.id}: "${checkpoint === `replay` ? `random seed` : probe.target}" did not fail again: ${JSON.stringify(probe.states)}`, + checkpoint, + ) + if ( + probe.failure.kind !== probe.original?.kind || + probe.failure.law !== probe.original.law + ) + return violation( + checkpoint, + `${probe.id}: reproduced ${JSON.stringify(probe.failure)}, first failure ${JSON.stringify(probe.original)}`, + checkpoint, + ) + if ( + checkpoint === `replay` && + probe.failure.counterexample !== probe.original.counterexample + ) + return violation( + checkpoint, + `${probe.id}: replay counterexample ${probe.failure.counterexample}, fixed lane ${probe.original.counterexample}`, + `replay`, + ) + } + return pass +} + +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +const generatedDraft = (overrides: Partial): Draft => ({ + source: { kind: `generated`, runs: 2, seed: 7 }, + statesMode: `exact`, + segments: [], + pins: [], + nondeterministic: false, + calibrations: [], + ...overrides, +}) + +oracleProperty({ + id: `self-oracle.framework`, + law: { + statement: `oracleProperty registers, fails, shrinks, replays, reruns, calibrates, and checks determinism and reach as its contract states.`, + authority: `packages/db/tests/oracle-property.ts header; oracle rubric ORC-006, ORC-007, ORC-015`, + limits: `Synthetic properties with histories of one to three integers, one or two per run; real Vitest child processes.`, + owner: `oracleProperty framework`, + }, + histories: { + arbitrary: suite, + states: [], + runs: 3, + fixedSeeds: [1], + pins: [ + { + name: `a rerun command escapes a label with |`, + history: { + drafts: [ + { + ...generatedDraft({}), + source: { kind: `enumerated`, histories: [[3, 1]] }, + }, + ], + }, + }, + { + name: `a driver error after a violation reports the violation`, + history: { + drafts: [ + generatedDraft({ pins: [{ history: [3, 6], enters: null }] }), + ], + }, + }, + { + name: `shrinking keeps the first failure's law`, + history: { + drafts: [ + generatedDraft({ segments: [{ prefix: [4, 3], enters: [] }] }), + ], + }, + }, + { + name: `a replayed fixed-lane failure reproduces in the random lane`, + history: { + drafts: [ + generatedDraft({ + segments: [{ prefix: [1, 3], enters: [`saw-1`] }], + }), + ], + }, + }, + { + name: `shrinking keeps a setup error a setup error`, + history: { + drafts: [generatedDraft({ segments: [{ prefix: [6], enters: [] }] })], + }, + }, + { + name: `the determinism test rejects a nondeterministic driver`, + history: { + drafts: [ + generatedDraft({ + nondeterministic: true, + pins: [{ history: [0], enters: null }], + }), + ], + }, + }, + { + name: `a required state needs a segment`, + history: { + drafts: [ + generatedDraft({ + statesMode: `extra`, + segments: [{ prefix: [0], enters: [`saw-0`] }], + }), + ], + }, + }, + { + name: `calibrations need the named law`, + history: { + drafts: [ + generatedDraft({ + pins: [{ history: [0], enters: null }], + calibrations: [`right`, `wrong-law`, `no-op`], + }), + ], + }, + }, + ], + }, + resolve: resolveSuite, + model: suiteModel, + driver: driveSuite, + check: checkSuite, +}) diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index cb12bbdcba..61b3400fa5 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -82,10 +82,10 @@ * differs between runs of one history breaks replay, shrinking, and the gap * report's comparison, so a driver records relations (before and after, * same or different id) rather than clock readings or random ids. - * - **Shrinking keeps the law.** A violation can name the law it broke. Once - * a history violates one law, a smaller history that violates a different - * law counts as passing, so the reported counterexample still shows the - * first failure (ORC-010, design D24). + * - **Shrinking keeps the failure.** A violation can name the law it broke. + * Once a history fails one way, a setup error, a missed segment state, or + * one law, a smaller history that fails another way counts as passing, so + * the reported counterexample still shows the first failure (ORC-010). * * The `oracle/no-unit-test` lint rule treats an `oracleProperty` call as an * oracle test whose parts are its arguments, when its options are an object @@ -287,6 +287,8 @@ export interface OraclePropertyOptions { expected: Expected, ) => Verdict calibrations?: ReadonlyArray> + /** Each registered test's limit in milliseconds; 120 seconds by default. */ + timeout?: number } declare global { @@ -301,7 +303,6 @@ interface PropertyTest { run: () => Promise /** Path replays this test runs during; empty stands aside during every one. */ replays: ReadonlyArray - timeout?: number } export function oracleProperty( @@ -316,7 +317,11 @@ export function oracleProperty( : generatedTests(options, histories as GeneratedHistories) describe(id, () => { for (const test of tests) - oracleReplayTest(it, ...test.replays)(test.name, test.run, test.timeout) + oracleReplayTest(it, ...test.replays)( + test.name, + test.run, + options.timeout ?? 120_000, + ) }) } @@ -535,7 +540,6 @@ function calibrationTests( return (options.calibrations ?? []).map((calibration) => ({ name: `calibration: ${calibration.name}`, replays: [], - timeout: 120_000, run: async () => { const seen: Array = [] for (const { name, history } of candidates()) { @@ -647,15 +651,25 @@ function generatedTests( ) } - // Each lane gets a fresh predicate: its first violation fixes the law that - // shrinking must keep. It counts the histories that entered each state. + // Each lane gets a fresh predicate: its first failure fixes the kind that + // shrinking must keep, a setup error, a missed segment state, or one law. A + // smaller history that fails some other way counts as passing. It counts + // the histories that entered each state. const predicateFor = ( segmentOf: Map>, counts: Map, ) => { - let firstLaw: string | undefined + let firstKind: string | undefined + const keeps = (kind: string) => (firstKind ??= kind) === kind return async (history: History) => { - const { verdict, reached } = await runOne(options, history) + let outcome: Awaited> + try { + outcome = await runOne(options, history) + } catch (error) { + if (keeps(`setup`)) throw error + return + } + const { verdict, reached } = outcome for (const state of reached) { if (declared !== undefined && !declared.has(state)) throw new Error( @@ -666,16 +680,15 @@ function generatedTests( const segment = segmentOf.get(history) if (segment !== undefined) { const missing = segment.enters.filter((state) => !reached.has(state)) - if (missing.length > 0) + if (missing.length > 0) { + if (!keeps(`segment-reach`)) return throw new Error( `${id}: segment "${segment.name}" did not enter ${missing.join(`, `)}. A segment must reach every state it names.`, ) + } } if (verdict.kind === `pass`) return - const law = verdict.law ?? verdict.checkpoint - firstLaw ??= law - // A different law's violation during shrinking is not this failure. - if (law !== firstLaw) return + if (!keeps(`law:${lawOf(verdict)}`)) return throw failure( options, verdict, @@ -728,7 +741,6 @@ function generatedTests( const fixedLanes = source.fixedSeeds.map((seed): PropertyTest => ({ name: `fixed seed ${seed}`, replays: [], - timeout: 120_000, run: () => runLane(`fixed seed ${seed}`, seed, (examples) => ({ numRuns: oracleRuns(source.runs) + examples.length, @@ -741,7 +753,6 @@ function generatedTests( const randomLane: PropertyTest = { name: `random seed`, replays: [id], - timeout: 120_000, run: () => { const seed = oraclePropertyOptions(source.runs, id).seed ?? Date.now() % 2 ** 31 diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 50a8b550c2..a96f8ea054 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -557,6 +557,12 @@ const ownerGroups: ReadonlyArray< `ordered-window`, ], [`db/tests/reverse-index-oracle.property.test.ts`, `reverse-index`, `reads`], + [`db/tests/oracle-property-self-oracle.test.ts`, `self-oracle`, `framework`], + [ + `db/tests/oracle-property-self-oracle-fixture.ts`, + `self-oracle`, + `synthetic-0 synthetic-1 synthetic-2 synthetic-3`, + ], [ `db/tests/optimistic-history-oracle.property.test.ts`, `optimistic-history`, From e7b9ae15ce391304cc21903feb1c4b6b4c4117e8 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 08:56:01 -0700 Subject: [PATCH 010/168] test(oracles): settle three framework rules the self-oracle review found unstated The self-oracle's model had copied three framework behaviors that the framework's contract never stated (review: model rules without authority). Maintainer decisions, 2026-11-03: - A calibration skips a candidate whose clean trace fails or whose driver throws, for both the trace-corruption and hostile-driver forms; that history fails in its own test. Before, one failing first pin made every calibration fail. - Within one history a driver error decides first, then a law violation, then a missed segment state. Before, a missed segment state hid a law violation, and kind-preserving shrinking then kept it hidden. - A generated property's determinism test also runs three generated histories from the first fixed seed, so a varying driver is caught without pins or segments. The self-oracle states each rule beside its model, owes the determinism test's history count independently of what the framework chose to run, and has a pin for each rule. RED on the parent framework: 6 of 14 tests fail (the calibration pin, the precedence pin, and the determinism scope in four pins). GREEN: 14 of 14; the hydration, LocalStorage, and optimistic properties and the replay manifest pass (20,860 tests). Co-authored-by: Isaac --- .../tests/oracle-property-self-oracle.test.ts | 93 ++++++++++++++++--- packages/db/tests/oracle-property.ts | 64 ++++++++----- 2 files changed, 120 insertions(+), 37 deletions(-) diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts index 4572a13b90..dac3b2afd5 100644 --- a/packages/db/tests/oracle-property-self-oracle.test.ts +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -213,6 +213,7 @@ type Kind = | `determinism` | `calibration-passed` | `calibration-wrong-law` + | `calibration-none` type Outcome = { kind: Kind; law?: `three` | `four` } type LogEntry = { id: string; test: string; history: SyntheticHistory } @@ -233,7 +234,13 @@ const reaches = (history: SyntheticHistory, states: ReadonlyArray) => const label = (history: SyntheticHistory) => `items ${history.join(` | `)}` type SpecExpectation = - { loads: false } | { loads: true; tests: Map } + | { loads: false } + | { + loads: true + tests: Map + /** Histories a passing determinism test must run, each twice. */ + determinismRuns?: number + } function predictSpec( spec: SyntheticSpec, @@ -247,11 +254,14 @@ function predictSpec( ) .map((entry) => entry.history) const tests = new Map() + // A calibration needs a candidate whose clean trace passes; it skips the + // others (maintainer decision, 2026-11-03). Every history has an item, so + // the corruption applies to every candidate. const calibrate = (name: string, kind: (typeof calibrationKinds)[number]) => { - const first = ran(`calibration: ${name}`)[0] - if (!first) return { kind: `pass` } as Outcome - const clean = verdictOf(first) - if (clean.kind !== `pass`) return clean + const used = ran(`calibration: ${name}`).find( + (history) => verdictOf(history).kind === `pass`, + ) + if (!used) return { kind: `calibration-none` } as Outcome if (kind === `right`) return { kind: `pass` } as Outcome return { kind: kind === `no-op` ? `calibration-passed` : `calibration-wrong-law`, @@ -259,12 +269,17 @@ function predictSpec( } // The determinism test runs each of its histories twice, in order; the // log names them, including segment histories built from generated ones. + // A generated property's determinism test also runs three generated + // histories (maintainer decision, 2026-11-03), so a nondeterministic driver + // fails it even without pins or segments. const determinism = (): Outcome => { const histories = ran(`determinism`).filter((_, at) => at % 2 === 0) for (const history of histories) { if (history.includes(6)) return { kind: `setup` } if (spec.nondeterministic) return { kind: `determinism` } } + if (spec.nondeterministic && spec.source.kind === `generated`) + return { kind: `determinism` } return { kind: `pass` } } if (spec.source.kind === `enumerated`) { @@ -286,9 +301,12 @@ function predictSpec( const lane = (test: string): Outcome => { if (spec.segments.some((segment) => !member(segment.prefix))) return { kind: `segment-membership` } + // A driver error decides a history first, then a law violation, then a + // missed segment state: a violation outranks a reach failure (maintainer + // decision, 2026-11-03). for (const [at, history] of ran(test).entries()) { const verdict = verdictOf(history) - if (verdict.kind === `setup`) return verdict + if (verdict.kind !== `pass`) return verdict const segment = spec.segments[at] if ( at < spec.segments.length && @@ -296,7 +314,6 @@ function predictSpec( !reaches(history, segment.enters) ) return { kind: `segment-reach` } - if (verdict.kind === `violation`) return verdict } return { kind: `pass` } } @@ -321,7 +338,11 @@ function predictSpec( for (const { name, kind } of spec.calibrations) tests.set(`calibration: ${name}`, calibrate(name, kind)) tests.set(`determinism`, determinism()) - return { loads: true, tests } + return { + loads: true, + tests, + determinismRuns: spec.pins.length + spec.segments.length + 3, + } } function suiteModel( @@ -427,12 +448,13 @@ function classify(error: ReportedError | undefined): Failure { ? `determinism` : text.includes(`but the check passed it`) ? `calibration-passed` - : text.includes(`but the check reported law`) || - text.includes(`found no history`) + : text.includes(`but the check reported law`) ? `calibration-wrong-law` - : text.includes(`synthetic driver failed`) - ? `setup` - : `other` + : text.includes(`found no history`) + ? `calibration-none` + : text.includes(`synthetic driver failed`) + ? `setup` + : `other` return { kind, ...(law ? { law } : {}), ...facts } } @@ -651,6 +673,23 @@ function checkSuite( : lawOfTest(test.name), { actual: failure ?? { kind: `pass` }, expected: outcome }, ) + // A passing determinism test ran every history it owes. + if ( + test.name === `determinism` && + test.state === `passed` && + owed.determinismRuns !== undefined + ) { + const runs = run.log.filter( + (entry) => + entry.id === spec.id && entry.test === `${spec.id} > determinism`, + ).length + if (runs !== 2 * owed.determinismRuns) + return violation( + `run`, + `${spec.id} determinism ran ${runs / 2} histories, owed ${owed.determinismRuns}`, + `determinism`, + ) + } // A lane's counterexample keeps the first failure's law. if (failure?.counterexample && outcome.law) { const shrunk = JSON.parse(failure.counterexample) as [SyntheticHistory] @@ -796,6 +835,34 @@ oracleProperty({ ], }, }, + { + name: `a calibration skips a candidate whose clean trace fails`, + history: { + drafts: [ + generatedDraft({ + pins: [ + { history: [3], enters: null }, + { history: [0], enters: null }, + ], + calibrations: [`right`], + }), + ], + }, + }, + { + name: `a law violation outranks a missed segment state`, + history: { + drafts: [ + generatedDraft({ + segments: [{ prefix: [3, 0, 0], enters: [`saw-1`] }], + }), + ], + }, + }, + { + name: `the determinism test also samples generated histories`, + history: { drafts: [generatedDraft({ nondeterministic: true })] }, + }, { name: `calibrations need the named law`, history: { diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 61b3400fa5..7ddc7b7a5a 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -74,11 +74,13 @@ * must reject it: a `corrupt` function that alters a recorded trace, as an * observation mutant does, or a hostile `driver`, such as production set up * wrongly. The calibration test finds a history where the corruption - * applies, requires the clean trace to pass, and requires the check to report - * that law for the corrupted one (rubric, ORC-006). + * applies and the clean trace passes (it skips a candidate whose clean trace + * fails), and requires the check to report that law for the corrupted one + * (rubric, ORC-006). * - **Determinism.** A `determinism` test runs some histories twice and * requires the same trace: every pin and segment history of a generated - * source, or up to eight evenly spaced enumerated histories. A trace that + * source and three generated histories, or up to eight evenly spaced + * enumerated histories. A trace that * differs between runs of one history breaks replay, shrinking, and the gap * report's comparison, so a driver records relations (before and after, * same or different id) rather than clock readings or random ids. @@ -86,6 +88,8 @@ * Once a history fails one way, a setup error, a missed segment state, or * one law, a smaller history that fails another way counts as passing, so * the reported counterexample still shows the first failure (ORC-010). + * Within one history a driver error decides first, then a law violation, + * then a missed segment state. * * The `oracle/no-unit-test` lint rule treats an `oracleProperty` call as an * oracle test whose parts are its arguments, when its options are an object @@ -551,15 +555,14 @@ function calibrationTests( ) const corrupted = calibration.corrupt(observations, plan) if (corrupted === undefined) continue - // A calibration is meaningful only where the true trace passes. - const clean = judge(options, plan, observations, driverError).verdict - if (clean.kind === `violation`) - throw failure( - options, - clean, - history, - `the calibration's history "${name}"`, - ) + // A calibration is meaningful only where the true trace passes, so + // a candidate whose clean trace fails, or whose driver throws, is + // skipped; that history fails in its own test. + if ( + driverError || + judge(options, plan, observations).verdict.kind !== `pass` + ) + continue const wrong = judge(options, plan, corrupted).verdict if (wrong.kind === `violation` && lawOf(wrong) === calibration.law) return @@ -567,13 +570,20 @@ function calibrationTests( `${id}: calibration "${calibration.name}" corrupted the trace of "${name}", but the check ${wrong.kind === `pass` ? `passed it` : `reported law "${lawOf(wrong)}"`}. It must report law "${calibration.law}".`, ) } + // The real driver's trace of the candidate must pass first. + const clean = await recordGuarded(options.driver, plan) + if ( + clean.driverError || + judge(options, plan, clean.observations).verdict.kind !== `pass` + ) + continue const { verdict } = await runOne(options, history, calibration.driver) if (verdict.kind === `pass`) continue if (lawOf(verdict) === calibration.law) return seen.push(`"${name}" reported law "${lawOf(verdict)}"`) } throw new Error( - `${id}: calibration "${calibration.name}" found no history where the check reports law "${calibration.law}"${seen.length ? `; ${seen.slice(0, 3).join(`, `)}` : ``}. Add a pin or segment that reaches the state its wrong answer changes.`, + `${id}: calibration "${calibration.name}" found no history whose clean trace passes and where the check reports law "${calibration.law}"${seen.length ? `; ${seen.slice(0, 3).join(`, `)}` : ``}. Add a passing pin or segment that reaches the state its wrong answer changes.`, ) }, })) @@ -677,17 +687,16 @@ function generatedTests( ) counts.set(state, (counts.get(state) ?? 0) + 1) } - const segment = segmentOf.get(history) - if (segment !== undefined) { - const missing = segment.enters.filter((state) => !reached.has(state)) - if (missing.length > 0) { - if (!keeps(`segment-reach`)) return - throw new Error( - `${id}: segment "${segment.name}" did not enter ${missing.join(`, `)}. A segment must reach every state it names.`, - ) - } + // A law violation outranks a missed segment state. + if (verdict.kind === `pass`) { + const segment = segmentOf.get(history) + const missing = + segment?.enters.filter((state) => !reached.has(state)) ?? [] + if (missing.length === 0 || !keeps(`segment-reach`)) return + throw new Error( + `${id}: segment "${segment!.name}" did not enter ${missing.join(`, `)}. A segment must reach every state it names.`, + ) } - if (verdict.kind === `pass`) return if (!keeps(`law:${lawOf(verdict)}`)) return throw failure( options, @@ -800,7 +809,14 @@ function generatedTests( })), ] } - const determinism = determinismTest(options, fixedHistories) + // Determinism also covers three generated histories, so a property whose + // driver varies is caught even without pins or segments. + const determinism = determinismTest(options, () => [ + ...fixedHistories(), + ...fc + .sample(source.arbitrary, { seed: source.fixedSeeds[0] ?? 0, numRuns: 3 }) + .map((history, index) => ({ name: `generated ${index}`, history })), + ]) // A calibration also tries a fixed sample of generated histories. const calibrations = calibrationTests(options, () => [ ...fixedHistories(), From d0cb1f5094baecbb6387fc61e9f70a3cd48656e0 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 09:18:54 -0700 Subject: [PATCH 011/168] test(oracles): close the self-oracle review's enforcement gaps Three reviews of the self-oracle (adversarial, mutant gap hunt, law coverage) found no framework bug among their survivors; each survivor was a gap in the self-oracle. Closed: - Replay: every failing lane is replayed across all files; other tests must stand aside, the replay must start from the counterexample (fast-check walks the path through the shrink tree without running it), and reproduce it. The model owes the probes, so a missing replay fails. - Rerun: each property's first violating pin or history is rerun, and a sibling label (`items 3 | 2`) makes an unescaped `-t` run two tests. - Witnesses for every reach and membership rule (pins and segments, inside and outside the language), the default `observe("reach")` path, the hostile-driver calibration form, a violation's driver-error cause, and a property's timeout. Segment prefixes can now lie outside the language. - Reach counts: each passing lane's printed counts are compared with counts from the logged histories (read from the child's stdout, which the framework writes past Vitest's console capture). - Determinism: the nondeterministic counter is recorded last, so a comparison of only the first observations fails. - The self-oracle declares its own framework states as measured, and each of its 18 pins names the state it witnesses. - Prose beside every section; corrected limits; a coverage-map row; the framework header now states its rules plainly instead of citing design notes outside the repository. Evidence: 21 of 21 pass (about two minutes). Seventeen framework mutants, covering every survivor class from the reviews and the three behaviors the 2026-11-03 decisions replaced, are each rejected by at least one pin. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 1 + .../oracle-property-self-oracle-fixture.ts | 71 +- .../tests/oracle-property-self-oracle.test.ts | 927 ++++++++++++------ packages/db/tests/oracle-property.ts | 19 +- 4 files changed, 665 insertions(+), 353 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 5a93c41b5d..b99503bd54 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1846,6 +1846,7 @@ generic visitor behavior outside the named collectors. | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | | React paced-mutation render cuts | [render-cut oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx), [PR #2170 review](oracle-reviews/pr-2170-react-paced-render-cuts.md) | `usePacedMutations` publishes callbacks only from committed renders and does so before descendant layout effects, while callback-only commits preserve mutate identity. An independent committed-document model crosses ordinary and StrictMode schedules with mutation before or after a suspended candidate, mutation after the candidate commits, and delayed or immediate mutation from a descendant layout effect. It compares ordered callback traces and public rows at the mutation cut, the trace after persistence, and mutate identity at the commit cut. Render-time publication, layout-effect-only publication, and split insertion/layout publication mutants fail at the intended comparisons. A commit between transaction admission and persistence, arbitrary React schedules, server delivery, and other frameworks remain outside this receiving owner. | | Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [core property](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.property.test.ts) (`optimistic-history.core`), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | `optimistic-history.core` runs the core grammar through `oracleProperty`: the history resolves to a plan of concrete actions, the driver records each checkpoint, and the model owes allowed cuts, rows, outcomes, receipts, and tracked transactions, judged as named laws; the former settlement-boundary regressions are its pins, and the seven observation mutants are calibrations, each rejected by its named law. Its grammar does not yet generate pending manual edits, external completion, rejected handlers, reentrant drains, partial updates, ordered operations, or schema defaults; those dimensions are still covered only by pinned histories in the owners that drive `runOptimisticHistory` (open: move them into the grammar). Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| oracleProperty framework | [self-oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle.test.ts) (`self-oracle.framework`), [synthetic properties](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle-fixture.ts) | `packages/db/tests/oracle-property.ts` promises its authors registration, verdicts, shrinking, membership, reach, calibration, determinism, timeouts, replay, and rerun commands. The self-oracle writes synthetic properties (histories of one to three integers), runs them under real Vitest in a child process, and compares each test's state and failure with an independent model that reads only the synthetic driver's log of the histories each test ran. It probes a replay of every failing lane (other tests must stand aside, the replay must start from the counterexample and reproduce it) and a rerun of each property's first violating pin or history (the printed `-t` command must run exactly that test). Eighteen pins replay the framework's regressions and witness each state its laws are about; lanes count those states. Maintainer decisions of 2026-11-03: a calibration skips a candidate whose clean trace fails; within one history a driver error decides first, then a law violation, then a missed segment state; a generated property's determinism test also runs three generated histories. Seventeen framework mutants, including every survivor class from the three reviews, are rejected. Limits: one rerun probe per property; the model relies on fast-check running a lane's segment histories first; `forwardCut`, `withinTurns`, `resolve` errors, and the gap report's observation hook have no law here; a run takes about two minutes. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, and delete through handler parameter and mutation-alias access. All calls explicitly refetch; former `refetch`/`clearError` coordinates remain only as historical replay labels, preserving all 12 identity and 12 mutation-overlap executions. The ordinary [health scenarios](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.test.ts) observe exact public causes and settlement. A held refetch retains the current public error. A failed initial load, including a failed initial Collection application, remains a loading error for that sync run regardless of later fetch results. After readiness, a pending retry retains the prior failure reason; failure reports the later cause, and successful result application clears it. Query retry attempts and an older held success are crossed with newer failures, including reuse of one Error value in one clock tick. These scenarios supplement the oracle; they do not claim removed utility fields or error counts. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session) | | Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. The `live-query-observer.hydration-terminal` property (`oracleProperty`, in the path-replay registry) generates histories of up to 10 steps that cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart. Its 11 declared states are model states (a status while the seed is active, attached or detached; attach to a failure; cleanup after a failure ended the seed; restart under the seed). Eight segments enter them, so every fixed and random lane reaches every state, and half of each lane's histories start with a segment; each lane prints its reach counts. Pins replay the crash, fail-then-cleanup, and crash-before-attach traces in both modes. The check judges laws `view` (status, flags, claimed rows) and `announcement` (last-read snapshot, granular rebuilt rows). The migration from the hand-built campaign kept every mutant kill and every gap-report outcome on `src/live-query-observer.ts` (seed 42); the old prefixed histories ran up to 13 steps. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests. | diff --git a/packages/db/tests/oracle-property-self-oracle-fixture.ts b/packages/db/tests/oracle-property-self-oracle-fixture.ts index c93777e5ab..5571bb1f45 100644 --- a/packages/db/tests/oracle-property-self-oracle-fixture.ts +++ b/packages/db/tests/oracle-property-self-oracle-fixture.ts @@ -14,8 +14,11 @@ * A segment builds its history as its prefix followed by a generated history, * cut to three items. The driver appends every history it runs, with the running test's name, to * the file named by SELF_ORACLE_LOG, so the self-oracle knows which - * histories each lane ran. A `nondeterministic` spec also records a counter - * that changes on every run. + * histories each lane ran. A `nondeterministic` spec also records, last, a + * counter that changes on every run. A spec reports reach through the + * property's `reached` part or, as the framework's default, through the + * driver's `observe("reach", state)`. A `slow` spec's driver takes about + * 300 ms per history, so a small `timeout` fails every test that runs one. * * The synthetic ids come from a fixed pool, registered in oracle-config. */ @@ -54,8 +57,13 @@ export type SyntheticSpec = { nondeterministic: boolean calibrations: ReadonlyArray<{ name: string - kind: `right` | `wrong-law` | `no-op` + kind: `right` | `wrong-law` | `no-op` | `driver` }> + /** `observe`: the driver reports reach itself, the framework's default. */ + reachVia: `reached` | `observe` + /** Each history takes about 300 ms; with `timeout`, tests outlive it. */ + slow?: boolean + timeout?: number } /** The synthetic language: one to three items, mostly ordinary ones. */ @@ -70,8 +78,15 @@ export const syntheticArbitrary: fc.Arbitrary = fc.array( const allStates = [`saw-0`, `saw-1`, `saw-2`] let nonce = 0 -function syntheticDriver(spec: SyntheticSpec) { - return ( +const reachedStates = (history: SyntheticHistory) => + allStates.filter((state) => history.includes(Number(state.slice(4)))) + +/** + * The synthetic driver. `hostile` records a 3 in place of the first item, the + * wrong answer a driver calibration supplies. + */ +function syntheticDriver(spec: SyntheticSpec, hostile = false) { + return async ( history: SyntheticHistory, observe: (checkpoint: string, value: unknown) => void, ) => { @@ -81,12 +96,16 @@ function syntheticDriver(spec: SyntheticSpec) { log, `${JSON.stringify({ id: spec.id, test: expect.getState().currentTestName ?? ``, history })}\n`, ) - if (spec.nondeterministic) observe(`nonce`, nonce++) + if (spec.slow) await new Promise((resolve) => setTimeout(resolve, 300)) + if (spec.reachVia === `observe`) + for (const state of reachedStates(history)) observe(`reach`, state) for (const [index, item] of history.entries()) { - observe(`item ${index}`, item) + observe(`item ${index}`, hostile && index === 0 ? 3 : item) if (item === 6) throw new Error(`synthetic driver failed at item ${index}`) } + // Last, so a comparison of only the first observations misses it. + if (spec.nondeterministic) observe(`nonce`, nonce++) } } @@ -113,29 +132,37 @@ function syntheticCheck( function syntheticCalibrations( spec: SyntheticSpec, ): Array> { - return spec.calibrations.map(({ name, kind }) => ({ - name, - law: kind === `wrong-law` ? `four` : `three`, - corrupt: (trace) => { - const at = trace.findIndex(({ checkpoint }) => - checkpoint.startsWith(`item`), - ) - if (at < 0) return undefined - if (kind === `no-op`) return [...trace] - return trace.map((entry, index) => - index === at ? { ...entry, value: 3 } : entry, - ) + return spec.calibrations.map( + ({ name, kind }): Calibration => { + // A hostile driver records a 3 in place of the first item. + if (kind === `driver`) + return { name, law: `three`, driver: syntheticDriver(spec, true) } + return { + name, + law: kind === `wrong-law` ? `four` : `three`, + corrupt: (trace) => { + const at = trace.findIndex(({ checkpoint }) => + checkpoint.startsWith(`item`), + ) + if (at < 0) return undefined + if (kind === `no-op`) return [...trace] + return trace.map((entry, index) => + index === at ? { ...entry, value: 3 } : entry, + ) + }, + } }, - })) + ) } export function syntheticProperty(spec: SyntheticSpec): void { const parts = { model: () => undefined, - reached: (history: SyntheticHistory) => - allStates.filter((state) => history.includes(Number(state.slice(4)))), + // Without `reached`, the framework counts the driver's `reach` records. + ...(spec.reachVia === `reached` ? { reached: reachedStates } : {}), driver: syntheticDriver(spec), calibrations: syntheticCalibrations(spec), + ...(spec.timeout !== undefined ? { timeout: spec.timeout } : {}), } const law = { statement: `A synthetic history holds no 3 and no 4.`, diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts index dac3b2afd5..5c12fdf09a 100644 --- a/packages/db/tests/oracle-property-self-oracle.test.ts +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -5,34 +5,44 @@ * This oracle checks them the way every other oracle checks production: it * writes synthetic properties, runs them under real Vitest in a child * process, and compares what Vitest reports with what an independent model - * predicts. The laws, each judged per test: + * predicts. The laws, each judged per test or per probe: * * - `registration`: a property registers its lanes, pins, calibrations, and * determinism test, or fails to load when a required state has no segment * or a segment names an undeclared state. * - `verdict`: a test fails with a violation of law L exactly when its first - * failing history violates L; a driver error fails it as a setup error - * unless a recorded item already violated a law, which then wins. + * failing history violates L. Within one history a driver error decides + * first, then a law violation, then a missed segment state; a driver error + * after a recorded violation becomes that violation's cause. * - `shrink`: a lane's reported failure keeps the kind and law of its first * failure, and its counterexample still has them. - * - `membership` and `reach`: a pin or segment outside the language fails, - * and a pin or segment that does not enter a state it names fails. - * - `calibration`: a calibration passes only when its candidate's clean trace - * passes and the corrupted one fails with the named law. - * - `determinism`: the determinism test fails for a driver whose trace - * changes between runs of one history, and passes otherwise. - * - `replay`: replaying a fixed lane's reported seed and path runs only the - * random lane of that property and reproduces the same counterexample. - * - `rerun`: a failure's printed rerun command runs exactly that test. + * - `membership`: a pin, or a segment's history, outside the language fails. + * - `reach`: a pin or segment that does not enter a state it names fails, and + * a passing lane reports how many of its histories entered each declared + * state, whether the property names reach itself or the driver records it. + * - `calibration`: a calibration uses the first candidate whose clean trace + * passes, and passes only when its wrong answer (a corrupted trace or a + * hostile driver) fails with the named law. + * - `determinism`: the determinism test runs every pin and segment history + * and three generated ones, and fails for a driver whose trace changes. + * - `timeout`: a property's `timeout` limits every test it registers. + * - `replay`: replaying a failing lane's seed and path runs only that + * property's random lane, starts from the counterexample itself, and + * reproduces it. + * - `rerun`: a violation's printed rerun command runs exactly that test. * - * Authority: the contract in the header of `oracle-property.ts`, and the - * oracle rubric's replay (ORC-007), calibration (ORC-006), and declared-reach - * (ORC-015) rules. + * Authority: the contract in the header of `oracle-property.ts`; the + * maintainer decisions of 2026-11-03 on calibration candidates, failure + * precedence, and determinism scope; and the oracle rubric's replay + * (ORC-007), calibration (ORC-006), and declared-reach (ORC-015) rules. * - * Limits: synthetic properties only, with histories of one to three - * integers; one or two properties per run; the model predicts which histories - * a lane fails on from the histories the synthetic driver logged, not the - * order fast-check draws them in. + * Limits: synthetic properties with histories of one to three integers, one + * or two per run. The model learns which histories a test ran from the + * synthetic driver's log, and relies on fast-check running a lane's examples + * (its segment histories) first, in order. A replay or rerun probe covers + * every failing lane but only the first violating pin or history of each + * property. The gap report's observation hook, `forwardCut`, and `withinTurns` + * have no law here. */ import { spawnSync } from 'node:child_process' import { @@ -59,9 +69,14 @@ import type { // --------------------------------------------------------------------------- // History: a suite of one or two synthetic property drafts // --------------------------------------------------------------------------- +// +// A draft chooses a generated or enumerated source, how its declared states +// relate to its segments, segments and pins (inside or outside the language), +// whether its driver varies between runs, how it reports reach, whether it is +// slow under a small timeout, and its calibrations. const allStates = [`saw-0`, `saw-1`, `saw-2`] -const calibrationKinds = [`right`, `wrong-law`, `no-op`] as const +const calibrationKinds = [`right`, `wrong-law`, `no-op`, `driver`] as const type Draft = { source: | { kind: `generated`; runs: number; seed: number } @@ -71,11 +86,14 @@ type Draft = { segments: Array<{ prefix: SyntheticHistory; enters: Array }> pins: Array<{ history: SyntheticHistory; enters: Array | null }> nondeterministic: boolean + reachVia: `reached` | `observe` + /** `slow`: 300 ms per history under a 100 ms timeout. */ + timing: `normal` | `slow` calibrations: Array<(typeof calibrationKinds)[number]> } type Suite = { drafts: Array } -// A pin may lie outside the synthetic language: an item 5, or four items. +// Outside the synthetic language: an item 5, or four items. const outside = fc.oneof( fc.constantFrom(5).map( (item) => [item], @@ -86,6 +104,11 @@ const outside = fc.oneof( ), fc.array(fc.integer({ min: 0, max: 2 }), { minLength: 4, maxLength: 4 }), ) +const insideOrOutside = (insideWeight: number) => + fc.oneof( + { arbitrary: syntheticArbitrary, weight: insideWeight }, + { arbitrary: outside, weight: 1 }, + ) const draft: fc.Arbitrary = fc.record({ source: fc.oneof( fc.record({ @@ -110,21 +133,12 @@ const draft: fc.Arbitrary = fc.record({ }, ), segments: fc.array( - fc.record({ - prefix: syntheticArbitrary, - enters: fc.subarray(allStates), - }), + fc.record({ prefix: insideOrOutside(6), enters: fc.subarray(allStates) }), { maxLength: 2 }, ), pins: fc.array( fc.record({ - history: fc.oneof( - { - arbitrary: syntheticArbitrary, - weight: 4, - }, - { arbitrary: outside, weight: 1 }, - ), + history: insideOrOutside(4), enters: fc.option(fc.subarray(allStates), { freq: 2 }), }), { maxLength: 2 }, @@ -133,6 +147,14 @@ const draft: fc.Arbitrary = fc.record({ { arbitrary: fc.constant(false), weight: 4 }, { arbitrary: fc.constant(true), weight: 1 }, ), + reachVia: fc.oneof( + { arbitrary: fc.constant(`reached` as const), weight: 3 }, + { arbitrary: fc.constant(`observe` as const), weight: 1 }, + ), + timing: fc.oneof( + { arbitrary: fc.constant(`normal` as const), weight: 12 }, + { arbitrary: fc.constant(`slow` as const), weight: 1 }, + ), calibrations: fc.array(fc.constantFrom(...calibrationKinds), { maxLength: 3, }), @@ -183,6 +205,8 @@ function resolveSuite({ drafts }: Suite): Plan { })) : [], nondeterministic: d.nondeterministic, + reachVia: d.reachVia, + ...(d.timing === `slow` ? { slow: true, timeout: 100 } : {}), calibrations: d.calibrations.map((kind, at) => ({ name: `cal-${at}`, kind, @@ -198,9 +222,11 @@ function resolveSuite({ drafts }: Suite): Plan { // // The synthetic laws are simple enough to state directly: the first 3, 4, or // 6 in a history decides it (law `three`, law `four`, or a driver error), and -// a history reaches `saw-k` when it holds k. The model reads the histories -// the synthetic driver logged for a test only to learn which histories that -// test ran; it never reads what Vitest reported. +// a history reaches `saw-k` when it holds k. The model reads the synthetic +// driver's log only to learn which histories a test ran; it never reads what +// Vitest reported. From the predicted failures it also owes the probes: a +// replay of every lane it predicts to fail, and a rerun of each property's +// first predicted violation among its pins or enumerated histories. type Kind = | `pass` @@ -214,24 +240,41 @@ type Kind = | `calibration-passed` | `calibration-wrong-law` | `calibration-none` -type Outcome = { kind: Kind; law?: `three` | `four` } + | `timeout` +type Outcome = { + kind: Kind + law?: `three` | `four` + /** A violation whose driver threw afterwards carries that error. */ + cause?: `driver` +} type LogEntry = { id: string; test: string; history: SyntheticHistory } +const allowed = (item: number) => [0, 1, 2, 3, 4, 6].includes(item) const member = (history: SyntheticHistory) => - history.length >= 1 && - history.length <= 3 && - history.every((item) => [0, 1, 2, 3, 4, 6].includes(item)) + history.length >= 1 && history.length <= 3 && history.every(allowed) +// A segment's history is its prefix followed by a generated history, cut to +// three items; generated items are always allowed. +const segmentMember = (prefix: SyntheticHistory) => + prefix.length >= 1 && prefix.slice(0, 3).every(allowed) function verdictOf(history: SyntheticHistory): Outcome { - for (const item of history) { - if (item === 3) return { kind: `violation`, law: `three` } - if (item === 4) return { kind: `violation`, law: `four` } + for (const [at, item] of history.entries()) { + if (item === 3 || item === 4) + return { + kind: `violation`, + law: item === 3 ? `three` : `four`, + ...(history.slice(at + 1).includes(6) ? { cause: `driver` } : {}), + } if (item === 6) return { kind: `setup` } } return { kind: `pass` } } +const statesOf = (history: SyntheticHistory) => + allStates.filter((state) => history.includes(Number(state.slice(4)))) const reaches = (history: SyntheticHistory, states: ReadonlyArray) => - states.every((state) => history.includes(Number(state.slice(4)))) + states.every((state) => statesOf(history).includes(state)) const label = (history: SyntheticHistory) => `items ${history.join(` | `)}` +const isLane = (name: string) => + name.startsWith(`fixed seed`) || name === `random seed` type SpecExpectation = | { loads: false } @@ -240,6 +283,11 @@ type SpecExpectation = tests: Map /** Histories a passing determinism test must run, each twice. */ determinismRuns?: number + /** For each passing lane, the histories entering each state. */ + reach: Map> + /** Lanes owed a replay probe, and the test owed a rerun probe. */ + replays: Array + rerun?: string } function predictSpec( @@ -254,6 +302,35 @@ function predictSpec( ) .map((entry) => entry.history) const tests = new Map() + const generated = spec.source.kind === `generated` + if (generated) { + const declared = new Set(spec.states) + const entered = new Set(spec.segments.flatMap((segment) => segment.enters)) + if ( + spec.states.some((state) => !entered.has(state)) || + [...entered].some((state) => !declared.has(state)) + ) + return { loads: false } + } + const names = + spec.source.kind === `enumerated` + ? spec.source.histories.map(label) + : [ + `fixed seed ${spec.source.seed}`, + `random seed`, + ...spec.pins.map((pin) => `pin: ${pin.name}`), + ] + const all = [ + ...names, + ...spec.calibrations.map(({ name }) => `calibration: ${name}`), + `determinism`, + ] + // A slow driver outlives the 100 ms limit in every test that runs a + // history, and every test runs at least one. + if (spec.slow) { + for (const name of all) tests.set(name, { kind: `timeout` }) + return { loads: true, tests, reach: new Map(), replays: [] } + } // A calibration needs a candidate whose clean trace passes; it skips the // others (maintainer decision, 2026-11-03). Every history has an item, so // the corruption applies to every candidate. @@ -262,13 +339,12 @@ function predictSpec( (history) => verdictOf(history).kind === `pass`, ) if (!used) return { kind: `calibration-none` } as Outcome - if (kind === `right`) return { kind: `pass` } as Outcome + if (kind === `right` || kind === `driver`) + return { kind: `pass` } as Outcome return { kind: kind === `no-op` ? `calibration-passed` : `calibration-wrong-law`, } as Outcome } - // The determinism test runs each of its histories twice, in order; the - // log names them, including segment histories built from generated ones. // A generated property's determinism test also runs three generated // histories (maintainer decision, 2026-11-03), so a nondeterministic driver // fails it even without pins or segments. @@ -278,70 +354,88 @@ function predictSpec( if (history.includes(6)) return { kind: `setup` } if (spec.nondeterministic) return { kind: `determinism` } } - if (spec.nondeterministic && spec.source.kind === `generated`) - return { kind: `determinism` } + if (spec.nondeterministic && generated) return { kind: `determinism` } return { kind: `pass` } } - if (spec.source.kind === `enumerated`) { - const { histories } = spec.source - for (const history of histories) - tests.set(label(history), verdictOf(history)) - for (const { name, kind } of spec.calibrations) - tests.set(`calibration: ${name}`, calibrate(name, kind)) - tests.set(`determinism`, determinism()) - return { loads: true, tests } - } - const declared = new Set(spec.states) - const entered = new Set(spec.segments.flatMap((segment) => segment.enters)) - if ( - spec.states.some((state) => !entered.has(state)) || - [...entered].some((state) => !declared.has(state)) - ) - return { loads: false } + // A driver error decides a history first, then a law violation, then a + // missed segment state (maintainer decision, 2026-11-03). A lane runs its + // segment histories first, in order. const lane = (test: string): Outcome => { - if (spec.segments.some((segment) => !member(segment.prefix))) + if (spec.segments.some((segment) => !segmentMember(segment.prefix))) return { kind: `segment-membership` } - // A driver error decides a history first, then a law violation, then a - // missed segment state: a violation outranks a reach failure (maintainer - // decision, 2026-11-03). for (const [at, history] of ran(test).entries()) { - const verdict = verdictOf(history) + const { cause: _shrinkable, ...verdict } = verdictOf(history) if (verdict.kind !== `pass`) return verdict const segment = spec.segments[at] - if ( - at < spec.segments.length && - segment && - !reaches(history, segment.enters) - ) + if (segment && !reaches(history, segment.enters)) return { kind: `segment-reach` } } return { kind: `pass` } } - tests.set( - `fixed seed ${spec.source.seed}`, - lane(`fixed seed ${spec.source.seed}`), - ) - tests.set(`random seed`, lane(`random seed`)) - for (const pin of spec.pins) { - const verdict = verdictOf(pin.history) - tests.set( - `pin: ${pin.name}`, - !member(pin.history) - ? { kind: `pin-membership` } - : verdict.kind !== `pass` - ? verdict - : !reaches(pin.history, pin.enters ?? []) - ? { kind: `pin-reach` } - : { kind: `pass` }, - ) + if (spec.source.kind === `enumerated`) + for (const history of spec.source.histories) + tests.set(label(history), verdictOf(history)) + else { + tests.set(names[0]!, lane(names[0]!)) + tests.set(`random seed`, lane(`random seed`)) + for (const pin of spec.pins) { + const verdict = verdictOf(pin.history) + tests.set( + `pin: ${pin.name}`, + !member(pin.history) + ? { kind: `pin-membership` } + : verdict.kind !== `pass` + ? verdict + : !reaches(pin.history, pin.enters ?? []) + ? { kind: `pin-reach` } + : { kind: `pass` }, + ) + } } for (const { name, kind } of spec.calibrations) tests.set(`calibration: ${name}`, calibrate(name, kind)) tests.set(`determinism`, determinism()) + // The determinism and calibration tests build segment histories too, so a + // segment outside the language fails them the same way it fails a lane. + if (spec.segments.some((segment) => !segmentMember(segment.prefix))) + for (const name of all.filter( + (name) => name === `determinism` || name.startsWith(`calibration:`), + )) + tests.set(name, { kind: `segment-membership` }) + // A passing lane counts, for every declared and measured state, the + // histories it ran that entered it. + const reach = new Map>() + for (const name of names.filter(isLane)) + if (tests.get(name)!.kind === `pass`) + reach.set( + name, + Object.fromEntries( + allStates.map((state) => [ + state, + ran(name).filter((history) => statesOf(history).includes(state)) + .length, + ]), + ), + ) + // A lane that fails inside fast-check reports a seed and path to replay; a + // segment outside the language fails before fast-check runs. + const replays = names.filter( + (name) => + isLane(name) && + [`violation`, `setup`, `segment-reach`].includes(tests.get(name)!.kind), + ) + const rerun = names.find( + (name) => !isLane(name) && tests.get(name)!.kind === `violation`, + ) return { loads: true, tests, - determinismRuns: spec.pins.length + spec.segments.length + 3, + reach, + replays, + ...(rerun ? { rerun } : {}), + ...(generated + ? { determinismRuns: spec.pins.length + spec.segments.length + 3 } + : {}), } } @@ -355,15 +449,95 @@ function suiteModel( return new Map(plan.specs.map((spec) => [spec.id, predictSpec(spec, logged)])) } +// The self-oracle's own reach: states of the framework that its laws are +// about. Each pin names the state it witnesses, so every one is reached on +// every run; lanes count them. +const frameworkStates = [ + `registration-error`, + `lane-violation`, + `lane-setup-failure`, + `replay-probe`, + `rerun-probe`, + `cause-attached`, + `segment-reach-failure`, + `pin-reach-failure`, + `pin-membership-failure`, + `segment-membership-failure`, + `calibration-applied`, + `calibration-rejected`, + `calibration-skipped-candidate`, + `determinism-caught`, + `observed-reach`, + `timeout`, +] as const + +function frameworkReach( + plan: Plan, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +): Iterable { + const reached = new Set() + const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as + SuiteRun | undefined + for (const spec of plan.specs) { + const owed = expected.get(spec.id)! + if (!owed.loads) { + reached.add(`registration-error`) + continue + } + for (const [name, outcome] of owed.tests) { + if (isLane(name) && outcome.kind === `violation`) + reached.add(`lane-violation`) + if (isLane(name) && outcome.kind === `setup`) + reached.add(`lane-setup-failure`) + if (outcome.cause) reached.add(`cause-attached`) + if (outcome.kind === `timeout`) reached.add(`timeout`) + if (outcome.kind === `determinism`) reached.add(`determinism-caught`) + const failure = { + 'segment-reach': `segment-reach-failure`, + 'pin-reach': `pin-reach-failure`, + 'pin-membership': `pin-membership-failure`, + 'segment-membership': `segment-membership-failure`, + 'calibration-passed': `calibration-rejected`, + 'calibration-wrong-law': `calibration-rejected`, + }[outcome.kind as string] + if (failure) reached.add(failure) + if (name.startsWith(`calibration:`) && outcome.kind === `pass`) + reached.add(`calibration-applied`) + if (name.startsWith(`calibration:`)) { + const tried = (run?.log ?? []).filter( + (entry) => + entry.id === spec.id && entry.test === `${spec.id} > ${name}`, + ) + if (tried.length > 1) reached.add(`calibration-skipped-candidate`) + } + } + if (spec.reachVia === `observe` && owed.reach.size > 0) + reached.add(`observed-reach`) + } + if (trace.some(({ checkpoint }) => checkpoint === `replay`)) + reached.add(`replay-probe`) + if (trace.some(({ checkpoint }) => checkpoint === `rerun`)) + reached.add(`rerun-probe`) + return reached +} + // --------------------------------------------------------------------------- // Production driver: real Vitest in a child process // --------------------------------------------------------------------------- // // The driver writes each spec to its own test file in a fresh directory // under tests/, runs Vitest once over them with the self-oracle reporter, and -// records each file's load errors and each test's state and failure. It then -// probes replay (one failing fixed lane's seed and path) and rerun (one -// failing test's printed `-t` command), and records what ran. +// records each file's load errors, each test's state and failure (with the +// cause fast-check attaches), each passing lane's printed reach counts, and +// the synthetic driver's log. Then it probes: +// - replay: for every failing lane with a reported seed and path, Vitest runs +// all the files again with that replay selected and its own log, which +// shows the history the replay started from; +// - rerun: for each property's first failure that printed a rerun command, +// Vitest runs that file with the command's `-t` pattern. +// Child runs fix the random lane's seed (a seed without a path selects no +// replay), so a child run records the same facts every time. type ReportedError = { name?: string; message?: string; cause?: ReportedError } type ReportedTest = { @@ -376,32 +550,38 @@ type ReportedFile = { errors: Array tests: Array } +type Report = { files: Array; stdout: string } +type Failure = { + kind: Kind | `other` + law?: string + cause?: `driver` + counterexample?: string + seed?: number + path?: string + rerun?: string +} type SuiteRun = { files: Record< string, { loaded: boolean tests: Array<{ name: string; state: string; failure?: Failure }> + reach: Record> } > log: Array } -type Failure = { - kind: Kind | `other` - law?: string - counterexample?: string - seed?: number - path?: string - rerun?: string -} type ProbeRun = { id: string - /** Each test's state in the probe run, by name without the property prefix. */ - states: Record - failure?: Failure - /** The probe target's failure in the first run. */ + /** The probed test: a lane for replay, a pin or history for rerun. */ target: string - original?: Failure + original: Failure + /** Each test's state in the probe run, by property id and name. */ + states: Array<[string, string, string]> + /** The probed test's failure in the probe run. */ + failure?: Failure + /** Histories the replayed random lane ran, in order. */ + ran?: Array } const here = dirname(fileURLToPath(import.meta.url)) @@ -414,8 +594,6 @@ let runCount = 0 function classify(error: ReportedError | undefined): Failure { if (!error) return { kind: `other` } - // fast-check reports a property failure and attaches the property's error - // as its cause; the cause carries the framework's own failure. const outer = error.message ?? `` // Only fast-check's wrapper carries the framework's failure as its cause; // the framework's own errors may carry a driver error as theirs. @@ -426,36 +604,45 @@ function classify(error: ReportedError | undefined): Failure { const path = /path: "([\d:]+)"/.exec(outer)?.[1] const counterexample = /Counterexample: (.*)/.exec(outer)?.[1] const rerun = /Rerun: -t ("(?:[^"\\]|\\.)*")/.exec(text)?.[1] - const facts = { + const law = /\(law: ([^)]+)\)/.exec(text)?.[1] + const cause = + inner.name === `OracleViolation` && + (inner.cause?.message ?? ``).includes(`synthetic driver failed`) + ? (`driver` as const) + : undefined + const kind: Kind | `other` = + inner.name === `OracleViolation` + ? `violation` + : /timed out in \d+ms/.test(text) || /timed out in \d+ms/.test(outer) + ? `timeout` + : text.includes(`is not a history the arbitrary can generate`) + ? `pin-membership` + : text.includes(`built a history the arbitrary cannot generate`) + ? `segment-membership` + : text.includes(`did not enter`) && text.includes(`: pin "`) + ? `pin-reach` + : text.includes(`did not enter`) && text.includes(`: segment "`) + ? `segment-reach` + : text.includes(`two runs of`) + ? `determinism` + : text.includes(`but the check passed it`) + ? `calibration-passed` + : text.includes(`but the check reported law`) + ? `calibration-wrong-law` + : text.includes(`found no history`) + ? `calibration-none` + : text.includes(`synthetic driver failed`) + ? `setup` + : `other` + return { + kind, + ...(law ? { law } : {}), + ...(cause ? { cause } : {}), ...(seed !== undefined ? { seed: Number(seed) } : {}), ...(path !== undefined ? { path } : {}), ...(counterexample !== undefined ? { counterexample } : {}), ...(rerun !== undefined ? { rerun: JSON.parse(rerun) as string } : {}), } - const law = /\(law: ([^)]+)\)/.exec(text)?.[1] - const kind: Kind | `other` = - inner.name === `OracleViolation` - ? `violation` - : text.includes(`is not a history the arbitrary can generate`) - ? `pin-membership` - : text.includes(`built a history the arbitrary cannot generate`) - ? `segment-membership` - : text.includes(`did not enter`) && text.includes(`: pin "`) - ? `pin-reach` - : text.includes(`did not enter`) && text.includes(`: segment "`) - ? `segment-reach` - : text.includes(`two runs of`) - ? `determinism` - : text.includes(`but the check passed it`) - ? `calibration-passed` - : text.includes(`but the check reported law`) - ? `calibration-wrong-law` - : text.includes(`found no history`) - ? `calibration-none` - : text.includes(`synthetic driver failed`) - ? `setup` - : `other` - return { kind, ...(law ? { law } : {}), ...facts } } function runVitest( @@ -463,7 +650,7 @@ function runVitest( files: Array, env: Record, args: Array = [], -) { +): Report { const report = join(directory, `report-${runCount++}.json`) const childEnv = Object.fromEntries( Object.entries(process.env).filter( @@ -485,8 +672,6 @@ function runVitest( ], { cwd: packageDirectory, - // A seed without a path selects no replay; it only fixes the random - // lane's seed, so a child run records the same facts every time. env: { ...childEnv, TANSTACK_DB_ORACLE_SEED: `20251103`, @@ -503,12 +688,33 @@ function runVitest( throw new Error( `the child Vitest run wrote no report:\n${run.stdout}\n${run.stderr}`, ) - return JSON.parse(readFileSync(report, `utf8`)) as { + // The framework prints reach counts straight to stdout, past Vitest's + // console capture, so they arrive in the child's own output. + const { files: reported } = JSON.parse(readFileSync(report, `utf8`)) as { files: Array } + return { files: reported, stdout: run.stdout } } const short = (id: string, name: string) => name.replace(`${id} > `, ``) +const readLog = (log: string): Array => + existsSync(log) + ? readFileSync(log, `utf8`) + .trim() + .split(`\n`) + .filter(Boolean) + .map((line) => JSON.parse(line) as LogEntry) + : [] +const statesIn = (report: Report, plan: Plan, files: Array) => + plan.specs.flatMap((spec, index) => + (report.files.find((file) => file.file === files[index])?.tests ?? []).map( + (test): [string, string, string] => [ + spec.id, + short(spec.id, test.name), + test.state, + ], + ), + ) async function driveSuite( plan: Plan, @@ -527,16 +733,17 @@ async function driveSuite( return file }) const report = runVitest(directory, files, { SELF_ORACLE_LOG: log }) - const logged: Array = existsSync(log) - ? readFileSync(log, `utf8`) - .trim() - .split(`\n`) - .filter(Boolean) - .map((line) => JSON.parse(line) as LogEntry) - : [] - const run: SuiteRun = { files: {}, log: logged } + const run: SuiteRun = { files: {}, log: readLog(log) } for (const [index, spec] of plan.specs.entries()) { const reported = report.files.find((file) => file.file === files[index]) + // A passing lane prints ` reach {...}`. + const reach: Record> = {} + for (const line of report.stdout.split(`\n`)) { + const match = + /^(\S+) (fixed seed -?\d+|random seed) reach (\{.*\})$/.exec(line) + if (match?.[1] === spec.id) + reach[match[2]!] = JSON.parse(match[3]!) as Record + } run.files[spec.id] = { loaded: reported !== undefined && reported.errors.length === 0, tests: (reported?.tests ?? []).map((test) => ({ @@ -546,68 +753,54 @@ async function driveSuite( ? { failure: classify(test.errors[0]) } : {}), })), + reach, } } observe(`run`, run) - // Replay: the first failing fixed lane that reported a seed and path. - for (const [index, spec] of plan.specs.entries()) { - const lane = run.files[spec.id]!.tests.find( - (test) => - test.name.startsWith(`fixed seed`) && - test.failure?.path !== undefined, - ) - if (!lane) continue - const replay = runVitest(directory, [files[index]!], { - TANSTACK_DB_ORACLE_SEED: String(lane.failure!.seed), - TANSTACK_DB_ORACLE_PATH: lane.failure!.path!, - TANSTACK_DB_ORACLE_PROPERTY: spec.id, - }).files[0] - const states = Object.fromEntries( - (replay?.tests ?? []).map((test) => [ - short(spec.id, test.name), - test.state, - ]), - ) - const random = replay?.tests.find((test) => - test.name.endsWith(`random seed`), - ) - const probe: ProbeRun = { - id: spec.id, - states, - target: lane.name, - original: lane.failure, - ...(random?.state === `failed` - ? { failure: classify(random.errors[0]) } - : {}), + for (const spec of plan.specs) { + for (const lane of run.files[spec.id]!.tests) { + if (!isLane(lane.name) || lane.failure?.path === undefined) continue + const replayLog = join(directory, `replay-${runCount}.jsonl`) + const replay = runVitest(directory, files, { + TANSTACK_DB_ORACLE_SEED: String(lane.failure.seed), + TANSTACK_DB_ORACLE_PATH: lane.failure.path, + TANSTACK_DB_ORACLE_PROPERTY: spec.id, + SELF_ORACLE_LOG: replayLog, + }) + const index = plan.specs.indexOf(spec) + const random = replay.files + .find((file) => file.file === files[index]) + ?.tests.find((test) => test.name.endsWith(`random seed`)) + observe(`replay`, { + id: spec.id, + target: lane.name, + original: lane.failure, + states: statesIn(replay, plan, files), + ...(random?.state === `failed` + ? { failure: classify(random.errors[0]) } + : {}), + ran: readLog(replayLog) + .filter((entry) => entry.test === `${spec.id} > random seed`) + .map((entry) => entry.history), + } satisfies ProbeRun) } - observe(`replay`, probe) - break - } - // Rerun: the first failing pin or enumerated history with a rerun command. - for (const [index, spec] of plan.specs.entries()) { const target = run.files[spec.id]!.tests.find( (test) => test.failure?.rerun !== undefined, ) if (!target) continue - const pattern = target.failure!.rerun! - const rerun = runVitest(directory, [files[index]!], {}, [`-t`, pattern]) - .files[0] - const states = Object.fromEntries( - (rerun?.tests ?? []).map((test) => [ - short(spec.id, test.name), - test.state, - ]), - ) - const ran = rerun?.tests.find((test) => test.state === `failed`) - const probe: ProbeRun = { + const index = plan.specs.indexOf(spec) + const rerun = runVitest(directory, [files[index]!], {}, [ + `-t`, + target.failure!.rerun!, + ]) + const ran = rerun.files[0]?.tests.find((test) => test.state === `failed`) + observe(`rerun`, { id: spec.id, - states, target: target.name, - original: target.failure, + original: target.failure!, + states: statesIn(rerun, plan, files), ...(ran ? { failure: classify(ran.errors[0]) } : {}), - } - observe(`rerun`, probe) - break + } satisfies ProbeRun) } } finally { rmSync(directory, { recursive: true, force: true }) @@ -617,13 +810,32 @@ async function driveSuite( // --------------------------------------------------------------------------- // Check // --------------------------------------------------------------------------- +// +// For each property: it loads exactly when the model says so; it registers +// exactly the owed tests; each test passes, or fails with the owed kind, law, +// and cause; a lane's counterexample still violates the owed law; a passing +// determinism test ran every history it owes; and each passing lane printed +// the owed reach counts. Then the probes: the replays and reruns the model +// owes all happened; in each, every other test stood aside; the probed test +// failed again the same way; and a replay started from the counterexample +// itself, as a direct replay must, and reported the same one. const lawOfTest = (name: string) => name.startsWith(`calibration:`) ? `calibration` : name === `determinism` ? `determinism` - : `verdict` + : isLane(name) + ? `shrink` + : `verdict` +const lawOfKind = (kind: string, name: string) => + kind === `timeout` + ? `timeout` + : kind.endsWith(`membership`) + ? `membership` + : kind.endsWith(`reach`) + ? `reach` + : lawOfTest(name) function checkSuite( plan: Plan, @@ -633,6 +845,12 @@ function checkSuite( const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as SuiteRun | undefined if (!run) return violation(`run`, `the driver recorded no run`, `trace`) + const probes = trace + .filter(({ checkpoint }) => checkpoint !== `run`) + .map(({ checkpoint, value }) => ({ + checkpoint, + probe: value as ProbeRun, + })) for (const spec of plan.specs) { const owed = expected.get(spec.id)! const seen = run.files[spec.id]! @@ -663,17 +881,18 @@ function checkSuite( test.state === `passed` ? `pass` : (failure?.kind ?? `other`) if ( observedKind !== outcome.kind || - (outcome.law !== undefined && failure?.law !== outcome.law) + (outcome.law !== undefined && failure?.law !== outcome.law) || + (!isLane(test.name) && failure?.cause !== outcome.cause) ) return violation( `run`, `${spec.id} "${test.name}" ${test.state} as ${JSON.stringify(failure ?? null)}, owed ${JSON.stringify(outcome)}`, - test.name.startsWith(`fixed seed`) || test.name === `random seed` - ? `shrink` - : lawOfTest(test.name), + lawOfKind( + outcome.kind === `pass` ? observedKind : outcome.kind, + test.name, + ), { actual: failure ?? { kind: `pass` }, expected: outcome }, ) - // A passing determinism test ran every history it owes. if ( test.name === `determinism` && test.state === `passed` && @@ -690,7 +909,6 @@ function checkSuite( `determinism`, ) } - // A lane's counterexample keeps the first failure's law. if (failure?.counterexample && outcome.law) { const shrunk = JSON.parse(failure.counterexample) as [SyntheticHistory] if (verdictOf(shrunk[0]).law !== outcome.law) @@ -701,27 +919,60 @@ function checkSuite( ) } } + for (const [lane, counts] of owed.reach) + if (JSON.stringify(seen.reach[lane]) !== JSON.stringify(counts)) + return violation( + `run`, + `${spec.id} "${lane}" printed reach ${JSON.stringify(seen.reach[lane] ?? null)}, owed ${JSON.stringify(counts)}`, + `reach`, + { actual: seen.reach[lane], expected: counts }, + ) + for (const lane of owed.replays) + if ( + !probes.some( + ({ checkpoint, probe }) => + checkpoint === `replay` && + probe.id === spec.id && + probe.target === lane, + ) + ) + return violation( + `replay`, + `${spec.id} "${lane}" failed but was never replayed`, + `replay`, + ) + if ( + owed.rerun && + !probes.some( + ({ checkpoint, probe }) => + checkpoint === `rerun` && probe.id === spec.id, + ) + ) + return violation( + `rerun`, + `${spec.id} "${owed.rerun}" printed no rerun command`, + `rerun`, + ) } - for (const { checkpoint, value } of trace) { - if (checkpoint !== `replay` && checkpoint !== `rerun`) continue - const probe = value as ProbeRun - const others = Object.entries(probe.states).filter(([name]) => - checkpoint === `replay` ? name !== `random seed` : name !== probe.target, + for (const { checkpoint, probe } of probes) { + const probed = checkpoint === `replay` ? `random seed` : probe.target + const others = probe.states.filter( + ([id, name]) => !(id === probe.id && name === probed), ) - if (others.some(([, state]) => state !== `skipped`)) + if (others.some(([, , state]) => state !== `skipped`)) return violation( checkpoint, - `${probe.id}: other tests ran: ${JSON.stringify(Object.fromEntries(others))}`, + `${probe.id} ${checkpoint} of "${probe.target}": other tests ran: ${JSON.stringify(others.filter(([, , state]) => state !== `skipped`))}`, checkpoint, ) if (!probe.failure) return violation( checkpoint, - `${probe.id}: "${checkpoint === `replay` ? `random seed` : probe.target}" did not fail again: ${JSON.stringify(probe.states)}`, + `${probe.id} ${checkpoint} of "${probe.target}": "${probed}" did not fail again`, checkpoint, ) if ( - probe.failure.kind !== probe.original?.kind || + probe.failure.kind !== probe.original.kind || probe.failure.law !== probe.original.law ) return violation( @@ -729,13 +980,22 @@ function checkSuite( `${probe.id}: reproduced ${JSON.stringify(probe.failure)}, first failure ${JSON.stringify(probe.original)}`, checkpoint, ) - if ( - checkpoint === `replay` && - probe.failure.counterexample !== probe.original.counterexample - ) + if (checkpoint !== `replay`) continue + if (probe.failure.counterexample !== probe.original.counterexample) return violation( checkpoint, - `${probe.id}: replay counterexample ${probe.failure.counterexample}, fixed lane ${probe.original.counterexample}`, + `${probe.id}: replay counterexample ${probe.failure.counterexample}, "${probe.target}" ${probe.original.counterexample}`, + `replay`, + ) + // fast-check walks the recorded path through the shrink tree without + // running the property, so a direct replay runs the counterexample first. + const counterexample = ( + JSON.parse(probe.original.counterexample!) as [SyntheticHistory] + )[0] + if (JSON.stringify(probe.ran?.[0]) !== JSON.stringify(counterexample)) + return violation( + checkpoint, + `${probe.id}: the replay first ran ${JSON.stringify(probe.ran?.[0] ?? null)}, not the counterexample ${JSON.stringify(counterexample)}`, `replay`, ) } @@ -745,6 +1005,9 @@ function checkSuite( // --------------------------------------------------------------------------- // Property // --------------------------------------------------------------------------- +// +// Pins replay the regressions the framework has had and witness every +// framework state the laws are about; each names the state it witnesses. const generatedDraft = (overrides: Partial): Draft => ({ source: { kind: `generated`, runs: 2, seed: 7 }, @@ -752,132 +1015,148 @@ const generatedDraft = (overrides: Partial): Draft => ({ segments: [], pins: [], nondeterministic: false, + reachVia: `reached`, + timing: `normal`, calibrations: [], ...overrides, }) +const pin = ( + name: string, + enters: Array<(typeof frameworkStates)[number]>, + ...drafts: Array +) => ({ name, enters, history: { drafts } }) +const at = (history: SyntheticHistory) => ({ history, enters: null }) oracleProperty({ id: `self-oracle.framework`, law: { - statement: `oracleProperty registers, fails, shrinks, replays, reruns, calibrates, and checks determinism and reach as its contract states.`, - authority: `packages/db/tests/oracle-property.ts header; oracle rubric ORC-006, ORC-007, ORC-015`, - limits: `Synthetic properties with histories of one to three integers, one or two per run; real Vitest child processes.`, + statement: `oracleProperty registers, fails, shrinks, replays, reruns, calibrates, times out, and checks determinism, membership, and reach as its contract states.`, + authority: `packages/db/tests/oracle-property.ts header; maintainer decisions of 2026-11-03; oracle rubric ORC-006, ORC-007, ORC-015`, + limits: `Synthetic properties with histories of one to three integers, one or two per run; real Vitest child processes; one rerun probe per property.`, owner: `oracleProperty framework`, }, histories: { arbitrary: suite, states: [], + measured: frameworkStates, runs: 3, fixedSeeds: [1], pins: [ - { - name: `a rerun command escapes a label with |`, - history: { - drafts: [ - { - ...generatedDraft({}), - source: { kind: `enumerated`, histories: [[3, 1]] }, - }, - ], - }, - }, - { - name: `a driver error after a violation reports the violation`, - history: { - drafts: [ - generatedDraft({ pins: [{ history: [3, 6], enters: null }] }), - ], - }, - }, - { - name: `shrinking keeps the first failure's law`, - history: { - drafts: [ - generatedDraft({ segments: [{ prefix: [4, 3], enters: [] }] }), - ], - }, - }, - { - name: `a replayed fixed-lane failure reproduces in the random lane`, - history: { - drafts: [ - generatedDraft({ - segments: [{ prefix: [1, 3], enters: [`saw-1`] }], - }), - ], - }, - }, - { - name: `shrinking keeps a setup error a setup error`, - history: { - drafts: [generatedDraft({ segments: [{ prefix: [6], enters: [] }] })], - }, - }, - { - name: `the determinism test rejects a nondeterministic driver`, - history: { - drafts: [ - generatedDraft({ - nondeterministic: true, - pins: [{ history: [0], enters: null }], - }), - ], - }, - }, - { - name: `a required state needs a segment`, - history: { - drafts: [ - generatedDraft({ - statesMode: `extra`, - segments: [{ prefix: [0], enters: [`saw-0`] }], - }), - ], - }, - }, - { - name: `a calibration skips a candidate whose clean trace fails`, - history: { - drafts: [ - generatedDraft({ - pins: [ - { history: [3], enters: null }, - { history: [0], enters: null }, - ], - calibrations: [`right`], - }), - ], - }, - }, - { - name: `a law violation outranks a missed segment state`, - history: { - drafts: [ - generatedDraft({ - segments: [{ prefix: [3, 0, 0], enters: [`saw-1`] }], - }), - ], - }, - }, - { - name: `the determinism test also samples generated histories`, - history: { drafts: [generatedDraft({ nondeterministic: true })] }, - }, - { - name: `calibrations need the named law`, - history: { - drafts: [ - generatedDraft({ - pins: [{ history: [0], enters: null }], - calibrations: [`right`, `wrong-law`, `no-op`], - }), + pin(`a rerun command escapes a label with |`, [`rerun-probe`], { + ...generatedDraft({}), + source: { + kind: `enumerated`, + histories: [ + [3, 1], + [3, 2], ], }, - }, + }), + pin( + `a driver error after a violation is the violation's cause`, + [`cause-attached`], + generatedDraft({ pins: [at([3, 6])] }), + ), + pin( + `shrinking keeps the first failure's law`, + [`lane-violation`, `replay-probe`], + generatedDraft({ segments: [{ prefix: [4, 3], enters: [] }] }), + ), + pin( + `a replayed lane failure reproduces its path`, + [`replay-probe`], + generatedDraft({ segments: [{ prefix: [1, 3], enters: [`saw-1`] }] }), + ), + pin( + `shrinking keeps a setup error a setup error`, + [`lane-setup-failure`], + generatedDraft({ segments: [{ prefix: [6], enters: [] }] }), + ), + pin( + `the determinism test rejects a nondeterministic driver`, + [`determinism-caught`], + generatedDraft({ nondeterministic: true, pins: [at([0])] }), + ), + pin( + `the determinism test also samples generated histories`, + [`determinism-caught`], + generatedDraft({ nondeterministic: true }), + ), + pin( + `a required state needs a segment`, + [`registration-error`], + generatedDraft({ + statesMode: `extra`, + segments: [{ prefix: [0], enters: [`saw-0`] }], + }), + ), + pin( + `a calibration skips a candidate whose clean trace fails`, + [`calibration-skipped-candidate`, `calibration-applied`], + generatedDraft({ pins: [at([3]), at([0])], calibrations: [`right`] }), + ), + pin( + `calibrations need the named law`, + [`calibration-applied`, `calibration-rejected`], + generatedDraft({ + pins: [at([0])], + calibrations: [`right`, `wrong-law`, `no-op`], + }), + ), + pin( + `a hostile driver calibration`, + [`calibration-applied`], + generatedDraft({ pins: [at([0])], calibrations: [`driver`] }), + ), + pin( + `a law violation outranks a missed segment state`, + [`lane-violation`], + generatedDraft({ + segments: [{ prefix: [3, 0, 0], enters: [`saw-1`] }], + }), + ), + pin( + `a segment must enter the states it names`, + [`segment-reach-failure`], + generatedDraft({ + segments: [{ prefix: [0, 0, 0], enters: [`saw-1`] }], + }), + ), + pin( + `a pin must enter the states it names`, + [`pin-reach-failure`], + generatedDraft({ pins: [{ history: [0], enters: [`saw-1`] }] }), + ), + pin( + `a pin outside the language fails`, + [`pin-membership-failure`], + generatedDraft({ pins: [at([5])] }), + ), + pin( + `a segment outside the language fails`, + [`segment-membership-failure`], + generatedDraft({ segments: [{ prefix: [5], enters: [] }] }), + ), + pin( + `reach the driver records counts like reach the property names`, + [`observed-reach`], + generatedDraft({ + reachVia: `observe`, + segments: [{ prefix: [0, 0, 0], enters: [`saw-0`] }], + }), + ), + pin( + `a property timeout limits every test it registers`, + [`timeout`], + generatedDraft({ timing: `slow`, pins: [at([0])] }), + ), ], }, resolve: resolveSuite, model: suiteModel, + reached: frameworkReach, driver: driveSuite, check: checkSuite, + // Each history spawns one to five child Vitest runs. + timeout: 600_000, }) diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 7ddc7b7a5a..7633a19eaa 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -17,11 +17,11 @@ * with a run budget, fixed seeds, required segments, and pins (below). * - `model` gives the expected result of a history and its recorded trace, * without production. A model that answers from the history alone ignores - * the trace; a model of an environment the driver observes reads it - * (oracle-framework design, R7). + * the trace; a model of an environment the driver observes reads it, and + * reads only the inputs the driver recorded, never what production showed. * - `driver` runs one history through production and records public * observations with `observe(checkpoint, value)`. It returns nothing, so it - * cannot decide a verdict (oracle-framework design, decision D10). A + * cannot decide a verdict. A * production error that the law cares about is an observation the driver * records. An error that escapes the driver is a setup error, unless the * trace recorded before it already violates the law: then the violation is @@ -54,7 +54,7 @@ * from a generated history, and names those states. Every lane runs every * segment once as an example, and a segment run that does not enter a named * state fails. Only segments make reach required: a state that random - * histories reach is measured, never gated (design, D21). So every state in + * histories reach is measured, never gated. So every state in * `states` must be named by a segment, and a state the property only * measures is declared in `measured` instead (ORC-015). Half of a lane's * generated histories also go through a segment, so the histories after @@ -63,7 +63,8 @@ * states it `enters`, which its run must reach. A pin and a segment's * history must be histories the arbitrary could generate * (`canShrinkWithoutContext`), so they shrink like generated ones and cannot - * cover a dimension that the grammar lacks (design, R8). + * cover a dimension that the grammar lacks; such a dimension belongs in the + * grammar, or in the property's stated limits. * - **Resolution.** A history can name its actions indirectly, such as "settle * the second active transaction". `resolve(history)` turns it into the plan * of concrete actions that the driver runs and that the model and check read. @@ -91,6 +92,10 @@ * Within one history a driver error decides first, then a law violation, * then a missed segment state. * + * `oracle-property-self-oracle.test.ts` checks these promises the way an + * oracle checks production: it runs synthetic properties under real Vitest + * and compares what Vitest reports with an independent model. + * * The `oracle/no-unit-test` lint rule treats an `oracleProperty` call as an * oracle test whose parts are its arguments, when its options are an object * literal with a `check`. The property is its own declaration. @@ -149,7 +154,7 @@ export function violation( } /** - * History checking against allowed cuts (oracle-framework design, form F-E). + * History checking against allowed cuts. * A model can often name, in order, the states an observer may see after a * step without fixing which of them it sees: a publication may skip a state * but never go back to an earlier one. `forwardCut` returns the index of the @@ -169,7 +174,7 @@ export function forwardCut( } /** - * Logical time for drivers (oracle-framework design, form F-F). A driver that + * Logical time for drivers. A driver that * must not hang when production never settles a promise waits with * `withinTurns(promise, turns)`: it resolves `true` as soon as the promise * settles, at the same microtask an `await` would, or `false` after `turns` From 4aaea80a4e642ff4069f9ac0b85c4adb0fb12970 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 09:28:55 -0700 Subject: [PATCH 012/168] test(oracles): run the oracleProperty self-oracle outside db's default tests The self-oracle spawns real Vitest runs and takes about two minutes, which belongs in the oracle framework's own CI, not db's. The package config excludes it; `pnpm test:oracle-framework` runs it with a config that replaces that exclusion (mergeConfig concatenates arrays). Its child runs use the default config on temporary spec files, so they are unaffected. Co-authored-by: Isaac --- packages/db/package.json | 1 + packages/db/vite.config.ts | 8 +++++++- packages/db/vitest.oracle-framework.config.mjs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/db/vitest.oracle-framework.config.mjs diff --git a/packages/db/package.json b/packages/db/package.json index d10eaaebda..238919376d 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -24,6 +24,7 @@ "test:dist": "vitest run --config vitest.dist.config.ts", "test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts", "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/collection-gc-retention-oracle.property.test.ts tests/collection-gc-mutation-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-history-oracle.property.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-cache-options-oracle.test.ts tests/mutation-confirmation-oracle.test.ts tests/mutation-handler-types-oracle.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-hydration-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/virtual-row-fields-input-oracle.test.ts tests/query/virtual-row-fields-effect-oracle.test.ts tests/query/virtual-row-fields-types-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/load-subset-joint-replay-oracle.property.test.ts", + "test:oracle-framework": "vitest --run --config vitest.oracle-framework.config.mjs", "bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run", "test:indexed-db:package": "pnpm --filter @tanstack/db... build && vitest run --config vitest.indexed-db.package.config.ts", "test:collection-gc:browser": "playwright test --config playwright.collection-gc.config.ts", diff --git a/packages/db/vite.config.ts b/packages/db/vite.config.ts index 6b2f2fd929..48e799331c 100644 --- a/packages/db/vite.config.ts +++ b/packages/db/vite.config.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { transform } from 'esbuild' -import { defineConfig, mergeConfig } from 'vitest/config' +import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import { commonJsDeclarations } from '../../scripts/commonjs-declarations.mjs' import packageJson from './package.json' @@ -49,6 +49,12 @@ const config = defineConfig({ }, typecheck: { enabled: true, tsconfig: `./tsconfig.test.json` }, setupFiles: [`./tests/test-setup.ts`], + // The oracleProperty self-oracle spawns real Vitest runs and takes about + // two minutes; `pnpm test:oracle-framework` runs it on its own. + exclude: [ + ...configDefaults.exclude, + `**/oracle-property-self-oracle.test.ts`, + ], }, }) diff --git a/packages/db/vitest.oracle-framework.config.mjs b/packages/db/vitest.oracle-framework.config.mjs new file mode 100644 index 0000000000..bc40177300 --- /dev/null +++ b/packages/db/vitest.oracle-framework.config.mjs @@ -0,0 +1,17 @@ +import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' +import base from './vite.config' + +// The oracleProperty self-oracle, outside the package's default test run. It +// runs synthetic properties in child Vitest processes, which use the default +// config. +const config = mergeConfig( + base, + defineConfig({ + test: { coverage: { enabled: false }, typecheck: { enabled: false } }, + }), +) +// mergeConfig concatenates arrays, so the default run's exclusion of this +// file is replaced, not extended. +config.test.include = [`**/oracle-property-self-oracle.test.ts`] +config.test.exclude = [...configDefaults.exclude] +export default config From 352213da0d502022003b4db42ed61d7fbcc2daef Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 09:42:44 -0700 Subject: [PATCH 013/168] test(oracles): move framework result-shape conformance onto oracleProperty The result-shape law runs through seven framework drivers in six packages. It is now one oracleProperty definition registered by each framework's conformance suite with its own driver, as framework..result-shape. - The driver mounts the controllable binding and records raw public reads: the projection's shape, selected rows, and keyed state at each settled cut, and the shape, compiled mode, and observation count at each native cut. It no longer asserts. - The model owes each checkpoint's mode and rows, and names the twelve required states; two segments enter all of them on every lane, and the 26 former fixed examples are pins. - The check judges the six existing laws by name. The previous-mode projection test is a calibration, and stale settled rows is a second one. - The replay manifest names the random lane (`result-shape > random seed`). Evidence: all seven lanes pass (React, React with a dependency list, Preact, Vue, Solid, Angular, Svelte; 33 tests each). Reintroducing the historical Angular defect (the previous query's projection at the signal read) fails 32 tests at law `current query projection`; the old oracle failed 2. A production mutant that lets a list and findOne share a moved window (both guards removed) fails 36 React tests; removing either guard alone survives both oracles, because the other still refuses the move. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 7 +- .../tests/conformance/result-shape-oracle.ts | 647 +++++++++++------- packages/db/tests/oracle-replay-manifest.ts | 2 +- 3 files changed, 412 insertions(+), 244 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index b99503bd54..3591e8493b 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -6791,7 +6791,12 @@ rows and both data-first/Collection-first reads are checked at each driver's native parameter cut, then after settlement. Finite singleton queries retain identical offset/limit across projection changes, including positive offsets. Native cuts require raw shape; settled cuts also compare selected values and -keyed state. This does not promise synchronous row freshness or identical host +keyed state. It is one `oracleProperty` definition, registered by each +framework's conformance suite with that framework's driver as +`framework..result-shape`; two segments enter its twelve required +states on every lane, the former fixed examples are pins, and the previous-mode +projection and stale rows are calibrations. Reintroducing the Angular defect +below fails 32 of its tests, each at law `current query projection`. This does not promise synchronous row freshness or identical host scheduling. Disabled inputs, errors, supplied Collections, DbClient and broad host/AOT behavior remain outside this lane. diff --git a/packages/db/tests/conformance/result-shape-oracle.ts b/packages/db/tests/conformance/result-shape-oracle.ts index 278caa301a..b9d8891a3e 100644 --- a/packages/db/tests/conformance/result-shape-oracle.ts +++ b/packages/db/tests/conformance/result-shape-oracle.ts @@ -10,24 +10,31 @@ * This does not promise synchronous row freshness or identical host scheduling. * Disabled inputs, errors, supplied Collections and DbClient remain with their * existing conformance owners; this grammar uses enabled callback queries. + * + * The property is defined once here and registered once per framework, by + * that framework's conformance suite, with its own driver: one law, model, + * history grammar, and check, judged through each framework's binding. */ import fc from 'fast-check' -import { describe, expect, it } from 'vitest' -import { declareOracle } from '../oracle-declaration' -import { createOracleReach } from '../oracle-reach' -import { - atOracleCheckpoint, - createOracleFailureFidelity, -} from '../oracle-failure-fidelity' -import { - oraclePropertyOptions, - oracleReplayTest, - oracleRuns, -} from '../oracle-config' -import { expectKeyedRows, expectOrderedRows, selectedRow } from './result-laws' +import { oracleRuns } from '../oracle-config' +import { oracleProperty, pass, violation } from '../oracle-property' +import { selectedRowKeys } from './result-laws' import { ScenarioLifetime } from './scenario-lifetime' +import type { Observation } from '../oracle-property' import type { ControllableHandle, LiveQueryDriver } from './contract' +// --------------------------------------------------------------------------- +// Histories +// --------------------------------------------------------------------------- +// +// Source cardinality makes absent, single and multiple rows distinguishable. +// Mode changes projection; order changes which row findOne selects. The tail +// permits repeated modes and further returns. No disabled or invalid query is +// generated. Removing either direction or the native cut loses the reported +// stale-projection witness, even when all settled comparisons remain green. +// A finite singleton list and findOne can have identical offset/limit while +// their projections differ. That axis rejects window-only shape invalidation. + type Mode = `list` | `single` type ReadOrder = `data first` | `Collection first` type ShapeRow = { id: string; value: number } @@ -43,14 +50,7 @@ type ShapeHistory = { tail: Array } -// Source cardinality makes absent, single and multiple rows distinguishable. -// Mode changes projection; order changes which row findOne selects. The tail -// permits repeated modes and further returns. No disabled or invalid query is -// generated. Removing either direction or the native cut loses the reported -// stale-projection witness, even when all settled comparisons remain green. -// A finite singleton list and findOne can have identical offset/limit while -// their projections differ. That axis rejects window-only shape invalidation. -const shapeHistory = fc.record({ +const shapeHistory: fc.Arbitrary = fc.record({ initial: fc.constantFrom(`list`, `single`), order: fc.constantFrom(`data first`, `Collection first`), count: fc.integer({ min: 0, max: 4 }), @@ -61,55 +61,62 @@ const shapeHistory = fc.record({ offset: fc.integer({ min: 0, max: 3 }), tail: fc.array(fc.constantFrom(`list`, `single`), { maxLength: 4 }), }) -const examples: Array<[ShapeHistory]> = [3, 1, 0].flatMap((count) => + +// The regressions: every count, initial mode, read order, and window choice, +// plus the adjacent finite offset for the three-row windows. +const regressions: Array = [3, 1, 0].flatMap((count) => ([`list`, `single`] as const).flatMap((initial) => ([`data first`, `Collection first`] as const).flatMap((order) => - [false, true].map((windowed): [ShapeHistory] => [ - { - initial, - order, - count, - base: 0, - spacing: 1, - descending: false, - windowed, - offset: 0, - tail: [], - }, - ]), + [false, true].map((windowed): ShapeHistory => ({ + initial, + order, + count, + base: 0, + spacing: 1, + descending: false, + windowed, + offset: 0, + tail: [], + })), ), ), ) -// Pin the adjacent finite offset as well as zero. Each input still goes through -// the same history grammar, independent model, real driver and comparison. -examples.push( - ...examples - .filter(([history]) => history.count === 3 && history.windowed) - .map(([history]): [ShapeHistory] => [{ ...history, offset: 1 }]), +regressions.push( + ...regressions + .filter((history) => history.count === 3 && history.windowed) + .map((history) => ({ ...history, offset: 1 })), ) +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- +// // The model recomputes from authored source rows and mode. It knows nothing -// about Collection classifiers, observer snapshots, or adapter caches. -function shapeRows( - rows: ReadonlyArray, - mode: Mode, - descending: boolean, - windowed = false, - offset = 0, -) { - const ordered = [...rows].sort((a, b) => - descending ? b.value - a.value : a.value - b.value, +// about Collection classifiers, observer snapshots, or adapter caches. A +// history mounts in its initial mode, switches to the opposite mode twice, +// returns, then follows its tail; each switch has a native cut, where only +// the shape is owed, and a settled cut, where the rows are owed too. + +function sourceRows(history: ShapeHistory): Array { + return Array.from({ length: history.count }, (_, index) => ({ + id: String(index), + value: history.base + index * history.spacing, + })) +} + +function shapeRows(history: ShapeHistory, mode: Mode): Array { + const ordered = [...sourceRows(history)].sort((a, b) => + history.descending ? b.value - a.value : a.value - b.value, ) - const selected = windowed ? ordered.slice(offset, offset + 1) : ordered + const selected = history.windowed + ? ordered.slice(history.offset, history.offset + 1) + : ordered return mode === `single` ? selected.slice(0, 1) : selected } -function checkResultShape(actual: unknown, mode: Mode): void { - expect(Array.isArray(actual), `raw ${mode} projection`).toBe(mode === `list`) - if (mode === `single` && actual !== undefined) { - expect(actual).not.toBeNull() - expect(typeof actual).toBe(`object`) - } +function modes(history: ShapeHistory): Array { + const opposite: Mode = history.initial === `list` ? `single` : `list` + return [history.initial, opposite, opposite, history.initial, ...history.tail] } const states = [ @@ -127,204 +134,360 @@ const states = [ `nonzero finite window projection change`, ] as const -async function checkShapeHistory( - driver: LiveQueryDriver, - history: ShapeHistory, - reach: ReturnType>, -) { - const { initial, order } = history - const lifetime = new ScenarioLifetime() - let handle: ControllableHandle | undefined - lifetime.defer(() => handle?.unmount()) - await lifetime.run(async () => { - const rows = Array.from({ length: history.count }, (_, index) => ({ - id: String(index), - value: history.base + index * history.spacing, - })) - const source = driver.makeSource(rows.map((row) => ({ ...row }))) - lifetime.defer(() => source.collection.cleanup({ dependents: true })) - let compiledMode: Mode | undefined - handle = driver.mountControllable((q, mode) => { - compiledMode = mode - let query = q - .from({ row: source.collection }) - .orderBy( - ({ row }: { row: ShapeRow }) => row.value, - history.descending ? `desc` : `asc`, - ) - if (history.windowed) query = query.offset(history.offset).limit(1) - return mode === `single` ? query.findOne() : query - }, initial) - const mounted = handle - const failures: Array = [] - const compare = (law: string, checkpoint: string, check: () => void) => { - try { - atOracleCheckpoint(law, checkpoint, check) - } catch (error) { - // Keep both directions and the later settled evidence even when the - // native shape is wrong. No failed comparison is discarded. - failures.push(error) - } +type Owed = { mode: Mode; rows?: Array } + +function shapeModel(history: ShapeHistory) { + const sequence = modes(history) + const owed = new Map() + const reached = new Set() + const settled = (checkpoint: string, mode: Mode) => { + owed.set(checkpoint, { mode, rows: shapeRows(history, mode) }) + if (history.count === 0) reached.add(`empty source settled`) + if (history.count > 1) reached.add(`multiple source rows settled`) + } + settled(`initial settled`, history.initial) + for (const [index, mode] of sequence.slice(1).entries()) { + const previous = sequence[index]! + const transition = `${previous} to ${mode}` + owed.set(`${index}: ${transition}; native`, { mode }) + reached.add(`${history.order} read`) + if (previous === mode) reached.add(`repeated mode at native cut`) + else { + reached.add(`${transition} at native cut`) + if (history.count > 0) reached.add(`nonempty ${transition} at native cut`) + if (history.windowed) reached.add(`same finite window projection change`) + if (history.windowed && history.offset > 0) + reached.add(`nonzero finite window projection change`) } - const settled = (mode: Mode, checkpoint: string) => { - const actual = mounted.current() - const expected = shapeRows( - rows, - mode, - history.descending, - history.windowed, - history.offset, - ) - compare(`current query projection`, checkpoint, () => - checkResultShape(actual.data, mode), - ) - compare(`settled selected rows`, checkpoint, () => { - if (mode === `list`) expectOrderedRows(actual.data, expected) - else if (expected.length === 0) expect(actual.data).toBeUndefined() - else { - checkResultShape(actual.data, mode) - expect(selectedRow(actual.data)).toStrictEqual(expected[0]) - } - }) - compare(`settled keyed rows`, checkpoint, () => - expectKeyedRows(actual.state, expected), + settled(`${index}: ${transition}; settled`, mode) + if (index === 2) reached.add(`returned mode settled`) + } + return { owed, reached } +} + +// --------------------------------------------------------------------------- +// Production driver, for one framework's binding +// --------------------------------------------------------------------------- +// +// The driver mounts the query through the framework's controllable binding, +// flushes, and records the settled projection: its shape, its selected rows, +// and the keyed state. For each switch it reads the binding inside the +// framework's native observation callback, in the history's read order, and +// records the shape and the mode the query callback last compiled; after the +// switch settles it records how many native observations ran and the settled +// projection. Rows are recorded as plain selected values; the driver +// decides nothing. + +type Shape = `array` | `undefined` | `null` | `object` | `other` +const shapeOf = (value: unknown): Shape => + Array.isArray(value) + ? `array` + : value === undefined + ? `undefined` + : value === null + ? `null` + : typeof value === `object` + ? `object` + : `other` +const plainRow = (row: unknown): unknown => + row !== null && typeof row === `object` + ? Object.fromEntries( + selectedRowKeys(row) + .filter((key) => typeof key === `string`) + .map((key) => [key, (row as Record)[key]]), ) - if (history.count === 0) reach.reach(`empty source settled`) - if (history.count > 1) reach.reach(`multiple source rows settled`) - } - try { + : row +const byId = (a: { id: unknown }, b: { id: unknown }) => + String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0 + +type Settled = { + shape: Shape + rows: Array + /** Keyed state as plain entries, or null when the binding exposes none. */ + state: Array<{ id: unknown; value: unknown }> | null +} +type Native = { + shape: Shape + compiledMode: Mode | undefined + readError?: string + observations: number +} + +function resultShapeDriver(driver: LiveQueryDriver) { + return async ( + history: ShapeHistory, + observe: (checkpoint: string, value: unknown) => void, + ) => { + const lifetime = new ScenarioLifetime() + let handle: ControllableHandle | undefined + lifetime.defer(() => handle?.unmount()) + await lifetime.run(async () => { + const source = driver.makeSource(sourceRows(history)) + lifetime.defer(() => source.collection.cleanup({ dependents: true })) + let compiledMode: Mode | undefined + handle = driver.mountControllable((q, mode) => { + compiledMode = mode + let query = q + .from({ row: source.collection }) + .orderBy( + ({ row }: { row: ShapeRow }) => row.value, + history.descending ? `desc` : `asc`, + ) + if (history.windowed) query = query.offset(history.offset).limit(1) + return mode === `single` ? query.findOne() : query + }, history.initial) + const mounted = handle + const settled = (checkpoint: string) => { + const { data, state } = mounted.current() + const shape = shapeOf(data) + observe(checkpoint, { + shape, + rows: + shape === `array` + ? (data as Array).map(plainRow) + : shape === `object` + ? [plainRow(data)] + : [], + state: state + ? [...(state as ReadonlyMap).entries()] + .map(([id, value]) => ({ id, value: plainRow(value) })) + .sort(byId) + : null, + } satisfies Settled) + } await mounted.flush() - settled(initial, `initial settled`) - const opposite: Mode = initial === `list` ? `single` : `list` - let previous = initial - for (const [index, mode] of [ - opposite, - opposite, - initial, - ...history.tail, - ].entries()) { + settled(`initial settled`) + const sequence = modes(history) + for (const [index, mode] of sequence.slice(1).entries()) { + const transition = `${sequence[index]} to ${mode}` + let native: Omit | undefined let observations = 0 - const transition = `${previous} to ${mode}` - const checkpoint = `${transition}; ${driver.parameterObservationCut}; ${order}` await mounted.setParam(mode, () => { observations++ - reach.reach(`${order} read`) - // These are real public reads. Their order is part of the history; - // neither a snapshot normalization nor a later flush can repair them. - const actual = atOracleCheckpoint( - `public result read`, - checkpoint, - () => { - if (order === `Collection first`) mounted.collection() - const result = mounted.current() - if (order === `data first`) mounted.collection() - return result - }, - ) - compare(`current input reached query callback`, checkpoint, () => - expect(compiledMode).toBe(mode), - ) - if (previous === mode) reach.reach(`repeated mode at native cut`) - else { - if (history.windowed) - reach.reach(`same finite window projection change`) - if (history.windowed && history.offset > 0) - reach.reach(`nonzero finite window projection change`) - reach.reach( - `${transition} at native cut` as (typeof states)[number], - ) - if (history.count > 0) - reach.reach( - `nonempty ${transition} at native cut` as (typeof states)[number], - ) + // These are real public reads; their order is part of the history. + try { + if (history.order === `Collection first`) mounted.collection() + const result = mounted.current() + if (history.order === `data first`) mounted.collection() + native = { shape: shapeOf(result.data), compiledMode } + } catch (error) { + native = { shape: `other`, compiledMode, readError: String(error) } } - compare(`current query projection`, checkpoint, () => - checkResultShape(actual.data, mode), - ) }) - compare(`native observation executed`, checkpoint, () => - expect(observations).toBe(1), - ) - settled(mode, `${transition}; settled`) - if (index === 2) reach.reach(`returned mode settled`) - previous = mode + observe(`${index}: ${transition}; native`, { + ...(native ?? { shape: `other`, compiledMode }), + observations, + } satisfies Native) + settled(`${index}: ${transition}; settled`) } - } catch (error) { - // A later driver/read interruption cannot replace an earlier mismatch. - failures.push(error) - } - if (failures.length) - throw new AggregateError(failures, failures.map(String).join(`\n`), { - cause: failures[0], - }) - }) + }) + } } -declareOracle({ - model: [shapeRows], - grammar: [shapeHistory], - check: [checkResultShape, checkShapeHistory], -}) +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- +// +// At a native cut: the public read did not throw, exactly one native +// observation ran, the query callback compiled the current mode, and the +// projection has the current mode's shape. At a settled cut: the shape again, +// the selected rows in order (a findOne row or undefined), and the keyed +// state as an unordered set. -export function registerResultShapeOracle(driver: LiveQueryDriver): void { - const id = driver.name.replace(/\W+/g, `-`).replace(/-$/, ``) - const property = `framework.${id}.result-shape` - describe(`${driver.name} result-shape conformance`, () => { - for (const campaign of [`fixed`, `random`] as const) { - const test = - campaign === `fixed` - ? oracleReplayTest(it) - : oracleReplayTest(it, property) - test( - `query projection histories (${campaign})`, - async () => { - const reach = createOracleReach(`${property}/${campaign}`, states, { - fixedHistories: examples.length, - }) - const fidelity = createOracleFailureFidelity() - let begun = 0 - try { - await fidelity.assert(() => - fc.assert( - fc.asyncProperty(shapeHistory, async (history) => { - reach.beginHistory({ fixed: begun++ < examples.length }) - await fidelity.run(history, () => - checkShapeHistory(driver, history, reach), - ) - }), - campaign === `fixed` - ? { - seed: 2316, - numRuns: oracleRuns(6) + examples.length, - examples, - } - : oraclePropertyOptions(6, property, { examples }), - ), - ) - reach.assertReached() - } finally { - console.info( - `shape reach ${property}/${campaign}`, - Object.fromEntries(reach.counts()), - ) - } - }, - oracleRuns(30_000), +const projects = (shape: Shape, mode: Mode) => + mode === `list` + ? shape === `array` + : shape === `undefined` || shape === `object` +const json = (value: unknown) => JSON.stringify(value) + +function checkResultShape( + _history: ShapeHistory, + trace: ReadonlyArray, + { owed }: ReturnType, +) { + for (const { checkpoint, value } of trace) { + const expected = owed.get(checkpoint) + if (!expected) + return violation( + checkpoint, + `the driver recorded a checkpoint the model does not owe`, + `trace`, ) + if (checkpoint.endsWith(`native`)) { + const native = value as Native + if (native.readError) + return violation( + checkpoint, + `the public read threw: ${native.readError}`, + `public result read`, + ) + if (native.observations !== 1) + return violation( + checkpoint, + `${native.observations} native observations ran`, + `native observation executed`, + ) + if (native.compiledMode !== expected.mode) + return violation( + checkpoint, + `the query callback last compiled ${native.compiledMode}`, + `current input reached query callback`, + ) + if (!projects(native.shape, expected.mode)) + return violation( + checkpoint, + `a ${expected.mode} binding projects ${native.shape}`, + `current query projection`, + { actual: native.shape, expected: expected.mode }, + ) + continue } + const seen = value as Settled + if (!projects(seen.shape, expected.mode)) + return violation( + checkpoint, + `a ${expected.mode} binding projects ${seen.shape}`, + `current query projection`, + { actual: seen.shape, expected: expected.mode }, + ) + const rows = expected.rows! + const selected = + expected.mode === `single` && rows.length === 0 + ? seen.shape === `undefined` + : json(seen.rows) === json(rows) + if (!selected) + return violation( + checkpoint, + `selected rows ${json(seen.rows)}, owed ${json(rows)}`, + `settled selected rows`, + { actual: seen.rows, expected: rows }, + ) + const keyed = rows.map((row) => ({ id: row.id, value: row })).sort(byId) + if (json(seen.state) !== json(keyed)) + return violation( + checkpoint, + `keyed state ${json(seen.state)}, owed ${json(keyed)}`, + `settled keyed rows`, + { actual: seen.state, expected: keyed }, + ) + } + for (const checkpoint of owed.keys()) + if (!trace.some((entry) => entry.checkpoint === checkpoint)) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}"`, + `trace`, + ) + return pass +} - // Calibrate the same raw-shape judgment with the previous mode's answer. - // Both wrong directions have identical selected rows and differ only in - // array-versus-row projection, which row normalization would erase. - it(`rejects the previous query projection in either direction`, () => { - const rows = [{ id: `0`, value: 0 }] - for (const mode of [`list`, `single`] as const) { - const expected = shapeRows(rows, mode, false) - const good = mode === `list` ? expected : expected[0] - checkResultShape(good, mode) - const wrong = mode === `list` ? expected[0] : expected - expect(() => checkResultShape(wrong, mode)).toThrow() - } - }) +// --------------------------------------------------------------------------- +// Definition and registration +// --------------------------------------------------------------------------- + +// Two segments enter every state the laws need, on every lane. +const resultShapeHistories = { + arbitrary: shapeHistory, + states, + runs: 6, + fixedSeeds: [2316], + segments: [ + { + name: `every switch over a three-row window`, + enters: states.filter( + (state) => + state !== `Collection first read` && state !== `empty source settled`, + ), + build: (generated: ShapeHistory): ShapeHistory => ({ + ...generated, + initial: `list`, + order: `data first`, + count: 3, + windowed: true, + offset: 1, + tail: [`list`], + }), + }, + { + name: `an empty source read Collection first`, + enters: [`Collection first read`, `empty source settled`], + build: (generated: ShapeHistory): ShapeHistory => ({ + ...generated, + order: `Collection first`, + count: 0, + }), + }, + ], + pins: regressions.map((history) => ({ + name: `${history.initial}, ${history.order}, ${history.count} rows${history.windowed ? `, window at ${history.offset}` : ``}`, + history, + })), +} + +// Wrong answers the check must reject: the previous mode's projection at a +// native cut, in either direction, and stale rows at a settled cut. +const resultShapeCalibrations = [ + { + name: `previous projection at the native cut`, + law: `current query projection`, + corrupt: (trace: ReadonlyArray) => { + const at = trace.findIndex(({ checkpoint }) => + checkpoint.endsWith(`native`), + ) + if (at < 0) return undefined + return trace.map((entry, index) => { + if (index !== at) return entry + const native = entry.value as Native + return { + ...entry, + value: { + ...native, + shape: native.shape === `array` ? `object` : `array`, + }, + } + }) + }, + }, + { + name: `stale rows at a settled cut`, + law: `settled selected rows`, + corrupt: (trace: ReadonlyArray) => { + const at = trace.findIndex( + ({ checkpoint, value }) => + checkpoint.endsWith(`settled`) && (value as Settled).rows.length > 0, + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { + ...entry, + value: { + ...(entry.value as Settled), + rows: [{ id: `stale`, value: 0 }], + }, + } + : entry, + ) + }, + }, +] + +export function registerResultShapeOracle(driver: LiveQueryDriver): void { + const id = driver.name.replace(/\W+/g, `-`).replace(/-$/, ``) + oracleProperty({ + id: `framework.${id}.result-shape`, + law: { + statement: `A binding projects data through its current authored query: a list exposes an array and findOne a row or undefined, at the native cut after a mode change and after it settles, where the selected rows and keyed state equal the source model.`, + authority: `packages/db/tests/conformance/contract.ts and each adapter's public result type`, + limits: `Enabled callback queries over one source of zero to four rows; no disabled inputs, errors, supplied Collections, or DbClient. No promise of synchronous row freshness or identical host scheduling.`, + owner: `Framework conformance`, + }, + histories: resultShapeHistories, + model: shapeModel, + reached: (_history, _trace, expected) => expected.reached, + driver: resultShapeDriver(driver), + check: checkResultShape, + calibrations: resultShapeCalibrations, + timeout: oracleRuns(30_000), }) } diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index a96f8ea054..8c9c807fce 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -1036,7 +1036,7 @@ function directReplayTestName(property: string): string | undefined { if (property.startsWith(`framework.`)) { switch (property.split(`.`).at(-1)) { case `result-shape`: - return `query projection histories \\(random\\)` + return `result-shape > random seed` case `window-history`: return `(?:direct|nested) window histories \\(fresh\\)` case `pending-window-history`: From 625f755b7a5080583e9b28276a2f4c9522d0f3e6 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 09:50:49 -0700 Subject: [PATCH 014/168] test(oracles): move the adapter handler-types oracle onto oracleProperty A type-level oracle in the core shape. Each of the 36 type shapes (adapter, key and scalar types, schema, Query row or envelope selection) resolves to a generated consumer program; the plan holds its source and each candidate declaration's position, never expected admission. The TypeScript compiler is the driver and records every diagnostic as plain data. The model owes the rejected candidates from each handler result's description; the check judges four named laws (candidate-diagnostics, admission, diagnostic-cause, distinct-diagnostics). The six compiler-observation controls are now calibrations, each required to fail its named law. Evidence: 43 of 43 pass. Typing the Query `onUpdate` handler as Promise fails the 24 Query shapes at `diagnostic-cause`; the old oracle failed the same shapes and, with them, its six controls, which required the first shape to pass. The new calibrations skip failing candidates, so they stay meaningful under a production defect. Limit: the old per-state reach minimum over the enumeration is not asserted; oracleProperty declares no reach for enumerated sources yet. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 7 +- .../mutation-handler-types-oracle.test.ts | 488 +++++++++++------- 2 files changed, 311 insertions(+), 184 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3591e8493b..0486e0a0a5 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -6408,7 +6408,12 @@ explicit factory and inferred factory entry points, with/without schema and Query rows/envelope select.36 consumers contain4,212 cells; all702 declared states reach six histories. Promise accepts void/undefined and rejection, including a synchronous never result; fulfilled values and synchronous void -are rejected. Six controls calibrate missing/duplicate/misattributed diagnostics. +are rejected. It is the `oracleProperty` `adapter.handler-types`: each shape +resolves to a generated consumer program, the TypeScript compiler is the +driver, and six calibrations (missing, duplicate, setup-error, rejected-void, +unrelated same-code, and body-local Promise diagnostics) must each fail +their named law. Its enumerated reach is no longer asserted per state (open: +enumerated sources declare no reach yet). All42 tests pass on unchanged858b174a6. Independent compiler probes reject unrelated same-code and body-local Promise errors substituted for handler errors. Source imports do not establish packed declarations or npm consumers; diff --git a/packages/db/tests/mutation-handler-types-oracle.test.ts b/packages/db/tests/mutation-handler-types-oracle.test.ts index 84b18b6558..143312c178 100644 --- a/packages/db/tests/mutation-handler-types-oracle.test.ts +++ b/packages/db/tests/mutation-handler-types-oracle.test.ts @@ -7,17 +7,22 @@ * The finite grammar crosses adapter, key/scalar domain, CRUD handler, direct * Config annotation/factory call, schema presence, Query row/envelope selection, * Promise construction and return-value shape. - * An independent output/transport rule predicts admission. The actual compiler - * receives generated consumers importing public source entries. Diagnostics - * must match exactly the rejected candidate lines; missing imports or unrelated - * compiler failures never count as rejection evidence. Built declarations/npm - * resolution and actual application migrations remain separate O22 receivers. + * An independent output/transport rule predicts admission. Each type shape + * resolves to a generated consumer program, one candidate declaration per line; + * the driver is the real TypeScript compiler, which records each diagnostic. + * Diagnostics must match exactly the rejected candidate lines; missing imports + * or unrelated compiler failures never count as rejection evidence. Built + * declarations, npm resolution, and actual application migrations remain + * separate receivers. */ import { resolve } from 'node:path' import ts from 'typescript' -import { afterAll, describe, expect, test } from 'vitest' -import { declareOracle } from './oracle-declaration' -import { createOracleReach } from './oracle-reach' +import { oracleProperty, pass, violation } from './oracle-property' +import type { Observation } from './oracle-property' + +// --------------------------------------------------------------------------- +// Grammar +// --------------------------------------------------------------------------- type ResultDescription = { name: string @@ -99,26 +104,27 @@ const typeShapes = ([`query`, `electric`] as const).flatMap((adapter) => ) type TypeShape = (typeof typeShapes)[number] -// Promise accepts fulfilled void/undefined and rejection. A synchronous -// never-returning function is also assignable; a synchronous void one is not. -// This rule uses the declared result description, not production handler types. -function admitsHandler(result: ResultDescription): boolean { - return ( - result.output === `never` || - (result.transport !== `sync` && result.output !== `value`) - ) -} - type Candidate = { id: string + /** The result description the candidate's handler returns. */ + result: string line: number - admitted: boolean surface: string start: number end: number handlerStart: number handlerEnd: number } + +// --------------------------------------------------------------------------- +// Resolution: a type shape becomes a consumer program +// --------------------------------------------------------------------------- +// +// The plan is the generated source and where each candidate declaration and +// its CRUD property sit in it. It holds no expected admission. + +type Plan = { shape: TypeShape; source: string; candidates: Array } + function handlerConsumer(shape: TypeShape): { source: string candidates: Array @@ -170,7 +176,7 @@ function handlerConsumer(shape: TypeShape): { candidates.push({ id, line: lines.length + 1, - admitted: admitsHandler(result), + result: result.name, surface, start, end: start + declaration.length, @@ -185,6 +191,10 @@ function handlerConsumer(shape: TypeShape): { return { source: lines.join(`\n`), candidates } } +// --------------------------------------------------------------------------- +// Production driver: the TypeScript compiler +// --------------------------------------------------------------------------- + // Only compiler mechanics are cached. Every history gets a fresh consumer AST; // all dependency diagnostics are retained. Public imports resolve to source so // stale installed declaration files cannot make this source contract pass. @@ -244,179 +254,291 @@ function compileHandlerConsumer(source: string): ReadonlyArray { return [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] } +// The driver compiles the plan's source and records every diagnostic as +// plain data: whether it is in the generated file, its line, span, code, and +// message. +type Diagnostic = { + generated: boolean + line: number | undefined + start: number | undefined + length: number + code: number + message: string +} + +function compileDriver( + plan: Plan, + observe: (checkpoint: string, value: unknown) => void, +) { + const diagnostics = compileHandlerConsumer(plan.source).map( + (diagnostic): Diagnostic => ({ + generated: diagnostic.file?.fileName === filename, + line: + diagnostic.file && diagnostic.start !== undefined + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + .line + 1 + : undefined, + start: diagnostic.start, + length: diagnostic.length ?? 0, + code: diagnostic.code, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `), + }), + ) + observe(`diagnostics`, diagnostics) +} + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- +// +// Promise accepts fulfilled void/undefined and rejection. A synchronous +// never-returning function is also assignable; a synchronous void one is not. +// This rule uses the declared result description, not production handler +// types. The model owes the set of rejected candidates, and names the states +// each shape reaches. + +function admitsHandler(result: ResultDescription): boolean { + return ( + result.output === `never` || + (result.transport !== `sync` && result.output !== `value`) + ) +} + +function admissionModel(plan: Plan) { + const admitted = new Map( + plan.candidates.map((candidate) => [ + candidate.id, + admitsHandler( + results.find((result) => result.name === candidate.result)!, + ), + ]), + ) + const { adapter, schema, data } = plan.shape + const reached = plan.candidates.map( + (candidate) => + `${adapter}/${schema ? `schema` : `plain`}/${data}/${candidate.id}`, + ) + return { admitted, reached } +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- +// // A negative candidate owes a handler-result incompatibility inside its own // declaration, anchored on its CRUD property; positive neighbors owe none. -// TypeScript may report both schema and handler overload errors for one call. Keep those distinct diagnostics, -// reject duplicates, and compare candidate membership rather than multiplicity. -// The Promise cause prevents unrelated same-code errors from substituting -// for the required rejection. The property anchor also rejects an unrelated -// Promise error inside the callback. Built diagnostics are an O22 receiver. +// TypeScript may report both schema and handler overload errors for one call. +// Keep those distinct diagnostics, reject duplicates, and compare candidate +// membership rather than multiplicity. The Promise cause prevents +// unrelated same-code errors from substituting for the required rejection. +// The property anchor also rejects an unrelated Promise error inside the +// callback. The laws: +// - `candidate-diagnostics`: every diagnostic is in the generated file, on a +// candidate's line; +// - `admission`: no admitted candidate has one, and the candidates rejected at +// their CRUD property are exactly the ones the model rejects; +// - `diagnostic-cause`: a diagnostic has the expected code, lies inside its +// candidate's declaration, and names Promise; +// - `distinct-diagnostics`: no diagnostic is reported twice. + function checkHandlerAdmission( - candidates: ReadonlyArray, - diagnostics: ReadonlyArray, -): void { - const observed = diagnostics.map((diagnostic) => { - const line = - diagnostic.file && diagnostic.start !== undefined - ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + - 1 - : undefined - const candidate = candidates.find((entry) => entry.line === line) - expect( - diagnostic.file?.fileName, - ts.flattenDiagnosticMessageText(diagnostic.messageText, `\n`), - ).toBe(filename) - expect( - candidate, - `unexpected diagnostic at line ${line}: TS${diagnostic.code} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `)}`, - ).toBeDefined() - expect(candidate!.admitted, `admitted handler ${candidate!.id}`).toBe(false) - expect(candidate!.surface === `config` ? [2322] : [2322, 2769]).toContain( - diagnostic.code, + plan: Plan, + trace: ReadonlyArray, + { admitted }: ReturnType, +) { + const recorded = trace.find(({ checkpoint }) => checkpoint === `diagnostics`) + if (!recorded) + return violation( + `diagnostics`, + `the driver recorded no diagnostics`, + `trace`, ) - expect(diagnostic.start).toBeGreaterThanOrEqual(candidate!.start) - expect(diagnostic.start! + (diagnostic.length ?? 0)).toBeLessThanOrEqual( - candidate!.end, + const diagnostics = recorded.value as Array + const rejected = new Set() + for (const diagnostic of diagnostics) { + const candidate = plan.candidates.find( + (entry) => entry.line === diagnostic.line, ) - expect( - ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `), - ).toContain(`Promise`) - return { - id: candidate!.id, - handler: - diagnostic.start === candidate!.handlerStart && - diagnostic.start + (diagnostic.length ?? 0) === candidate!.handlerEnd, - diagnostic: `${diagnostic.start}/${diagnostic.length}/${diagnostic.code}/${ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `)}`, - } - }) - expect(new Set(observed.map((entry) => entry.diagnostic)).size).toBe( - observed.length, - ) - expect( - [ - ...new Set( - observed.filter((entry) => entry.handler).map((entry) => entry.id), - ), - ].sort(), - ).toEqual( - candidates - .filter((entry) => !entry.admitted) - .map((entry) => entry.id) - .sort(), + if (!diagnostic.generated || !candidate) + return violation( + `diagnostics`, + `unexpected diagnostic at line ${diagnostic.line}: TS${diagnostic.code} ${diagnostic.message}`, + `candidate-diagnostics`, + ) + if (admitted.get(candidate.id)) + return violation( + `diagnostics`, + `admitted handler ${candidate.id} was rejected`, + `admission`, + ) + const codes = candidate.surface === `config` ? [2322] : [2322, 2769] + const end = (diagnostic.start ?? 0) + diagnostic.length + if ( + !codes.includes(diagnostic.code) || + diagnostic.start === undefined || + diagnostic.start < candidate.start || + end > candidate.end || + !diagnostic.message.includes(`Promise`) + ) + return violation( + `diagnostics`, + `${candidate.id}: TS${diagnostic.code} at ${diagnostic.start}..${end} (declaration ${candidate.start}..${candidate.end}): ${diagnostic.message}`, + `diagnostic-cause`, + ) + if ( + diagnostic.start === candidate.handlerStart && + end === candidate.handlerEnd + ) + rejected.add(candidate.id) + } + const texts = diagnostics.map( + (diagnostic) => + `${diagnostic.start}/${diagnostic.length}/${diagnostic.code}/${diagnostic.message}`, ) + if (new Set(texts).size !== texts.length) + return violation( + `diagnostics`, + `a diagnostic was reported twice`, + `distinct-diagnostics`, + ) + const owed = [...admitted] + .filter(([, value]) => !value) + .map(([id]) => id) + .sort() + const seen = [...rejected].sort() + if (JSON.stringify(seen) !== JSON.stringify(owed)) + return violation( + `diagnostics`, + `rejected ${JSON.stringify(seen)}, owed ${JSON.stringify(owed)}`, + `admission`, + { actual: seen, expected: owed }, + ) + return pass } -// A state name leaves out the key and scalar types, so the six shapes that -// differ only there share one state, and `minimum: 6` asks for all of them. -const states = [ - ...new Set( - typeShapes.flatMap((shape) => - surfaces.flatMap((surface) => - handlers.flatMap((handler) => - results.map( - (result) => - `${shape.adapter}/${shape.schema ? `schema` : `plain`}/${shape.data}/${surface}/${handler}/${result.name}`, - ), - ), - ), - ), - ), -] -const reach = createOracleReach(`adapter.handler-types`, states, { minimum: 6 }) -describe(`public handler result admission`, () => { - afterAll(() => { - reach.assertReached() - console.info( - `adapter.handler-types reach`, - Object.fromEntries(reach.counts()), +// --------------------------------------------------------------------------- +// Calibrations +// --------------------------------------------------------------------------- +// +// Wrong compiler observations the check must reject: a missing rejection, a +// duplicated one, a setup error in place of a rejection, an admitted handler +// reported as rejected, an unrelated error with the same code, and a +// Promise error inside a callback body instead of at the property. + +/** Replaces the recorded diagnostics, or gives undefined when `change` does. */ +const corruptDiagnostics = + ( + change: ( + diagnostics: Array, + plan: Plan, + ) => Array | undefined, + ) => + (trace: ReadonlyArray, plan: Plan) => { + const at = trace.findIndex(({ checkpoint }) => checkpoint === `diagnostics`) + if (at < 0) return undefined + const changed = change([...(trace[at]!.value as Array)], plan) + if (!changed) return undefined + return trace.map((entry, index) => + index === at ? { ...entry, value: changed } : entry, ) - }) - test.each(typeShapes)( - `admits only promised handler results: %j`, - (shape) => { - reach.beginHistory() - const consumer = handlerConsumer(shape) - const diagnostics = compileHandlerConsumer(consumer.source) - checkHandlerAdmission(consumer.candidates, diagnostics) - for (const candidate of consumer.candidates) - reach.reach( - `${shape.adapter}/${shape.schema ? `schema` : `plain`}/${shape.data}/${candidate.id}`, - ) - }, - 30000, - ) -}) + } -// Controls alter actual compiler observations, not the expected grammar. They -// first establish that the generated neighbor is admitted/rejected correctly. -test.each([ - `missing-rejection`, - `duplicate-rejection`, - `setup-error`, - `reject-void`, - `unrelated-same-code`, - `unrelated-promise-in-body`, -] as const)( - `rejects wrong handler compiler observation: %s`, - (fault) => { - const consumer = handlerConsumer(typeShapes[0]!) - const diagnostics = [...compileHandlerConsumer(consumer.source)] - checkHandlerAdmission(consumer.candidates, diagnostics) - if (fault === `missing-rejection`) { - const line = diagnostics[0]!.file!.getLineAndCharacterOfPosition( - diagnostics[0]!.start!, - ).line - for (let i = diagnostics.length - 1; i >= 0; i--) - if ( - diagnostics[i]!.file!.getLineAndCharacterOfPosition( - diagnostics[i]!.start!, - ).line === line - ) - diagnostics.splice(i, 1) - } - if (fault === `duplicate-rejection`) diagnostics.push(diagnostics[0]!) - if (fault === `setup-error`) - diagnostics[0] = { ...diagnostics[0]!, code: 2307 } - if (fault === `unrelated-same-code`) - diagnostics[0] = { - ...diagnostics[0]!, - code: 2322, - messageText: `Type 'number' is not assignable to type 'string'.`, - } - if (fault === `unrelated-promise-in-body`) { - const candidate = consumer.candidates.find( - (entry) => - entry.line === - diagnostics[0]!.file!.getLineAndCharacterOfPosition( - diagnostics[0]!.start!, - ).line + - 1, - )! - diagnostics[0] = { - ...diagnostics[0]!, - start: candidate.handlerEnd + 3, - length: 1, - } - } - if (fault === `reject-void`) { - const admitted = consumer.candidates.find( - (candidate) => candidate.admitted, - )! - diagnostics.push({ - ...diagnostics[0]!, - start: admitted.handlerStart, - length: admitted.handlerEnd - admitted.handlerStart, - }) - } - expect(() => - checkHandlerAdmission(consumer.candidates, diagnostics), - ).toThrow() +const handlerCalibrations = [ + { + name: `missing rejection`, + law: `admission`, + corrupt: corruptDiagnostics((diagnostics) => { + const first = diagnostics[0] + return first + ? diagnostics.filter((diagnostic) => diagnostic.line !== first.line) + : undefined + }), }, - 30000, -) + { + name: `duplicate rejection`, + law: `distinct-diagnostics`, + corrupt: corruptDiagnostics((diagnostics) => + diagnostics[0] ? [...diagnostics, diagnostics[0]] : undefined, + ), + }, + { + name: `setup error`, + law: `diagnostic-cause`, + corrupt: corruptDiagnostics((diagnostics) => + diagnostics[0] + ? [{ ...diagnostics[0], code: 2307 }, ...diagnostics.slice(1)] + : undefined, + ), + }, + { + name: `rejected void`, + law: `admission`, + corrupt: corruptDiagnostics((diagnostics, plan) => { + const model = admissionModel(plan) + const candidate = plan.candidates.find((entry) => + model.admitted.get(entry.id), + ) + return diagnostics[0] && candidate + ? [ + ...diagnostics, + { + ...diagnostics[0], + line: candidate.line, + start: candidate.handlerStart, + length: candidate.handlerEnd - candidate.handlerStart, + }, + ] + : undefined + }), + }, + { + name: `unrelated same code`, + law: `diagnostic-cause`, + corrupt: corruptDiagnostics((diagnostics) => + diagnostics[0] + ? [ + { + ...diagnostics[0], + code: 2322, + message: `Type 'number' is not assignable to type 'string'.`, + }, + ...diagnostics.slice(1), + ] + : undefined, + ), + }, + { + name: `Promise error inside the callback body`, + law: `admission`, + corrupt: corruptDiagnostics((diagnostics, plan) => { + const first = diagnostics[0] + const candidate = + first && plan.candidates.find((entry) => entry.line === first.line) + return first && candidate + ? [ + { ...first, start: candidate.handlerEnd + 3, length: 1 }, + ...diagnostics.slice(1), + ] + : undefined + }), + }, +] -declareOracle({ - check: [checkHandlerAdmission], - model: [admitsHandler], - grammar: [handlerConsumer, typeShapes, results], +oracleProperty({ + id: `adapter.handler-types`, + law: { + statement: `Query and Electric handlers admit exactly the results that Promise accepts: a handler that returns a value, or a synchronous handler that returns, is rejected at its CRUD property with a Promise diagnostic, and no other diagnostic appears.`, + authority: `the approved RC handler migration and the public QueryCollectionConfig and ElectricCollectionConfig types`, + limits: `Source entries only; built declarations, npm resolution, and application migrations are separate receivers.`, + owner: `Adapter handler types`, + }, + histories: typeShapes, + label: ({ adapter, key, scalar, schema, data }) => + `${adapter} ${key} ${scalar} ${schema ? `schema` : `plain`} ${data}`, + resolve: (shape: TypeShape): Plan => ({ shape, ...handlerConsumer(shape) }), + model: admissionModel, + reached: (_plan, _trace, expected) => expected.reached, + driver: compileDriver, + check: checkHandlerAdmission, + calibrations: handlerCalibrations, }) From a8af8fa215d2287f7a33e20e83b4524b3457d6db Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 11:11:08 -0700 Subject: [PATCH 015/168] test(oracles): add a data schedule for drivers, with its own oracle Drivers hand-roll async interleavings with deferred gates. fast-check's fc.scheduler() would generate and shrink them, but a spike inside oracleProperty showed it cannot be a history value: the determinism test caught two runs of one scheduler history recording different orders (a scheduler is consumed as it runs), three clones of one unrun scheduler did not all reproduce, a generated scheduler value is not a member of its own language (so no pin or segment can hold one), and it prints as internal state. scheduleFrom(choices) keeps fc.scheduler's method names (schedule, waitOne, waitIdle) with decisions as plain data in the history. A task belongs to a queue, and a queue releases in scheduling order, as a storage engine completes one store's operations in order; each choice picks which queue's oldest task is released next. A history holding the choices replays exactly, shrinks like an array, can be a pin, and prints as JSON. self-oracle.scheduler checks the contract against an independent model of the release rule (100 generated histories plus a pin). RED: the scheduler without queues fails 3 of 4 tests at law release-order. GREEN: 4 of 4. Co-authored-by: Isaac --- packages/db/tests/oracle-config.ts | 1 + ...property-scheduler-oracle.property.test.ts | 152 ++++++++++++++++++ packages/db/tests/oracle-property.ts | 74 +++++++++ packages/db/tests/oracle-replay-manifest.ts | 5 + 4 files changed, 232 insertions(+) create mode 100644 packages/db/tests/oracle-property-scheduler-oracle.property.test.ts diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 719fc17ffd..9dcc5ae420 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -285,6 +285,7 @@ const staticOracleProperties = [ `self-oracle.synthetic-2`, `self-oracle.synthetic-3`, `self-oracle.framework`, + `self-oracle.scheduler`, `derived-publication.membership-work`, `collection-publication.metadata-cancellation`, `collection-publication.metadata-only`, diff --git a/packages/db/tests/oracle-property-scheduler-oracle.property.test.ts b/packages/db/tests/oracle-property-scheduler-oracle.property.test.ts new file mode 100644 index 0000000000..c6edb248ce --- /dev/null +++ b/packages/db/tests/oracle-property-scheduler-oracle.property.test.ts @@ -0,0 +1,152 @@ +/** + * # A data schedule releases tasks as its choices say + * + * `scheduleFrom(choices)` is the framework's schedule as plain data. Its + * promise, from the header of `oracle-property.ts`: a task belongs to a queue; + * each queue releases its tasks in the order they were scheduled; at each + * step the schedule releases the oldest task of the queue that + * `choices[step] % queues` names, counting the queues that have a pending task + * in the order they first did, and the oldest task overall once the choices + * run out. A released task's promise settles like the promise it wraps, and + * the same choices give the same release order every time. + * + * History: two to five tasks, each in one of three queues, each resolving or + * rejecting, and up to six choices. The model replays the rule above on the + * task list. The driver schedules every task through `scheduleFrom`, waits + * until idle, and records the release order and each task's outcome. The + * check compares them. + * + * Limits: tasks are scheduled up front; tasks scheduled by released tasks' + * continuations are not generated. + */ +import fc from 'fast-check' +import { + oracleProperty, + pass, + scheduleFrom, + violation, +} from './oracle-property' +import type { Observation } from './oracle-property' + +type Task = { queue: `storage` | `provider` | `timer`; rejects: boolean } +type History = { tasks: Array; choices: Array } + +const history: fc.Arbitrary = fc.record({ + tasks: fc.array( + fc.record({ + queue: fc.constantFrom( + `storage` as const, + `provider` as const, + `timer` as const, + ), + rejects: fc.boolean(), + }), + { minLength: 2, maxLength: 5 }, + ), + choices: fc.array(fc.nat(5), { maxLength: 6 }), +}) + +// The rule, stated over task indexes. +function releaseOrder({ tasks, choices }: History): Array { + const pending = tasks.map((_, index) => index) + const order: Array = [] + for (let step = 0; pending.length > 0; step++) { + const queues = [...new Set(pending.map((index) => tasks[index]!.queue))] + const choice = choices[step] + const at = + choice === undefined + ? 0 + : pending.findIndex( + (index) => tasks[index]!.queue === queues[choice % queues.length], + ) + order.push(...pending.splice(at, 1)) + } + return order +} + +async function driveSchedule( + { tasks, choices }: History, + observe: (checkpoint: string, value: unknown) => void, +) { + const schedule = scheduleFrom(choices) + const outcomes: Array = tasks.map(() => `pending`) + const settled = tasks.map((task, index) => + schedule + .schedule( + task.rejects + ? Promise.reject(new Error(`task ${index}`)) + : Promise.resolve(index), + `${task.queue} ${index}`, + ) + .then( + (value) => (outcomes[index] = `resolved ${String(value)}`), + (error: Error) => (outcomes[index] = `rejected ${error.message}`), + ), + ) + await schedule.waitIdle() + await Promise.all(settled) + observe(`schedule`, { + released: schedule.released().map((label) => Number(label.split(` `)[1])), + outcomes, + }) +} + +function checkSchedule( + { tasks }: History, + trace: ReadonlyArray, + owed: Array, +) { + const seen = trace[0]?.value as + { released: Array; outcomes: Array } | undefined + if (!seen) return violation(`schedule`, `nothing recorded`, `trace`) + if (JSON.stringify(seen.released) !== JSON.stringify(owed)) + return violation( + `schedule`, + `released ${seen.released}, owed ${owed}`, + `release-order`, + { + actual: seen.released, + expected: owed, + }, + ) + const outcomes = tasks.map((task, index) => + task.rejects ? `rejected task ${index}` : `resolved ${index}`, + ) + if (JSON.stringify(seen.outcomes) !== JSON.stringify(outcomes)) + return violation(`schedule`, `outcomes ${seen.outcomes}`, `settlement`, { + actual: seen.outcomes, + expected: outcomes, + }) + return pass +} + +oracleProperty({ + id: `self-oracle.scheduler`, + law: { + statement: `A data schedule releases the oldest task of the queue its next choice names, the oldest task overall once the choices run out, and settles each task like the promise it wraps.`, + authority: `the scheduleFrom contract in the header of packages/db/tests/oracle-property.ts`, + limits: `Tasks scheduled up front, in up to three queues; no tasks scheduled by continuations.`, + owner: `oracleProperty framework`, + }, + histories: { + arbitrary: history, + runs: 100, + fixedSeeds: [20261103], + pins: [ + { + name: `a queue keeps its order while another advances`, + history: { + tasks: [ + { queue: `storage`, rejects: false }, + { queue: `storage`, rejects: false }, + { queue: `provider`, rejects: true }, + ], + choices: [1, 0, 0], + }, + }, + ], + }, + model: releaseOrder, + driver: driveSchedule, + check: checkSchedule, +}) diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 7633a19eaa..0165332f6e 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -201,6 +201,80 @@ export function withinTurns( return Promise.race([done, expiry]) } +/** + * A schedule as plain data. `scheduleFrom(choices)` orders promises the way + * fast-check's `fc.scheduler()` does, with the same method names, but its + * decisions are a list of numbers in the history. A task belongs to a queue + * (its label's text before the first space, unless `queue` names one), and a + * queue releases its tasks in the order they were scheduled, as a storage + * engine completes one store's operations in order. At each step the schedule + * releases the oldest task of queue `choices[step] % queues`, counting the + * queues that have a pending task in the order they first did, and the oldest + * task overall once the choices run out. A history that holds the choices replays exactly, shrinks + * like any array, can be a pin or a segment, and prints as JSON; a + * `fc.scheduler()` value does none of these (it is consumed as it runs, its + * clones do not reproduce, and it is never a member of its own language). + */ +export interface DataScheduler { + /** A promise that settles like `promise`, once the schedule releases it. */ + schedule: ( + promise: Promise | T, + label?: string, + queue?: string, + ) => Promise + /** Releases one pending task and waits for its continuations. */ + waitOne: () => Promise + /** Releases tasks until none is pending, including ones they schedule. */ + waitIdle: () => Promise + /** The labels of released tasks, in release order. */ + released: () => ReadonlyArray +} + +export function scheduleFrom(choices: ReadonlyArray): DataScheduler { + const pending: Array<{ label: string; queue: string; release: () => void }> = + [] + const released: Array = [] + let step = 0 + // Continuations of a released task run on microtasks; one macrotask turn + // lets them finish, and lets them schedule further tasks. + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + const waitOne = async () => { + if (pending.length === 0) return + const queues = [...new Set(pending.map((task) => task.queue))] + const choice = choices[step++] + const at = + choice === undefined + ? 0 + : pending.findIndex( + (task) => task.queue === queues[choice % queues.length], + ) + const [task] = pending.splice(at, 1) + released.push(task!.label) + task!.release() + await settle() + } + return { + schedule: ( + promise: Promise | T, + label = `task ${pending.length}`, + queue = label.split(` `)[0]!, + ) => + new Promise((resolve, reject) => { + pending.push({ + label, + queue, + release: () => void Promise.resolve(promise).then(resolve, reject), + }) + }), + waitOne, + waitIdle: async () => { + await settle() + while (pending.length > 0) await waitOne() + }, + released: () => [...released], + } +} + /** * The check found a history whose observations the law does not allow. * `actual` and `expected` are the fields Vitest reads to print a diff. diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 8c9c807fce..41696f7d6b 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -558,6 +558,11 @@ const ownerGroups: ReadonlyArray< ], [`db/tests/reverse-index-oracle.property.test.ts`, `reverse-index`, `reads`], [`db/tests/oracle-property-self-oracle.test.ts`, `self-oracle`, `framework`], + [ + `db/tests/oracle-property-scheduler-oracle.property.test.ts`, + `self-oracle`, + `scheduler`, + ], [ `db/tests/oracle-property-self-oracle-fixture.ts`, `self-oracle`, From 965f43c6f5a64b878ceac60c3d8cc67faeb14138 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 11:48:34 -0700 Subject: [PATCH 016/168] test(oracles): track run budgets that oracleProperty definitions declare The run-count script read budgets only from `oraclePropertyOptions`, `oracleRandomParameters`, and `{ property, defaultRuns }`. A property moved onto `oracleProperty` declares its budget as `histories: { runs }`, so the script reported the moved budget as removed. - Law: every run budget an oracle property uses is tracked, wherever and however it is written (scripts/run-budget-oracle.property.test.js; the removal rule is the maintainer decision of 2026-10-27). - Reach: the grammar now draws `oracleProperty` definitions and property budgets that move to one at head; both new states are declared and required (minimum 1), and the GREEN run reached them. - RED: `vitest run --config scripts/oracle-scripts.vitest.config.mjs run-budget` on the unchanged script: 8 of 8 campaigns fail, with the migration as counterexample. - GREEN: the same command, 8 of 8 pass. - Mutant: a script that reads `runs` beside `id` instead of inside `histories` fails both "every source" campaigns. - Limits: inline `histories` only; a `histories` constant, and an id built by a template with expressions, are not read or generated. Co-authored-by: Isaac --- scripts/check-oracle-run-counts.mjs | 26 ++++++++++- scripts/run-budget-oracle.property.test.js | 53 +++++++++++++++------- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/scripts/check-oracle-run-counts.mjs b/scripts/check-oracle-run-counts.mjs index fa4108338e..7a4d4c86d6 100644 --- a/scripts/check-oracle-run-counts.mjs +++ b/scripts/check-oracle-run-counts.mjs @@ -13,8 +13,9 @@ * end of a turn. * * A budget is keyed by its registered property where the code names one - * (`oraclePropertyOptions(n, 'p')`, `oracleRandomParameters(n, _, 'p')`, or - * `{ property: 'p', defaultRuns: n }`), and otherwise by its fixed seed + * (`oraclePropertyOptions(n, 'p')`, `oracleRandomParameters(n, _, 'p')`, + * `{ property: 'p', defaultRuns: n }`, or the framework definition + * `oracleProperty({ id: 'p', histories: { runs: n } })`), and otherwise by its fixed seed * (`{ seed: s, numRuns: oracleRuns(n) }`). A key or count is a literal, a * constant in the same file, or a constant that the file imports. An import * follows relative re-exports (`export { x } from`, `export * from`), so a @@ -317,6 +318,27 @@ function runBudgets(file, revision, reader) { if (typeof property === `string`) record(`property ${property}`, value(first)) } + // A framework definition declares its budget inside its histories. + if ( + node.callee.name === `oracleProperty` && + first?.type === `ObjectExpression` + ) { + const property = propertyValue(first, `id`, file, reader, revision) + const histories = first.properties.find( + (candidate) => + candidate.type === `Property` && + !candidate.computed && + nameOf(candidate.key) === `histories`, + )?.value + if ( + typeof property === `string` && + histories?.type === `ObjectExpression` + ) + record( + `property ${property}`, + propertyValue(histories, `runs`, file, reader, revision), + ) + } if (node.callee.name === `oracleRandomParameters`) { const property = value(third) if (typeof property === `string`) diff --git a/scripts/run-budget-oracle.property.test.js b/scripts/run-budget-oracle.property.test.js index 2363642755..2c7b83bf6b 100644 --- a/scripts/run-budget-oracle.property.test.js +++ b/scripts/run-budget-oracle.property.test.js @@ -1,7 +1,9 @@ /** * Law (L5): every run budget that an oracle property uses is tracked by * scripts/check-oracle-run-counts.mjs, wherever it is written. A budget is the - * numRuns of a fixed-seed campaign, or the run count of a registered property. + * numRuns of a fixed-seed campaign, or the run count of a registered property, + * whether the property passes it to `oraclePropertyOptions` or declares it as + * `oracleProperty({ id, histories: { runs } })`. * When the branch lowers a budget, or removes it from every tracked oracle * file, the script reports it, so the change is visible to the reviewer. * @@ -26,7 +28,10 @@ * key is unique to its budget, except that a fixed seed can be shared with an * unrelated campaign in the second oracle file. Then the removal law treats * the seed as still present, and the model follows it. Star re-exports are not - * generated. A count may be wrapped in oracleRuns. The model judges the + * generated. A count may be wrapped in oracleRuns, except in `oracleProperty`, + * whose framework scales `runs` itself. An `oracleProperty` call is generated + * with inline `histories`; a `histories` object held in a constant, and an id + * built by a template with expressions, are not. The model judges the * reported budget, its base value, and its head value or removal; it does not * predict the wording of a line. * @@ -41,8 +46,8 @@ */ import { execFile } from 'node:child_process' import { - mkdtempSync, mkdirSync, + mkdtempSync, readFileSync, rmSync, symlinkSync, @@ -79,6 +84,9 @@ const LARGE_PADDING = 1_100_000 // -- 1. Grammar --------------------------------------------------------------- // +// A property budget is written through `oraclePropertyOptions` or as an +// `oracleProperty` definition, and a property budget may migrate from the +// first form at base to the second at head, as a port onto the framework does. // A budget's count is written inline, in support module A, or in A and passed // on by support module B's re-export. Its key is inline or imported from A. A // change edits the count where it is defined, or writes a literal at the call. @@ -88,7 +96,8 @@ const LARGE_PADDING = 1_100_000 // A are `.ts` or `.js`; an import names A with or without the `.js` suffix. const budgetSkeleton = fc.record({ - form: fc.constantFrom(`property`, `seed`), + form: fc.constantFrom(`property`, `framework`, `seed`), + migrates: fc.boolean(), source: fc.constantFrom(`inline`, `imported`, `reexported`), keySource: fc.constantFrom(`inline`, `imported`), wrapped: fc.boolean(), @@ -198,9 +207,12 @@ function resolveBudget(skeleton, index, sources, files) { return { index, form: skeleton.form, + // A property budget written through `oraclePropertyOptions` at base that + // is an `oracleProperty` definition at head. + migrates: skeleton.form === `property` && skeleton.migrates, source, keySource: skeleton.keySource, - wrapped: skeleton.wrapped, + wrapped: skeleton.form !== `framework` && skeleton.wrapped, before: skeleton.base, after: headValue, changed: headValue !== skeleton.base, @@ -218,7 +230,7 @@ function resolveBudget(skeleton, index, sources, files) { skeleton.fate !== `other-oracle` && !files.otherNew, key: - skeleton.form === `property` + skeleton.form !== `seed` ? `property budget.${skeleton.keySource === `imported` ? `q` : `p`}${index}` : `fixed seed ${1000 + index}`, } @@ -328,9 +340,11 @@ function renderFiles(program, revision) { const count = isHead ? budget.after : budget.before const seed = 1000 + i // The definition of an imported key and count lives in support module A. + // The form a budget is written in at this revision. + const form = isHead && budget.migrates ? `framework` : budget.form if (budget.keySource === `imported`) { support.line( - budget.form === `property` + budget.form !== `seed` ? `export const PROP_${i} = 'budget.q${i}'` : `export const SEED_${i} = ${seed}`, ) @@ -369,20 +383,23 @@ function renderFiles(program, revision) { : budget.source === `reexported` && !inSupport ? use(`RUNS_${i}`, files.reexport) : use(`RUNS_${i}`, files.support) - const runs = budget.wrapped ? `oracleRuns(${countText})` : countText + // A migrated budget drops its oracleRuns wrapper, as the framework scales. + const runs = + budget.wrapped && form !== `framework` + ? `oracleRuns(${countText})` + : countText const keyText = budget.keySource === `imported` - ? use( - budget.form === `property` ? `PROP_${i}` : `SEED_${i}`, - files.support, - ) - : budget.form === `property` + ? use(budget.form !== `seed` ? `PROP_${i}` : `SEED_${i}`, files.support) + : budget.form !== `seed` ? `'budget.p${i}'` : `${seed}` const call = - budget.form === `property` + form === `property` ? `export const opts${i} = oraclePropertyOptions(${runs}, ${keyText})` - : `export function campaign${i}() { fc.assert(fc.property(fc.integer(), () => undefined), { numRuns: ${runs}, seed: ${keyText} }) }` + : form === `framework` + ? `export const opts${i} = oracleProperty({ id: ${keyText}, law: {}, histories: { arbitrary: fc.integer(), runs: ${runs} } })` + : `export function campaign${i}() { fc.assert(fc.property(fc.integer(), () => undefined), { numRuns: ${runs}, seed: ${keyText} }) }` hostText.line(call) // The oracle file uses a campaign in A by importing it. if (inSupport) { @@ -390,7 +407,7 @@ function renderFiles(program, revision) { .get(files.oracle) .import( supportSpecifier, - budget.form === `property` ? `opts${i}` : `campaign${i}`, + budget.form !== `seed` ? `opts${i}` : `campaign${i}`, ) } } @@ -534,6 +551,8 @@ const STATES = [ `imported-count`, `re-exported-count`, `imported-key`, + `framework-definition`, + `migrated-to-framework`, `support-only-change`, `oracle-and-support-change`, `lowered-budget`, @@ -559,6 +578,8 @@ function recordStates(program) { if (budget.source === `imported`) states.add(`imported-count`) if (budget.source === `reexported`) states.add(`re-exported-count`) if (budget.keySource === `imported`) states.add(`imported-key`) + if (budget.form === `framework`) states.add(`framework-definition`) + if (budget.migrates) states.add(`migrated-to-framework`) if (budget.after < budget.before) states.add(`lowered-budget`) if (budget.callAt === `support`) states.add(`campaign-in-support`) if (budget.callAt === `oracle` && budget.fate === `swap`) { From 0a96a316fdc14b56a37cc54cac40a261ddd90aa6 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 12:01:28 -0700 Subject: [PATCH 017/168] test(oracles): log the random lane's seed once, and add an exact comparison helper The random lane called `oraclePropertyOptions` twice: the first call selected and logged the seed it ran, and the second logged a fresh seed that was then overridden. A tool that read the last `ORACLE_SEED` line replayed the wrong seed. The lane now selects its seed once, and `withOracleExamples` holds the "examples come on top of the budget" rule that both lanes and `oraclePropertyOptions` share. The loss audits of four ports found failures without diffs and checks that compared JSON text, which erases symbol keys, undefined fields, and -0. `differs(checkpoint, law, actual, expected)` is one exact comparison (Node's isDeepStrictEqual) with both values attached for the diff. A pin that misses a state now also prints the states it entered. - Law: a random lane logs exactly one ORACLE_SEED line for its property, the seed it runs, and no other test logs one (self-oracle law `seed-log`; authority: the replay contract in oracle-config.ts, rubric ORC-007). - Reach: new measured state `random-lane`, entered by every generated pin. - RED: `pnpm --filter @tanstack/db test:oracle-framework` on the parent: 17 of 21 fail with law seed-log ("logged 2 ORACLE_SEED lines, owed 1"). - GREEN: the same command, 21 of 21 pass. - Mutant: a lane that logs one seed and runs seed + 1 fails ("logged seed 20251103 but ran seed 20251104"). - Limits: `differs`, like `forwardCut` and `withinTurns`, has no self-oracle law; it is a thin wrapper over isDeepStrictEqual. Co-authored-by: Isaac --- packages/db/tests/oracle-config.ts | 22 ++++++-- .../oracle-property-self-oracle-reporter.mjs | 16 +++++- .../tests/oracle-property-self-oracle.test.ts | 54 +++++++++++++++++-- packages/db/tests/oracle-property.ts | 50 ++++++++++++----- 4 files changed, 120 insertions(+), 22 deletions(-) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 9dcc5ae420..be74a88c8d 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -826,10 +826,24 @@ function randomParameters( } : {}), } - if (examples === undefined) return parameters - // fast-check runs the examples first and counts them in numRuns, so they - // come on top of the random runs: the examples never displace one. A path - // replay runs one recorded history, which can be an example. + return examples === undefined + ? parameters + : withOracleExamples(parameters, examples) +} + +/** + * Run parameters with examples added. fast-check runs the examples first and + * counts them in numRuns, so they come on top of the random runs: the + * examples never displace one. A path replay runs one recorded history, which + * can be an example. + */ +export function withOracleExamples< + TParameters extends { numRuns: number; path?: string }, + TExample, +>( + parameters: TParameters, + examples: ReadonlyArray, +): TParameters & { examples: Array } { return { ...parameters, examples: [...examples], diff --git a/packages/db/tests/oracle-property-self-oracle-reporter.mjs b/packages/db/tests/oracle-property-self-oracle-reporter.mjs index 64b26198e3..8ae0ede39e 100644 --- a/packages/db/tests/oracle-property-self-oracle-reporter.mjs +++ b/packages/db/tests/oracle-property-self-oracle-reporter.mjs @@ -1,6 +1,7 @@ // A Vitest reporter for the oracleProperty self-oracle. It writes every test's // state and errors, with their causes (fast-check attaches a property's error -// as the cause of its failure), to the file named by SELF_ORACLE_REPORT. +// as the cause of its failure), and the console output each test printed, to +// the file named by SELF_ORACLE_REPORT. import { writeFileSync } from 'node:fs' /** @@ -16,6 +17,18 @@ const serialize = (error) => { } export default class SelfOracleReporter { + /** @type {Map} console output by task id */ + console = new Map() + + /** @param {{ taskId?: string, content: unknown }} log */ + onUserConsoleLog(log) { + if (log.taskId === undefined) return + this.console.set( + log.taskId, + (this.console.get(log.taskId) ?? ``) + String(log.content), + ) + } + /** * @param {ReadonlyArray} testModules * @param {ReadonlyArray} unhandledErrors @@ -28,6 +41,7 @@ export default class SelfOracleReporter { name: test.fullName, state: test.result().state, errors: (test.result().errors ?? []).map(serialize), + console: this.console.get(test.id) ?? ``, })), })) writeFileSync( diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts index 5c12fdf09a..79aadcc902 100644 --- a/packages/db/tests/oracle-property-self-oracle.test.ts +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -30,6 +30,9 @@ * property's random lane, starts from the counterexample itself, and * reproduces it. * - `rerun`: a violation's printed rerun command runs exactly that test. + * - `seed-log`: a random lane logs exactly one `ORACLE_SEED` line for its + * property, the seed it runs, and no other test logs one; a replay tool + * reads that line. * * Authority: the contract in the header of `oracle-property.ts`; the * maintainer decisions of 2026-11-03 on calibration candidates, failure @@ -41,8 +44,8 @@ * synthetic driver's log, and relies on fast-check running a lane's examples * (its segment histories) first, in order. A replay or rerun probe covers * every failing lane but only the first violating pin or history of each - * property. The gap report's observation hook, `forwardCut`, and `withinTurns` - * have no law here. + * property. The gap report's observation hook, `forwardCut`, `withinTurns`, + * and `differs` have no law here. */ import { spawnSync } from 'node:child_process' import { @@ -469,6 +472,7 @@ const frameworkStates = [ `determinism-caught`, `observed-reach`, `timeout`, + `random-lane`, ] as const function frameworkReach( @@ -492,6 +496,7 @@ function frameworkReach( reached.add(`lane-setup-failure`) if (outcome.cause) reached.add(`cause-attached`) if (outcome.kind === `timeout`) reached.add(`timeout`) + if (name === `random seed`) reached.add(`random-lane`) if (outcome.kind === `determinism`) reached.add(`determinism-caught`) const failure = { 'segment-reach': `segment-reach-failure`, @@ -544,6 +549,7 @@ type ReportedTest = { name: string state: string errors: Array + console: string } type ReportedFile = { file: string @@ -565,7 +571,13 @@ type SuiteRun = { string, { loaded: boolean - tests: Array<{ name: string; state: string; failure?: Failure }> + tests: Array<{ + name: string + state: string + failure?: Failure + /** Seeds of the `ORACLE_SEED` lines the test logged for its property. */ + seedLogs: Array + }> reach: Record> } > @@ -752,6 +764,21 @@ async function driveSuite( ...(test.state === `failed` ? { failure: classify(test.errors[0]) } : {}), + seedLogs: test.console + .split(`\n`) + .filter((line) => + line.startsWith( + `ORACLE_SEED {"property":${JSON.stringify(spec.id)},`, + ), + ) + .map( + (line) => + ( + JSON.parse(line.slice(`ORACLE_SEED `.length)) as { + seed: number + } + ).seed, + ), })), reach, } @@ -893,6 +920,27 @@ function checkSuite( ), { actual: failure ?? { kind: `pass` }, expected: outcome }, ) + // A random lane selects its seed once and logs that selection once; + // a failing lane's reported seed is the logged one. + const owedSeedLogs = test.name === `random seed` ? 1 : 0 + if (test.seedLogs.length !== owedSeedLogs) + return violation( + `run`, + `${spec.id} "${test.name}" logged ${test.seedLogs.length} ORACLE_SEED lines, owed ${owedSeedLogs}`, + `seed-log`, + { actual: test.seedLogs, expected: owedSeedLogs }, + ) + if ( + failure?.seed !== undefined && + test.name === `random seed` && + test.seedLogs[0] !== failure.seed + ) + return violation( + `run`, + `${spec.id} "${test.name}" logged seed ${test.seedLogs[0]} but ran seed ${failure.seed}`, + `seed-log`, + { actual: failure.seed, expected: test.seedLogs[0] }, + ) if ( test.name === `determinism` && test.state === `passed` && diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 0165332f6e..a1beb132d6 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -30,7 +30,8 @@ * checkpoints first and the trace's completeness last. * - `check` compares the observations with the model and returns a verdict. * Only the check can report a violation of the law. A violation that gives - * the `actual` and `expected` values gets the test runner's usual diff. + * the `actual` and `expected` values gets the test runner's usual diff; + * `differs` builds one from an exact comparison. * * Each history is one test, named by `label`. A violation fails it with an * `OracleViolation` that names the property, the law, the checkpoint, the @@ -101,12 +102,14 @@ * literal with a `check`. The property is its own declaration. */ import { appendFileSync } from 'node:fs' +import { isDeepStrictEqual } from 'node:util' import fc from 'fast-check' import { describe, expect, it } from 'vitest' import { oraclePropertyOptions, oracleReplayTest, oracleRuns, + withOracleExamples, } from './oracle-config.js' /** A law and where it comes from. */ @@ -153,6 +156,26 @@ export function violation( return { kind: `violation`, checkpoint, difference, law, values } } +/** + * One exact comparison: a violation of `law` at `checkpoint` when `actual` is + * not strictly deep-equal to `expected`, with both values attached so the + * failure shows a diff; otherwise undefined, so comparisons chain as + * `return differs(...) ?? differs(...) ?? pass`. Equality is Node's + * `isDeepStrictEqual`: symbol keys, undefined-versus-absent fields, `-0`, + * and prototypes count, where JSON text would erase them. + */ +export function differs( + checkpoint: string, + law: string, + actual: unknown, + expected: unknown, + difference = `the observed value differs from the owed value`, +): Verdict | undefined { + return isDeepStrictEqual(actual, expected) + ? undefined + : violation(checkpoint, difference, law, { actual, expected }) +} + /** * History checking against allowed cuts. * A model can often name, in order, the states an observer may see after a @@ -825,16 +848,16 @@ function generatedTests( `${id} ${name} reach ${JSON.stringify(Object.fromEntries([...counts].sort(([a], [b]) => (a < b ? -1 : 1))))}\n`, ) } - // Examples come on top of a lane's budget, as in `oraclePropertyOptions`. const fixedLanes = source.fixedSeeds.map((seed): PropertyTest => ({ name: `fixed seed ${seed}`, replays: [], run: () => - runLane(`fixed seed ${seed}`, seed, (examples) => ({ - numRuns: oracleRuns(source.runs) + examples.length, - seed, - examples, - })), + runLane(`fixed seed ${seed}`, seed, (examples) => + withOracleExamples( + { numRuns: oracleRuns(source.runs), seed }, + examples, + ), + ), })) // The random lane picks its seed itself, so a replay of that seed rebuilds // the same segment histories, which the replay path counts. @@ -842,12 +865,11 @@ function generatedTests( name: `random seed`, replays: [id], run: () => { - const seed = - oraclePropertyOptions(source.runs, id).seed ?? Date.now() % 2 ** 31 - return runLane(`random seed`, seed, (examples) => ({ - ...oraclePropertyOptions(source.runs, id, { examples }), - seed, - })) + // One call selects and logs the seed; a replay reads that log line. + const parameters = oraclePropertyOptions(source.runs, id) + return runLane(`random seed`, parameters.seed!, (examples) => + withOracleExamples(parameters, examples), + ) }, } const pins = (source.pins ?? []).map((pin): PropertyTest => ({ @@ -869,7 +891,7 @@ function generatedTests( const missing = (pin.enters ?? []).filter((state) => !reached.has(state)) if (missing.length > 0) throw new Error( - `${id}: pin "${pin.name}" did not enter ${missing.join(`, `)}. A pin must reach every state it names.`, + `${id}: pin "${pin.name}" did not enter ${missing.join(`, `)}; it entered ${[...reached].sort().join(`, `) || `no declared state`}. A pin must reach every state it names.`, ) }, })) From f7e607dbb304afc978477ca909c06d5cbabe2727 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 12:01:51 -0700 Subject: [PATCH 018/168] test(oracles): move the offline interruption oracle onto oracleProperty, and repair port losses Interruption settlement now runs on oracleProperty, and its driver records a trace that a separate check judges. A new scheduled timing holds storage operations and provider answers for a data schedule (`scheduleFrom`): storage completes in order, and the provider may answer before or after any pending storage operation. So an interruption can land while a write or an answer is still outstanding. The framework's rerun checks found two defects in the old oracle. Its three "issued" examples used turn 50, which the generator cannot make (maximum 40). Its trace held random transaction IDs, which the trace now names by commit position. Loss audits of the four ports (old version against new, one fresh reader per port) found losses that this commit repairs: - result-shape: the previous-projection calibration covered one direction; it is now one calibration per direction. Rows and keyed state compare exactly with `differs`, keeping enumerable symbol keys. Each native observation records its framework's cut name. The printed law says only the shape is owed at the native cut. - handler-types: a diagnostic records its file name, not a boolean; a repeated diagnostic is printed; the owner names its coverage entry. - optimistic core: the model again checks that each request's owed outcome agrees with its transaction state; four more violations carry diff values; the limits name the acknowledged handler write. - interruption: 120 runs keep 60 per timing; the printed law includes the unhandled-rejection law; order, outbox, and unhandled use `differs`. Evidence for the interruption port: - Law: interruption at any point keeps admission, fencing, exactly-once provider calls in commit order, settlement, durability, and no unhandled rejection (offline-transactions README; the settlement and leadership oracles). - Reach: measured per lane; fixed seed 20251103 entered all eight landings (dispose refused 4, leadership refused 3, others 16 to 30); eight pins enter every landing on every run. - RED/Mutant: against the old oracle and the new one, on mutated production. Dropping the execution fence, dropping the admission check, or admitting before initialization fails both (new: laws fencing, admission). An un-awaited outbox deletion passes all 9 old tests and fails 2 new ones with law durability. - GREEN: 13 of 13 on unchanged production. - Limits: disjoint keys; one store; no overlapping executors. Design questions the audits raised (enumerated reach, behavior test titles, segment budget, uncompared evidence, several violations per history, owner validation) are recorded for the design rerun. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 4 +- .../tests/conformance/result-shape-oracle.ts | 128 ++-- .../mutation-handler-types-oracle.test.ts | 28 +- ...optimistic-history-oracle.property.test.ts | 2 +- .../db/tests/optimistic-history-oracle.ts | 18 + ...ruption-settlement-oracle.property.test.ts | 642 ++++++++++-------- 6 files changed, 493 insertions(+), 329 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 0486e0a0a5..1e4e141251 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -587,7 +587,7 @@ that test identifiers must copy production's private data structures. | Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | | PowerSync | Fixed tests only | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are covered by fixed tests in `correctness.test.ts`; no oracle owns them (tracked in #2264). | | SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared-handle driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | -| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. A reach check requires interruptions before admission, between admission and the provider call, and after it, for each interruption. Mutants that drop the execution loop's fence, resolve before deletion, drop the admission check, or check admission before awaiting initialization fail it. Open there: shared keys, providers that answer later, and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per lane for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight pins enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | | Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | | Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. | | Mixed-kind ordered prefixes | Bounded | Eager BasicIndex and BTreeIndex paths compare ascending and descending direct and live-query windows with an unindexed scan across mixed value kinds and paired source changes. One retained on-demand BTree history checks settled windows after a number/bigint transition, a finite request for a missing row, and a cursor request after index eligibility changes. Other provider paging histories remain open. | @@ -6414,7 +6414,7 @@ driver, and six calibrations (missing, duplicate, setup-error, rejected-void, unrelated same-code, and body-local Promise diagnostics) must each fail their named law. Its enumerated reach is no longer asserted per state (open: enumerated sources declare no reach yet). -All42 tests pass on unchanged858b174a6. Independent compiler probes reject +All42 tests pass on unchanged858b174a6; the `oracleProperty` port registers 43 (it adds a determinism test). Independent compiler probes reject unrelated same-code and body-local Promise errors substituted for handler errors. Source imports do not establish packed declarations or npm consumers; O22 owns that receiving boundary. Full evidence is in the RC chunk record. diff --git a/packages/db/tests/conformance/result-shape-oracle.ts b/packages/db/tests/conformance/result-shape-oracle.ts index b9d8891a3e..2a436de3e2 100644 --- a/packages/db/tests/conformance/result-shape-oracle.ts +++ b/packages/db/tests/conformance/result-shape-oracle.ts @@ -17,7 +17,7 @@ */ import fc from 'fast-check' import { oracleRuns } from '../oracle-config' -import { oracleProperty, pass, violation } from '../oracle-property' +import { differs, oracleProperty, pass, violation } from '../oracle-property' import { selectedRowKeys } from './result-laws' import { ScenarioLifetime } from './scenario-lifetime' import type { Observation } from '../oracle-property' @@ -175,8 +175,10 @@ function shapeModel(history: ShapeHistory) { // framework's native observation callback, in the history's read order, and // records the shape and the mode the query callback last compiled; after the // switch settles it records how many native observations ran and the settled -// projection. Rows are recorded as plain selected values; the driver -// decides nothing. +// projection. Rows are recorded as plain selected values, with the enumerable +// symbol keys a binding exposes; the driver decides nothing. Each native +// observation also records the framework's name for its native cut, which a +// failure prints. type Shape = `array` | `undefined` | `null` | `object` | `other` const shapeOf = (value: unknown): Shape => @@ -192,9 +194,10 @@ const shapeOf = (value: unknown): Shape => const plainRow = (row: unknown): unknown => row !== null && typeof row === `object` ? Object.fromEntries( - selectedRowKeys(row) - .filter((key) => typeof key === `string`) - .map((key) => [key, (row as Record)[key]]), + selectedRowKeys(row).map((key) => [ + key, + (row as Record)[key], + ]), ) : row const byId = (a: { id: unknown }, b: { id: unknown }) => @@ -207,6 +210,8 @@ type Settled = { state: Array<{ id: unknown; value: unknown }> | null } type Native = { + /** The framework's native cut, from its conformance driver. */ + cut: string shape: Shape compiledMode: Mode | undefined readError?: string @@ -269,13 +274,26 @@ function resultShapeDriver(driver: LiveQueryDriver) { if (history.order === `Collection first`) mounted.collection() const result = mounted.current() if (history.order === `data first`) mounted.collection() - native = { shape: shapeOf(result.data), compiledMode } + native = { + cut: driver.parameterObservationCut, + shape: shapeOf(result.data), + compiledMode, + } } catch (error) { - native = { shape: `other`, compiledMode, readError: String(error) } + native = { + cut: driver.parameterObservationCut, + shape: `other`, + compiledMode, + readError: String(error), + } } }) observe(`${index}: ${transition}; native`, { - ...(native ?? { shape: `other`, compiledMode }), + ...(native ?? { + cut: driver.parameterObservationCut, + shape: `other`, + compiledMode, + }), observations, } satisfies Native) settled(`${index}: ${transition}; settled`) @@ -298,7 +316,6 @@ const projects = (shape: Shape, mode: Mode) => mode === `list` ? shape === `array` : shape === `undefined` || shape === `object` -const json = (value: unknown) => JSON.stringify(value) function checkResultShape( _history: ShapeHistory, @@ -350,26 +367,26 @@ function checkResultShape( `current query projection`, { actual: seen.shape, expected: expected.mode }, ) + // The selected rows are owed in order. An undefined findOne records no + // rows, so an empty findOne that exposes a row fails here. const rows = expected.rows! - const selected = - expected.mode === `single` && rows.length === 0 - ? seen.shape === `undefined` - : json(seen.rows) === json(rows) - if (!selected) - return violation( + const keyed = rows.map((row) => ({ id: row.id, value: row })).sort(byId) + const wrong = + differs( checkpoint, - `selected rows ${json(seen.rows)}, owed ${json(rows)}`, `settled selected rows`, - { actual: seen.rows, expected: rows }, - ) - const keyed = rows.map((row) => ({ id: row.id, value: row })).sort(byId) - if (json(seen.state) !== json(keyed)) - return violation( + seen.rows, + rows, + `the selected rows differ from the source model`, + ) ?? + differs( checkpoint, - `keyed state ${json(seen.state)}, owed ${json(keyed)}`, `settled keyed rows`, - { actual: seen.state, expected: keyed }, + seen.state, + keyed, + `the keyed state differs from the source model`, ) + if (wrong) return wrong } for (const checkpoint of owed.keys()) if (!trace.some((entry) => entry.checkpoint === checkpoint)) @@ -425,29 +442,42 @@ const resultShapeHistories = { } // Wrong answers the check must reject: the previous mode's projection at a -// native cut, in either direction, and stale rows at a settled cut. -const resultShapeCalibrations = [ - { - name: `previous projection at the native cut`, - law: `current query projection`, - corrupt: (trace: ReadonlyArray) => { - const at = trace.findIndex(({ checkpoint }) => - checkpoint.endsWith(`native`), - ) - if (at < 0) return undefined - return trace.map((entry, index) => { - if (index !== at) return entry - const native = entry.value as Native - return { - ...entry, - value: { - ...native, - shape: native.shape === `array` ? `object` : `array`, - }, - } - }) - }, +// native cut, in each direction, and stale rows at a settled cut. Both wrong +// directions keep the selected rows and differ only in array-versus-row +// projection, which row normalization would erase. Each direction corrupts +// the first native cut that projects its mode, so the framework picks a +// candidate history that has one. +const previousProjection = ( + name: string, + current: (shape: Shape) => boolean, + previous: Shape, +) => ({ + name, + law: `current query projection`, + corrupt: (trace: ReadonlyArray) => { + const at = trace.findIndex( + ({ checkpoint, value }) => + checkpoint.endsWith(`native`) && current((value as Native).shape), + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { ...entry, value: { ...(entry.value as Native), shape: previous } } + : entry, + ) }, +}) +const resultShapeCalibrations = [ + previousProjection( + `a list keeps the findOne projection at the native cut`, + (shape) => shape === `array`, + `object`, + ), + previousProjection( + `a findOne keeps the list projection at the native cut`, + (shape) => shape === `object` || shape === `undefined`, + `array`, + ), { name: `stale rows at a settled cut`, law: `settled selected rows`, @@ -477,10 +507,10 @@ export function registerResultShapeOracle(driver: LiveQueryDriver): void { oracleProperty({ id: `framework.${id}.result-shape`, law: { - statement: `A binding projects data through its current authored query: a list exposes an array and findOne a row or undefined, at the native cut after a mode change and after it settles, where the selected rows and keyed state equal the source model.`, + statement: `A binding projects data through its current authored query: a list exposes an array and findOne a row or undefined, at the native cut after a mode change, where only the shape is owed, and after it settles, where the selected rows and keyed state equal the source model.`, authority: `packages/db/tests/conformance/contract.ts and each adapter's public result type`, - limits: `Enabled callback queries over one source of zero to four rows; no disabled inputs, errors, supplied Collections, or DbClient. No promise of synchronous row freshness or identical host scheduling.`, - owner: `Framework conformance`, + limits: `Enabled callback queries over one source of zero to four rows; disabled inputs, errors, supplied Collections, and DbClient stay with their existing conformance owners. No promise of synchronous row freshness or identical host scheduling.`, + owner: `Frameworks`, }, histories: resultShapeHistories, model: shapeModel, diff --git a/packages/db/tests/mutation-handler-types-oracle.test.ts b/packages/db/tests/mutation-handler-types-oracle.test.ts index 143312c178..e7f0dfcade 100644 --- a/packages/db/tests/mutation-handler-types-oracle.test.ts +++ b/packages/db/tests/mutation-handler-types-oracle.test.ts @@ -13,9 +13,9 @@ * Diagnostics must match exactly the rejected candidate lines; missing imports * or unrelated compiler failures never count as rejection evidence. Built * declarations, npm resolution, and actual application migrations remain - * separate receivers. + * separate receivers (the coverage map's O22 boundary). */ -import { resolve } from 'node:path' +import { relative, resolve } from 'node:path' import ts from 'typescript' import { oracleProperty, pass, violation } from './oracle-property' import type { Observation } from './oracle-property' @@ -255,10 +255,10 @@ function compileHandlerConsumer(source: string): ReadonlyArray { } // The driver compiles the plan's source and records every diagnostic as -// plain data: whether it is in the generated file, its line, span, code, and -// message. +// plain data: its file (the generated consumer, another file by name, or +// none), its line, span, code, and message. type Diagnostic = { - generated: boolean + file: string line: number | undefined start: number | undefined length: number @@ -272,7 +272,12 @@ function compileDriver( ) { const diagnostics = compileHandlerConsumer(plan.source).map( (diagnostic): Diagnostic => ({ - generated: diagnostic.file?.fileName === filename, + file: + diagnostic.file === undefined + ? `(no file)` + : diagnostic.file.fileName === filename + ? `(generated consumer)` + : relative(packageRoot, diagnostic.file.fileName), line: diagnostic.file && diagnostic.start !== undefined ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) @@ -359,10 +364,10 @@ function checkHandlerAdmission( const candidate = plan.candidates.find( (entry) => entry.line === diagnostic.line, ) - if (!diagnostic.generated || !candidate) + if (diagnostic.file !== `(generated consumer)` || !candidate) return violation( `diagnostics`, - `unexpected diagnostic at line ${diagnostic.line}: TS${diagnostic.code} ${diagnostic.message}`, + `unexpected diagnostic in ${diagnostic.file} at line ${diagnostic.line}: TS${diagnostic.code} ${diagnostic.message}`, `candidate-diagnostics`, ) if (admitted.get(candidate.id)) @@ -395,10 +400,11 @@ function checkHandlerAdmission( (diagnostic) => `${diagnostic.start}/${diagnostic.length}/${diagnostic.code}/${diagnostic.message}`, ) - if (new Set(texts).size !== texts.length) + const repeated = texts.filter((text, index) => texts.indexOf(text) !== index) + if (repeated.length > 0) return violation( `diagnostics`, - `a diagnostic was reported twice`, + `${repeated.length} of ${texts.length} diagnostics were reported again: ${repeated.join(`; `)}`, `distinct-diagnostics`, ) const owed = [...admitted] @@ -530,7 +536,7 @@ oracleProperty({ statement: `Query and Electric handlers admit exactly the results that Promise accepts: a handler that returns a value, or a synchronous handler that returns, is rejected at its CRUD property with a Promise diagnostic, and no other diagnostic appears.`, authority: `the approved RC handler migration and the public QueryCollectionConfig and ElectricCollectionConfig types`, limits: `Source entries only; built declarations, npm resolution, and application migrations are separate receivers.`, - owner: `Adapter handler types`, + owner: `RC public mutation-handler type admission`, }, histories: typeShapes, label: ({ adapter, key, scalar, schema, data }) => diff --git a/packages/db/tests/optimistic-history-oracle.property.test.ts b/packages/db/tests/optimistic-history-oracle.property.test.ts index 5462afb393..f2a47bce4f 100644 --- a/packages/db/tests/optimistic-history-oracle.property.test.ts +++ b/packages/db/tests/optimistic-history-oracle.property.test.ts @@ -186,7 +186,7 @@ oracleProperty({ law: { statement: `Optimistic and sync histories expose the rows, publications, request outcomes, receipts, and tracked transactions that the optimistic-history model owes at every step.`, authority: `packages/db/tests/optimistic-history-oracle.ts header (the optimistic and sync history contract); the public transaction contract for commit admission and terminal outcomes`, - limits: `The core grammar and production path only: no partial-update lane, schema default, pending manual edit, external completion, rejected handler, reentrant drain, ordered source operations, or wrapping sync.`, + limits: `The core grammar and production path only: no partial-update lane, schema default, pending manual edit, external completion, rejected handler, reentrant drain, ordered source operations, acknowledged handler write, or wrapping sync.`, owner: `Optimistic state`, }, histories: { diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index 6ad5a86017..cd5dc940e2 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -1903,6 +1903,20 @@ function replayOptimisticHistory(history: OptimisticHistory) { const visible = () => sorted(model.visible().values()) let cuts = [visible()] const owe = (checkpoint: string, extra: Partial = {}) => { + // The model keeps two accounts of each request, its owed outcome and its + // transaction state; they must agree, or the model itself is wrong. + for (const [index, entry] of model.transactions.entries()) { + const status = + entry.state === `pending` || entry.state === `persisting` + ? `pending` + : entry.state === `completed` + ? `fulfilled` + : `rejected` + if (outcomes[index]?.status !== status) + throw new Error( + `The model owes request ${index} ${outcomes[index]?.status} at ${checkpoint}, but its transaction is ${entry.state}`, + ) + } owed.set(checkpoint, { cuts, visible: visible(), @@ -2895,6 +2909,7 @@ export function checkOptimisticHistory( checkpoint, `external completion was ${seen.completion}, owed ${owed.completion}`, `external-completion`, + { actual: seen.completion, expected: owed.completion }, ) if (owed.settledRows && !same(seen.settledRows, owed.settledRows)) return violation( @@ -2983,6 +2998,7 @@ export function checkOptimisticHistory( checkpoint, `request ${index} state ${request.state}, owed ${owedRequest.state}`, `transaction-state`, + { actual: request.state, expected: owedRequest.state }, ) if (!same(request.modified, owedRequest.row)) return violation( @@ -3032,12 +3048,14 @@ export function checkOptimisticHistory( checkpoint, `tracked transactions ${JSON.stringify(tracked)}, unsettled ${JSON.stringify(owed.unsettled)}`, `tracked-transactions`, + { actual: tracked, expected: owed.unsettled }, ) if (owed.quiet && seen.deliveries !== deliveries) return violation( checkpoint, `${seen.deliveries - deliveries} deliveries for a settlement that changed no visible row`, `quiet-settlement`, + { actual: seen.deliveries, expected: deliveries }, ) deliveries = seen.deliveries } diff --git a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts index a0f465a79d..7eae4855f2 100644 --- a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts @@ -1,22 +1,25 @@ import fc from 'fast-check' -import { expect, it, vi } from 'vitest' +import { vi } from 'vitest' import { NonRetriableError } from '../src/types' import { - oraclePropertyOptions, - oracleReplayTest, -} from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' + differs, + oracleProperty, + pass, + scheduleFrom, + violation, + withinTurns, +} from '../../db/tests/oracle-property' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import { - atOracleCheckpoint, captureUnhandledRejections, cleanupOfflineOracle, } from './oracle-lifecycle' +import type { Observation } from '../../db/tests/oracle-property' import type { OnlineDetector } from '../src/types' import type { TestItem } from './harness' /** - * # Does an interruption at any microtask turn keep each offline transaction's + * # Does an interruption at any point keep each offline transaction's * settlement and durable ownership? * * Contract and source: the README and the settlement and leadership oracles. @@ -28,52 +31,63 @@ import type { TestItem } from './harness' * the provider fulfilled and the outbox row was durably removed. * * The other offline oracles step between named gates or full flushes. This one - * moves a single interruption across individual microtask turns of the - * commit pipeline (`persistTransaction`, outbox admission, scheduling, the - * provider call, the phase write and the deletion), where a removed `await` - * or a reordered step would change what the interruption sees. + * moves a single interruption across the commit pipeline (`persistTransaction`, + * outbox admission, scheduling, the provider call, the phase write and the + * deletion), where a removed `await` or a reordered step would change what the + * interruption sees. Two timings: + * + * - **Immediate:** the fake storage answers each operation in one turn and the + * provider answers within its call; the interruption lands after `turn` + * microtask turns (0-40, favoring the first few, where admission happens). + * - **Scheduled:** storage operations and provider responses wait for a data + * schedule. Storage completes its operations in order, as one store does; + * the provider may answer before or after any pending storage operation. + * The interruption lands after `steps` releases and then `turn` microtask + * turns, so it can fall while a write or an answer is still outstanding. * * History grammar: one to three manual transactions on disjoint keys, each * with a provider outcome (fulfil, or reject with `NonRetriableError`), commit - * back to back. After `turn` microtask turns (0-40, favoring the first few, - * where admission happens) one interruption occurs: - * leadership loss, going offline, or disposal. Recovery then restores the - * condition: leadership returns to the same executor, the detector goes back - * online, or, after disposal and once issued work has settled, a fresh - * executor starts over the same storage. + * back to back. One interruption occurs: leadership loss, going offline, or + * disposal. Recovery then restores the condition: leadership returns to the + * same executor, the detector goes back online, or, after disposal and once + * issued work has settled, a fresh executor starts over the same storage. * - * Model and refinement check, all from recorded public observations: - * - Admission: a commit that rejects with `NonRetriableError` before its - * provider call was not admitted; it must never reach the provider. Only - * the current leader admits work, so after leadership loss or disposal no + * The laws, judged from recorded public observations: + * - `admission`: a commit that rejects with `NonRetriableError` before its + * provider call was not admitted, and never reaches the provider. Only the + * current leader admits work, so after leadership loss or disposal no * outbox write for a new row may start. The admission check and the write * run in one synchronous step, so a write that starts after the * interruption means admission passed after it. Going offline does not * refuse admission. - * - Fencing: no provider call starts between the interruption and recovery. - * - Once: every admitted ID reaches the provider exactly once across both + * - `fencing`: no provider call starts between the interruption and recovery. + * - `once`: every admitted ID reaches the provider exactly once across both * executors, and calls follow commit order. - * - Settlement: after leadership returns or the detector goes online, every + * - `settlement`: after leadership returns or the detector goes online, every * admitted commit settles with its provider outcome. After disposal, the * old executor's waiter may stay pending (disposal ends it), but a settled * commit must still match its outcome. - * - Durability: when a commit fulfils, its row is already gone from storage, - * and after recovery the outbox is empty. - * - No rejection escapes as an unhandled process rejection. + * - `durability`: when a commit fulfils, its row is already gone from + * storage, and after recovery the outbox is empty. + * - `unhandled`: no rejection escapes as an unhandled process rejection. * - * Reach: each history reports where the interruption landed for each - * transaction: before admission, after admission but before its provider - * call, or after the call. The campaign requires every reachable position for - * every interruption (going offline cannot refuse admission), so the turn - * range cannot silently fall entirely before or after the pipeline. + * Reach: where the interruption landed for each transaction: before + * admission (`refused`), after admission but before its provider call started + * (`queued`), or after the call started (`issued`), whether or not the + * provider has answered, per interruption. Pins enter every + * reachable landing; going offline cannot refuse admission. * * Limits: disjoint keys only, so sibling rollback is the settlement oracle's; - * the provider answers within its call, and fake storage answers each - * operation in one turn, so this proves ordering against this adapter, not a - * native storage engine. Overlapping old and new executors are not generated: - * exactly-once execution across independent leaders is not promised. + * fake storage over one store, so this proves ordering against this adapter, + * not a native storage engine. Overlapping old and new executors are not + * generated: exactly-once execution across independent leaders is not + * promised. */ +// --------------------------------------------------------------------------- +// History +// --------------------------------------------------------------------------- + type Outcome = `fulfil` | `reject` type Interruption = `leadership` | `offline` | `dispose` type Landing = `refused` | `queued` | `issued` @@ -81,12 +95,10 @@ type History = { outcomes: Array interruption: Interruption turn: number + /** Null for immediate storage and provider; otherwise a data schedule. */ + schedule: { choices: Array; steps: number } | null } -// A path replay of this property runs only the generated campaign, from its -// recorded seed and path. -const interruptionProperty = `offline.interruption-settlement` - const history: fc.Arbitrary = fc.record({ outcomes: fc.array(fc.constantFrom(`fulfil`, `reject`), { minLength: 1, @@ -102,8 +114,26 @@ const history: fc.Arbitrary = fc.record({ fc.integer({ min: 0, max: 4 }), fc.integer({ min: 0, max: 40 }), ), + schedule: fc.option( + fc.record({ + choices: fc.array(fc.nat(3), { maxLength: 12 }), + steps: fc.nat(8), + }), + { freq: 2 }, + ), }) +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- +// +// The driver commits the transactions back to back, lets the pipeline run +// for the history's timing, interrupts, lets issued work settle, recovers, +// and lets the outbox drain. It records the phase each outbox admission +// write and each provider call started in, each commit's result, the stored +// keys when each commit fulfilled, the outbox after recovery, and unhandled +// rejections. + class ControlledDetector implements OnlineDetector { online = true private listeners = new Set<() => void>() @@ -126,40 +156,72 @@ class ControlledDetector implements OnlineDetector { } } -const settle = async () => { - for (let index = 0; index < 10; index++) - await new Promise((resolve) => setTimeout(resolve, 0)) +type Phase = `before` | `fenced` | `after` +type CommitResult = + | { kind: `fulfilled`; storedAtFulfil: Array } + | { kind: `non-retriable`; message: string } + | { kind: `error`; message: string } + | { kind: `pending` } +type Run = { + admissions: Array + calls: Array<{ id: string; phase: Phase }> + commits: Array<{ id: string; result: CommitResult }> + outbox: Array + unhandled: Array } -async function checkInterruption({ - outcomes, - interruption, - turn, -}: History): Promise> { - // The phase in which each provider call or outbox write started. - let phase: `before` | `fenced` | `after` = `before` - // Outbox writes of rows not stored before, with the phase each started in. - const admissions: Array<`before` | `fenced` | `after`> = [] +async function driveInterruption( + { outcomes, interruption, turn, schedule: timing }: History, + observe: (checkpoint: string, value: unknown) => void, +) { + // A permanent provider failure logs once by design; this law ignores logs. + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const schedule = timing ? scheduleFrom(timing.choices) : undefined + const settle = async () => { + for (let index = 0; index < 10; index++) { + await schedule?.waitIdle() + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + let phase: Phase = `before` + const admissions: Array = [] + // Under a schedule, an operation's effect waits for its release; storage + // completes its operations in the order they were made. + const later = (label: string, effect: () => Promise) => + schedule + ? schedule.schedule(undefined, label, `storage`).then(effect) + : effect() const storage = new (class extends FakeStorageAdapter { override async set(key: string, value: string): Promise { if (!(key in this.snapshot())) admissions.push(phase) - return super.set(key, value) + return later(`storage set ${key}`, () => super.set(key, value)) + } + override async delete(key: string): Promise { + return later(`storage delete ${key}`, () => super.delete(key)) } })() const unhandled = captureUnhandledRejections() - // Provider calls from either executor, in order, with the phase each - // started in. - const calls: Array<{ id: string; phase: `before` | `fenced` | `after` }> = [] - const outcomeById = new Map() - const mutationFn = (params: { - transaction: { id: string } - idempotencyKey: string - }) => { - const id = params.transaction.id + const calls: Array<{ id: string; phase: Phase }> = [] + // Transaction IDs are random, so the trace names each transaction by its + // commit position, here and in storage keys. + const names = new Map() + const named = (text: string) => + [...names].reduce( + (renamed, [id, name]) => renamed.replaceAll(id, name), + text, + ) + const mutationFn = (params: { transaction: { id: string } }) => { + const id = names.get(params.transaction.id)! calls.push({ id, phase }) - if (outcomeById.get(id) === `reject`) - throw new NonRetriableError(`provider rejected ${id}`) - return { ok: true } + const answer = () => { + if (outcomes[Number(id.slice(3))] === `reject`) + throw new NonRetriableError(`provider rejected ${id}`) + return { ok: true } + } + // Under a schedule, the answer waits for its release. + return schedule + ? schedule.schedule(undefined, `provider ${id}`, `provider`).then(answer) + : answer() } const detector = new ControlledDetector() const first = createTestOfflineEnvironment({ @@ -170,16 +232,13 @@ async function checkInterruption({ let second: ReturnType | undefined const commits: Array<{ id: string - outcome: Outcome - // A snapshot of the stored keys when the commit fulfilled. - storedAtFulfil?: Array - result: Promise<`fulfilled` | unknown> - settled: boolean + result: CommitResult + settled: Promise }> = [] let hasPrimaryFailure = false try { await first.waitForLeader() - for (const [index, outcome] of outcomes.entries()) { + for (const index of outcomes.keys()) { const offline = first.executor.createOfflineTransaction({ mutationFnName: first.mutationFnName, autoCommit: false, @@ -191,29 +250,32 @@ async function checkInterruption({ updatedAt: new Date(1700000000000), } const transaction = offline.mutate(() => first.collection.insert(row)) - outcomeById.set(transaction.id, outcome) + names.set(transaction.id, `tx ${index}`) const entry: (typeof commits)[number] = { - id: transaction.id, - outcome, - result: Promise.resolve(), - settled: false, + id: `tx ${index}`, + result: { kind: `pending` }, + settled: Promise.resolve(), } - entry.result = offline.commit().then( + entry.settled = offline.commit().then( () => { - entry.settled = true - entry.storedAtFulfil = Object.keys(storage.snapshot()) - return `fulfilled` + entry.result = { + kind: `fulfilled`, + storedAtFulfil: Object.keys(storage.snapshot()).map(named), + } }, (error: unknown) => { - entry.settled = true - return error + entry.result = + error instanceof NonRetriableError + ? { kind: `non-retriable`, message: named(error.message) } + : { kind: `error`, message: named(String(error)) } }, ) commits.push(entry) } - // Inline, so turn 0 interrupts in the same task as the commits and each // later turn is exactly one microtask. + for (let index = 0; index < (timing?.steps ?? 0); index++) + await schedule!.waitOne() for (let index = 0; index < turn; index++) await Promise.resolve() phase = `fenced` if (interruption === `leadership`) first.leader.setLeader(false) @@ -229,75 +291,21 @@ async function checkInterruption({ await second.waitForLeader() } await settle() - - const results = await atOracleCheckpoint( - Promise.all( - commits.map((entry) => - entry.settled || interruption !== `dispose` - ? entry.result - : Promise.resolve(`pending`), - ), - ), - `commits after recovery`, - ) - - expect( - calls.filter((call) => call.phase === `fenced`), - `provider calls between interruption and recovery`, - ).toEqual([]) - if (interruption !== `offline`) - expect( - admissions.filter((started) => started === `fenced`), - `admissions after the interruption`, - ).toEqual([]) - const calledIds = calls.map((call) => call.id) - expect(new Set(calledIds).size, `provider calls per ID`).toBe( - calledIds.length, - ) - const landings: Array = [] - for (const [index, entry] of commits.entries()) { - const result = results[index] - const called = calledIds.includes(entry.id) - const notAdmitted = !called && result instanceof NonRetriableError - landings.push( - notAdmitted - ? `refused` - : calls.find((call) => call.id === entry.id)?.phase === `before` - ? `issued` - : `queued`, - ) - if (notAdmitted) continue - expect(called, `admitted ${entry.id} reached the provider`).toBe(true) - if (result === `pending`) { - expect(interruption, `only disposal leaves a waiter pending`).toBe( - `dispose`, - ) - continue - } - if (entry.outcome === `fulfil`) { - expect(result, `commit of ${entry.id}`).toBe(`fulfilled`) - expect( - entry.storedAtFulfil?.some((key) => key.includes(entry.id)), - `row of ${entry.id} stored when its commit fulfilled`, - ).toBe(false) - } else { - expect(result, `commit of ${entry.id}`).toBeInstanceOf( - NonRetriableError, - ) - expect((result as Error).message).toBe(`provider rejected ${entry.id}`) - } - } - const order = commits.map((entry) => entry.id) - expect(calledIds, `provider calls follow commit order`).toEqual( - order.filter((id) => calledIds.includes(id)), - ) - expect(Object.keys(storage.snapshot()), `outbox after recovery`).toEqual([]) - expect(unhandled.rejections, `unhandled rejections`).toEqual([]) - return landings + // A waiter that never settles stays pending; the check decides whether + // that is allowed. + for (const entry of commits) await withinTurns(entry.settled, 20) + observe(`run`, { + admissions, + calls, + commits: commits.map(({ id, result }) => ({ id, result })), + outbox: Object.keys(storage.snapshot()).map(named), + unhandled: unhandled.rejections.map(String), + } satisfies Run) } catch (error) { hasPrimaryFailure = true throw error } finally { + warn.mockRestore() unhandled.cleanup() await cleanupOfflineOracle( [ @@ -311,128 +319,230 @@ async function checkInterruption({ } } -declareOracle({ check: [checkInterruption] }) +// --------------------------------------------------------------------------- +// Model and reach +// --------------------------------------------------------------------------- +// +// Each transaction owes its provider outcome; the interruption decides which +// laws apply. Where a transaction's interruption landed is read from the +// trace: whether it reached the provider, and in which phase. -/** - * Fixed histories designed to reach each interruption landing deterministically. - * Each named history specifies: a specific turn, outcomes, and transaction count - * that guarantee reaching a particular landing (issued, queued, or refused) with a - * specific interruption type. - * - * Landings: - * - "issued": provider call starts before interruption (phase before) - * - "queued": provider call starts after interruption (phase fenced or after) - * - "refused": outbox write never starts; admission is denied after interruption; - * commit fails with NonRetriableError - * - * For "refused" to occur with leadership loss or disposal, the outbox write must - * attempt to start after the interruption is already in place (during fenced phase). - * By using multiple transactions and appropriate turn timing, we ensure the later - * transaction's outbox write attempt reaches the fenced phase, where it is denied. - */ -const fixedLandings: Array<{ - name: string - history: History - expectedLanding: Landing -}> = [ - // Leadership landings - { - name: `leadership issued`, - history: { outcomes: [`fulfil`], interruption: `leadership`, turn: 50 }, - expectedLanding: `issued`, - }, - { - name: `leadership queued`, - history: { outcomes: [`fulfil`], interruption: `leadership`, turn: 1 }, - expectedLanding: `queued`, - }, - // For refused: two transactions, interrupt early so second one's outbox write - // reaches the fenced phase, and its outcome is reject so commit fails. - { - name: `leadership refused`, - history: { - outcomes: [`fulfil`, `reject`], - interruption: `leadership`, - turn: 0, - }, - expectedLanding: `refused`, - }, - // Offline landings (cannot be refused: going offline does not fence admission) - { - name: `offline issued`, - history: { outcomes: [`fulfil`], interruption: `offline`, turn: 50 }, - expectedLanding: `issued`, - }, - { - name: `offline queued`, - history: { outcomes: [`fulfil`], interruption: `offline`, turn: 1 }, - expectedLanding: `queued`, - }, - // Dispose landings - { - name: `dispose issued`, - history: { outcomes: [`fulfil`], interruption: `dispose`, turn: 50 }, - expectedLanding: `issued`, - }, - { - name: `dispose queued`, - history: { outcomes: [`fulfil`], interruption: `dispose`, turn: 1 }, - expectedLanding: `queued`, - }, - // For refused: two transactions, second one's outbox write reaches fenced phase. - { - name: `dispose refused`, - history: { - outcomes: [`fulfil`, `reject`], - interruption: `dispose`, - turn: 0, - }, - expectedLanding: `refused`, - }, -] +function landings(run: Run): Array { + return run.commits.map(({ id, result }) => { + const call = run.calls.find((entry) => entry.id === id) + if (!call && result.kind === `non-retriable`) return `refused` + return call?.phase === `before` ? `issued` : `queued` + }) +} -/** - * Deterministic test: each fixed history must reach its expected landing every time. - * This proves that each landing is reachable and that the oracle observes them correctly. - * A mutant that removes refusal paths or makes landings unreachable must fail here. - */ -it.each(fixedLandings)( - `landing $name is reachable: reaches $expectedLanding`, - async ({ history: fixedHistory, expectedLanding, name }) => { - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) - try { - const landings = await checkInterruption(fixedHistory) - // For "refused" cases (which use two transactions), check the second transaction. - // For others, check the first. - const transactionIndex = name.includes(`refused`) ? 1 : 0 - expect(landings[transactionIndex], `${name} landing`).toBe( - expectedLanding, - ) - } finally { - warn.mockRestore() - } - }, - 60_000, +const landingStates = ([`leadership`, `offline`, `dispose`] as const).flatMap( + (interruption) => + (interruption === `offline` + ? ([`issued`, `queued`] as const) + : ([`refused`, `issued`, `queued`] as const) + ).map((landing) => `${interruption} ${landing}`), ) -oracleReplayTest(it, interruptionProperty)( - `keeps settlement and ownership for an interruption at any microtask turn`, - async () => { - // A permanent provider failure logs once by design; this law ignores logs. - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) - try { - await fc.assert( - fc.asyncProperty(history, async (generated) => { - // Settlement and ownership must hold for every generated history, - // regardless of which landing it reaches. Coverage is verified by the - // fixed tests above; this campaign proves the core law. - await checkInterruption(generated) - }), - oraclePropertyOptions(60, interruptionProperty), +function interruptionModel({ outcomes }: History) { + return { outcomes } +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- + +function checkInterruption( + { interruption }: History, + trace: ReadonlyArray, + { outcomes }: ReturnType, +) { + const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as + Run | undefined + if (!run) return violation(`run`, `the driver recorded no run`, `trace`) + if (run.calls.some((call) => call.phase === `fenced`)) + return violation( + `run`, + `a provider call started between the interruption and recovery: ${JSON.stringify(run.calls)}`, + `fencing`, + ) + if (interruption !== `offline` && run.admissions.includes(`fenced`)) + return violation( + `run`, + `an outbox admission started after the interruption: ${JSON.stringify(run.admissions)}`, + `admission`, + ) + const calledIds = run.calls.map((call) => call.id) + if (new Set(calledIds).size !== calledIds.length) + return violation( + `run`, + `an ID reached the provider twice: ${JSON.stringify(calledIds)}`, + `once`, + ) + for (const [index, { id, result }] of run.commits.entries()) { + const called = calledIds.includes(id) + if (!called && result.kind === `non-retriable`) continue + if (!called) + return violation( + `run`, + `admitted ${id} never reached the provider; its commit is ${JSON.stringify(result)}`, + `settlement`, ) - } finally { - warn.mockRestore() + if (result.kind === `pending`) { + if (interruption !== `dispose`) + return violation( + `run`, + `${id} is still pending after recovery from ${interruption}`, + `settlement`, + ) + continue } + if (outcomes[index] === `fulfil`) { + if (result.kind !== `fulfilled`) + return violation( + `run`, + `${id} owed fulfilment, got ${JSON.stringify(result)}`, + `settlement`, + ) + if (result.storedAtFulfil.some((key) => key.includes(id))) + return violation( + `run`, + `${id} fulfilled while its row was still stored`, + `durability`, + ) + } else if ( + result.kind !== `non-retriable` || + result.message !== `provider rejected ${id}` + ) + return violation( + `run`, + `${id} owed its provider rejection, got ${JSON.stringify(result)}`, + `settlement`, + ) + } + const order = run.commits + .map(({ id }) => id) + .filter((id) => calledIds.includes(id)) + return ( + differs( + `run`, + `once`, + calledIds, + order, + `provider calls do not follow commit order`, + ) ?? + differs( + `run`, + `durability`, + run.outbox, + [], + `the outbox still holds rows after recovery`, + ) ?? + differs( + `run`, + `unhandled`, + run.unhandled, + [], + `a rejection escaped as an unhandled rejection`, + ) ?? + pass + ) +} + +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +// Fixed histories that reach each landing: a late interruption lands after +// the provider call (`issued`), an early one before it (`queued`), and with +// two transactions an immediate one stops the second admission (`refused`). +const landingPin = ( + name: string, + interruption: Interruption, + outcomes: Array, + turn: number, +) => ({ + name, + enters: [name], + history: { outcomes, interruption, turn, schedule: null } satisfies History, +}) + +oracleProperty({ + id: `offline.interruption-settlement`, + law: { + statement: `Leadership loss, going offline, or disposal at any point of the commit pipeline keeps each offline transaction's admission, fencing, exactly-once provider call in commit order, settlement with its provider outcome, and durable ownership, with no unhandled rejection.`, + authority: `the offline-transactions README, and the settlement and leadership oracles`, + limits: `Disjoint keys (sibling rollback is the settlement oracle's); one to three transactions; fake storage over one store; no overlapping old and new executors, since exactly-once execution across independent leaders is not promised.`, + owner: `Offline execution`, }, - 120_000, -) + histories: { + arbitrary: history, + measured: landingStates, + // Half of the generated histories are immediate and half scheduled, so + // each timing keeps the 60 runs the immediate-only oracle had. + runs: 120, + fixedSeeds: [20251103], + pins: [ + landingPin(`leadership issued`, `leadership`, [`fulfil`], 40), + landingPin(`leadership queued`, `leadership`, [`fulfil`], 1), + landingPin(`leadership refused`, `leadership`, [`fulfil`, `reject`], 0), + landingPin(`offline issued`, `offline`, [`fulfil`], 40), + landingPin(`offline queued`, `offline`, [`fulfil`], 1), + landingPin(`dispose issued`, `dispose`, [`fulfil`], 40), + landingPin(`dispose queued`, `dispose`, [`fulfil`], 1), + landingPin(`dispose refused`, `dispose`, [`fulfil`, `reject`], 0), + ], + }, + model: interruptionModel, + reached: ({ interruption }, trace) => { + const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as + Run | undefined + return run + ? [ + ...new Set( + landings(run).map((landing) => `${interruption} ${landing}`), + ), + ] + : [] + }, + driver: driveInterruption, + check: checkInterruption, + calibrations: [ + { + name: `a provider call during the interruption`, + law: `fencing`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `run` && (entry.value as Run).calls.length + ? { + ...entry, + value: { + ...(entry.value as Run), + calls: (entry.value as Run).calls.map((call, index) => + index === 0 ? { ...call, phase: `fenced` } : call, + ), + }, + } + : entry, + ), + }, + { + name: `a repeated provider call`, + law: `once`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `run` && (entry.value as Run).calls.length + ? { + ...entry, + value: { + ...(entry.value as Run), + calls: [ + ...(entry.value as Run).calls, + (entry.value as Run).calls[0]!, + ], + }, + } + : entry, + ), + }, + ], +}) From 7116079c3b13196d69fa0bd451518563ba330001 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 12:24:45 -0700 Subject: [PATCH 019/168] test(oracles): move the persisted optimistic lane onto oracleProperty The persisted lane ran the shared optimistic model through `runOptimisticHistory` with a wrapping-sync lane. It is now the `oracleProperty` `sqlite-persistence.optimistic-history`: the split model, check, and seven calibrations, with the persisted adapter as a per-history driver over the wrapping-sync grammar. The framework's membership check found that no pin or segment could be a member of that grammar: `abortStep`, `acknowledgedEdit`, the step list's settle-and-observe tail, and the core `handlerSourceBatch` were mapped without unmappers, and `storeStep` was a constant of objects, which fast-check compares by identity. Only 38 of 50 generated histories were members of their own language. Each now has an unmapper or is a record; all generated histories are members. The core grammar generates the same values. - Law: a persisted Collection exposes what the optimistic-history model owes at every step, while a durable store step is held and after a deferred source transaction aborts (optimistic-history-oracle.ts header). - Reach: `deferred-hold` and `abort-release` are required through two segments. A model-only search over 2,000 generated contexts found segments that enter them every time: a hold after the generated body, and an abort that comes first and releases the store itself. Fixed seed 22012 entered deferred-hold 24 times and abort-release 12. - RED/Mutant (old lane against new, on mutated production): a wrapper that reports no deferred keys to core fails both (new: law settled-rows); a discarded deferred transaction that keeps its held rows fails both (new: law rows). - GREEN: 11 of 11; the core owner and three legacy owners that share the grammar still pass (28 and 337). - Limits: an in-memory adapter; no awaited acceptance during a hold; no schema, partial-update, or reentrant lane. The old pinned history set only `a` on acknowledged edits, outside the grammar; it now sets the whole server row. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- ...optimistic-history-oracle.property.test.ts | 328 ++++++++++++++++++ .../persisted-optimistic-history.test.ts | 254 -------------- .../db/tests/optimistic-history-oracle.ts | 142 +++++--- packages/db/tests/oracle-replay-manifest.ts | 2 +- 5 files changed, 426 insertions(+), 302 deletions(-) create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts delete mode 100644 packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 1e4e141251..3589bfeccf 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -3116,7 +3116,7 @@ The [optimistic-history oracle](https://github.com/TanStack/db/blob/main/package now owns the held-row law for sync transactions deferred behind a wrapping sync's durable step, and checks that every optimistic mutation is visible in the tick that makes it. Its -[persisted lane](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts) +[persisted lane](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts) runs the shared grammar through `persistedCollectionOptions` with `hold-store` and `release-store` steps and a handler that commits its own server row. It was RED before the wrapper reported deferred sync keys to diff --git a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts new file mode 100644 index 0000000000..64c4388645 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts @@ -0,0 +1,328 @@ +import { + createCollection, + createLiveQueryCollection, + createTransaction, + whenSyncAccepted, +} from '@tanstack/db' +import { + checkOptimisticHistory, + optimisticHistoryCalibrations, + optimisticHistoryDriver, + optimisticHistoryModel, + optimisticHistoryReach, + optimisticHistoryStates, + resolveOptimisticHistory, + wrappingSteps, + wrappingSyncHistory, +} from '../../db/tests/optimistic-history-oracle' +import { oracleProperty } from '../../db/tests/oracle-property' +import { persistedCollectionOptions } from '../src' +import type { CollectionConfig } from '@tanstack/db' +import type { + HistoryRow, + OptimisticStep, + WrappingSyncLane, +} from '../../db/tests/optimistic-history-oracle' +import type { PersistedTx, PersistenceAdapter } from '../src' + +/** + * # Persisted Collections keep the optimistic-history laws + * + * The optimistic-history oracle states which rows optimistic and sync + * histories expose. Persistence is one production path for those laws: a + * wrapping sync between the source and the Collection. This property runs the + * oracle's model, check, and calibrations through `persistedCollectionOptions` + * with an in-memory adapter, over the wrapping-sync grammar. + * + * The persisted wrapper stores source transactions one at a time. The + * `hold-store` step holds the next stored source transaction; source + * transactions committed after it reach the Collection only after + * `release-store`. That is the model's `deferred` state. The two laws under + * test are the oracle's own: an optimistic mutation is visible in the tick + * that makes it, and a settled optimistic row stays over the synced rows while + * a committed sync transaction for its key has not applied, deferred or not. + * + * `abort-deferred` aborts one deferred transaction's signal. It never applies: + * the wrapper discards it when its turn comes, and every settled row it alone + * held drops in that publication. The model fixes that drop at the turn; an + * implementation that dropped the hold at abort time would also be legal, and + * this lane would reject it. + * + * Reach: two segments make the lane's own states required on every run. A + * hold across a drain (`deferred-hold`) comes after a generated history, so + * the hold meets whatever that history left; an abort that releases a held + * row (`abort-release`) comes first and releases the store itself, because a + * later settle could pick a transaction the generated part left active. + * + * Grammar limits: a handler cannot await acceptance while a hold is + * outstanding, because acceptance follows the held durable step; the grammar + * clears `awaitReceipt` there, and its unmapper admits only such histories. + * The schema, partial-update, and reentrant lanes stay core-only. The adapter + * is a Map, so SQLite timing and coordinator messages are outside this lane. + */ +function createHoldingAdapter() { + const rows = new Map() + let holdNext = false + let release: (() => void) | undefined + let latest = { term: 0, seq: 0, rowVersion: 0 } + const adapter: PersistenceAdapter = { + loadSubset: () => + Promise.resolve( + Array.from(rows, ([key, value]) => ({ key, value: { ...value } })), + ), + loadResumeSnapshot: () => + Promise.resolve({ + rows: [], + keySet: { status: `consistent` }, + collectionMetadata: [], + latestTerm: latest.term, + latestSeq: latest.seq, + latestRowVersion: latest.rowVersion, + resetEpoch: 0, + }), + loadCollectionMetadata: () => Promise.resolve([]), + applyCommittedTx: async (_target, tx: PersistedTx) => { + if (holdNext && (tx.truncate || tx.mutations.length > 0)) { + holdNext = false + await new Promise((resolve) => { + release = resolve + }) + } + if (tx.truncate) rows.clear() + for (const mutation of tx.mutations) { + if (mutation.type === `delete`) rows.delete(mutation.key) + else + rows.set(mutation.key, { + ...rows.get(mutation.key), + ...mutation.value, + } as HistoryRow) + } + latest = { term: tx.term, seq: tx.seq, rowVersion: tx.rowVersion } + }, + ensureIndex: () => Promise.resolve(), + } + return { + adapter, + rows, + holdNextStore: () => { + holdNext = true + }, + releaseStore: () => { + holdNext = false + release?.() + release = undefined + }, + } +} + +let laneRun = 0 + +function persistedLane(): WrappingSyncLane & { + rows: Map +} { + const store = createHoldingAdapter() + const id = `persisted-optimistic-history-${++laneRun}` + return { + db: { + createCollection, + createLiveQueryCollection, + createTransaction, + whenSyncAccepted, + }, + rows: store.rows, + wrap: (config) => + persistedCollectionOptions({ + ...(config as CollectionConfig), + id, + persistence: { adapter: store.adapter }, + }) as unknown as CollectionConfig, + holdNextStore: store.holdNextStore, + releaseStore: store.releaseStore, + // The wrapper's turns are promise and timer continuations. + settle: async () => { + for (let turn = 0; turn < 10; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + }, + } +} + +// A handler commits its server row and returns without awaiting acceptance; +// the source's acknowledgement came at commit. Each edit's fields are its +// whole server row, as the grammar's acknowledged edits are. The insert's stored row holds +// the store, so the update's server row is deferred when both settle. +const insertThenUpdateBehindHeldStore: Array = [ + { type: `hold-store` }, + { + type: `edit`, + key: 1, + fields: { a: 1, b: 0, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [{ id: 1, a: 1, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + }, + { + type: `edit`, + key: 1, + fields: { a: 2, b: 0, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [{ id: 1, a: 2, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `release-store` }, +] + +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +/** A wrapping history's generated body, without its settle-and-observe tail. */ +const bodyOf = (history: { steps: Array }) => + history.steps.slice(0, history.steps.length - wrappingSteps([]).length) +const row = (id: number, a: number): HistoryRow => ({ id, a, b: 0, c: 0 }) + +// A hold across a drain: the store holds a truncating source transaction, an +// optimistic edit commits its server row in its handler, and that row waits +// behind the held store when the tail settles. +const holdAcrossDrain: Array = [ + { type: `hold-store` }, + { type: `close`, commit: false }, + { + type: `sync`, + rows: [ + { id: 3, a: 1, b: -2, c: 0 }, + { id: 1, a: -2, b: 1, c: 0 }, + ], + deletes: [1, 3], + truncate: true, + copies: 1, + }, + { + type: `edit`, + key: 3, + fields: { a: 1 }, + optimistic: true, + inHandler: undefined, + }, + { + type: `edit`, + key: 1, + fields: { a: 2, b: -1, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [{ id: 1, a: 2, b: -1, c: 0 }], + truncate: false, + copies: 1, + }, + }, +] + +// An abort that releases a held row: the store holds key 2's source +// transaction, key 1's handler commits its server row behind it, the edit +// settles (its row is held by that deferred row), and aborting the deferred +// row releases the hold at its turn. The edit's value differs from the +// initial row 1, so the edit always changes the row. +const abortReleasingHold = ( + initial: Array, +): Array => { + const a = initial.find((entry) => entry.id === 1)?.a === 1 ? 2 : 1 + return [ + { type: `hold-store` }, + { + type: `sync`, + rows: [row(2, 1)], + deletes: [], + truncate: false, + copies: 1, + }, + { + type: `edit`, + key: 1, + fields: { a, b: 0, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [row(1, a)], + truncate: false, + copies: 1, + }, + }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `abort-deferred`, slot: 0 }, + { type: `release-store` }, + ] +} + +oracleProperty({ + id: `sqlite-persistence.optimistic-history`, + law: { + statement: `A persisted Collection exposes the rows, publications, request outcomes, receipts, and tracked transactions that the optimistic-history model owes at every step, including while a durable store step is held and after a deferred source transaction aborts.`, + authority: `packages/db/tests/optimistic-history-oracle.ts header (the optimistic and sync history contract, held rows behind a wrapping sync)`, + limits: `An in-memory adapter, not SQLite timing or coordinator messages; no awaited acceptance during a hold; no schema, partial-update, or reentrant lane.`, + owner: `Optimistic state`, + }, + histories: { + arbitrary: wrappingSyncHistory, + states: [`deferred-hold`, `abort-release`], + measured: optimisticHistoryStates.filter( + (state) => state !== `deferred-hold` && state !== `abort-release`, + ), + runs: 40, + fixedSeeds: [22012], + segments: [ + { + name: `a hold across a drain`, + enters: [`deferred-hold`], + build: (generated) => ({ + initial: generated.initial, + steps: wrappingSteps([ + ...bodyOf(generated).slice(0, 24 - holdAcrossDrain.length), + ...holdAcrossDrain, + ]), + }), + }, + { + name: `an abort that releases a held row`, + enters: [`abort-release`], + build: (generated) => ({ + initial: generated.initial, + steps: wrappingSteps( + [ + ...abortReleasingHold(generated.initial), + ...bodyOf(generated), + ].slice(0, 24), + ), + }), + }, + ], + pins: [ + { + name: `a settled update stays over its insert while the update's server row waits behind a held store`, + history: { + initial: [], + steps: wrappingSteps(insertThenUpdateBehindHeldStore), + }, + }, + ], + }, + resolve: resolveOptimisticHistory, + model: optimisticHistoryModel, + reached: optimisticHistoryReach, + // Each history gets its own adapter and Collection id. + driver: (plan, observe) => + optimisticHistoryDriver({ wrapping: persistedLane() })(plan, observe), + check: checkOptimisticHistory, + calibrations: optimisticHistoryCalibrations, + timeout: 60_000, +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts deleted file mode 100644 index 6f57096fb2..0000000000 --- a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { fc, test as fcTest } from '@fast-check/vitest' -import { expect, it } from 'vitest' -import { - createCollection, - createLiveQueryCollection, - createTransaction, - whenSyncAccepted, -} from '@tanstack/db' -import { - runOptimisticHistory, - wrappingSyncHistory, -} from '../../db/tests/optimistic-history-oracle' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' -import { persistedCollectionOptions } from '../src' -import type { CollectionConfig } from '@tanstack/db' -import type { - HistoryRow, - OptimisticStep, - WrappingSyncLane, -} from '../../db/tests/optimistic-history-oracle' -import type { PersistedTx, PersistenceAdapter } from '../src' - -/** - * The optimistic-history oracle states which rows optimistic and sync - * histories expose. Persistence is one production path for those laws: a - * wrapping sync between the source and the Collection. This file runs the - * oracle's model, grammar, and comparisons through `persistedCollectionOptions` - * with an in-memory adapter. - * - * The persisted wrapper stores source transactions one at a time. The - * `hold-store` step holds the next stored source transaction; source - * transactions committed after it reach the Collection only after - * `release-store`. That is the model's `deferred` state. The two laws under - * test are the oracle's own: an optimistic mutation is visible in the tick - * that makes it, and a settled optimistic row stays over the synced rows while - * a committed sync transaction for its key has not applied, deferred or not. - * - * `abort-deferred` aborts one deferred transaction's signal. It never applies: - * the wrapper discards it when its turn comes, and every settled row it alone - * held drops in that publication. The model fixes that drop at the turn; an - * implementation that dropped the hold at abort time would also be legal, and - * this lane would reject it. The last test checks that the fixed campaign - * reached a hold across a drain and a hold released by an abort. - * - * Grammar limits: a handler cannot await acceptance while a hold is - * outstanding, because acceptance follows the held durable step. The schema, - * partial-update, and reentrant lanes stay core-only. The adapter is a Map, - * so SQLite timing and coordinator messages are outside this lane. - */ -function createHoldingAdapter() { - const rows = new Map() - let holdNext = false - let release: (() => void) | undefined - let latest = { term: 0, seq: 0, rowVersion: 0 } - const adapter: PersistenceAdapter = { - loadSubset: () => - Promise.resolve( - Array.from(rows, ([key, value]) => ({ key, value: { ...value } })), - ), - loadResumeSnapshot: () => - Promise.resolve({ - rows: [], - keySet: { status: `consistent` }, - collectionMetadata: [], - latestTerm: latest.term, - latestSeq: latest.seq, - latestRowVersion: latest.rowVersion, - resetEpoch: 0, - }), - loadCollectionMetadata: () => Promise.resolve([]), - applyCommittedTx: async (_target, tx: PersistedTx) => { - if (holdNext && (tx.truncate || tx.mutations.length > 0)) { - holdNext = false - await new Promise((resolve) => { - release = resolve - }) - } - if (tx.truncate) rows.clear() - for (const mutation of tx.mutations) { - if (mutation.type === `delete`) rows.delete(mutation.key) - else - rows.set(mutation.key, { - ...rows.get(mutation.key), - ...mutation.value, - } as HistoryRow) - } - latest = { term: tx.term, seq: tx.seq, rowVersion: tx.rowVersion } - }, - ensureIndex: () => Promise.resolve(), - } - return { - adapter, - rows, - holdNextStore: () => { - holdNext = true - }, - releaseStore: () => { - holdNext = false - release?.() - release = undefined - }, - } -} - -let laneRun = 0 - -function persistedLane(): WrappingSyncLane & { - rows: Map -} { - const store = createHoldingAdapter() - const id = `persisted-optimistic-history-${++laneRun}` - return { - db: { - createCollection, - createLiveQueryCollection, - createTransaction, - whenSyncAccepted, - }, - rows: store.rows, - wrap: (config) => - persistedCollectionOptions({ - ...(config as CollectionConfig), - id, - persistence: { adapter: store.adapter }, - }) as unknown as CollectionConfig, - holdNextStore: store.holdNextStore, - releaseStore: store.releaseStore, - // The wrapper's turns are promise and timer continuations. - settle: async () => { - for (let turn = 0; turn < 10; turn++) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - }, - } -} - -// Each campaign must reach the states its laws are about, not only pass. -type CampaignReach = { - runs: number - deferredHolds: number - abortReleases: number -} -const reach: Record<`fixed` | `random`, CampaignReach> = { - fixed: { runs: 0, deferredHolds: 0, abortReleases: 0 }, - random: { runs: 0, deferredHolds: 0, abortReleases: 0 }, -} - -async function runPersistedHistory( - { - initial, - steps, - }: { - initial: Array - steps: Array - }, - campaign?: CampaignReach, -) { - const lane = persistedLane() - const counts = await runOptimisticHistory(initial, steps, undefined, { - wrapping: lane, - }) - if (!campaign) return - campaign.runs++ - campaign.deferredHolds += counts.deferredHolds - campaign.abortReleases += counts.abortReleases -} - -// A handler commits its server row and returns without awaiting acceptance; -// the source's acknowledgement came at commit. The insert's stored row holds -// the store, so the update's server row is deferred when both settle. -const insertThenUpdateBehindHeldStore: Array = [ - { type: `hold-store` }, - { - type: `edit`, - key: 1, - fields: { a: 1 }, - optimistic: true, - inHandler: { - type: `sync`, - rows: [{ id: 1, a: 1, b: 0, c: 0 }], - truncate: false, - copies: 1, - }, - }, - { - type: `edit`, - key: 1, - fields: { a: 2 }, - optimistic: true, - inHandler: { - type: `sync`, - rows: [{ id: 1, a: 2, b: 0, c: 0 }], - truncate: false, - copies: 1, - }, - }, - { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `release-store` }, -] - -it(`keeps a settled update over its insert while the update's server row waits behind a held store`, async () => { - await runPersistedHistory({ - initial: [], - steps: insertThenUpdateBehindHeldStore, - }) -}) - -it(`reconstructs the persisted grammar and excludes awaited acceptance during a hold`, () => { - const samples = fc.sample(wrappingSyncHistory, { - seed: 22_011, - numRuns: 200, - }) - const steps = samples.flatMap((sample) => sample.steps) - expect(steps.some((step) => step.type === `hold-store`)).toBe(true) - expect(steps.some((step) => step.type === `release-store`)).toBe(true) - for (const sample of samples) { - let holding = false - for (const step of sample.steps) { - if (step.type === `hold-store`) holding = true - if (step.type === `release-store`) holding = false - if (holding && (step.type === `edit` || step.type === `delete`)) - expect(step.inHandler?.awaitReceipt ?? false).toBe(false) - } - } -}) - -fcTest.prop([wrappingSyncHistory], { - numRuns: oracleRuns(40), - seed: 22_012, -})( - `persisted Collections match optimistic histories with a fixed seed`, - (history) => runPersistedHistory(history, reach.fixed), - 60_000, -) -fcTest.prop( - [wrappingSyncHistory], - oraclePropertyOptions(40, `sqlite-persistence.optimistic-history`), -)( - `persisted Collections match optimistic histories with a random or replayed seed`, - (history) => runPersistedHistory(history, reach.random), - 60_000, -) - -// The fixed campaign owes this reach; its seed is stable. A random seed can -// miss a rare state, so its reach is reported, not required. A replay runs -// one history. -it(`persisted fixed campaign reaches deferred holds and holds released by an abort`, () => { - if (process.env.TANSTACK_DB_ORACLE_SEED !== undefined) return - if (reach.fixed.runs === 0) return - expect(reach.fixed.deferredHolds, `fixed deferred holds`).toBeGreaterThan(0) - expect(reach.fixed.abortReleases, `fixed abort releases`).toBeGreaterThan(0) - console.info(`random campaign reach`, reach.random) -}) diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index cd5dc940e2..fb2b70a1b5 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -697,13 +697,24 @@ const sourceBatch = fc.record({ }) // A mutation handler may write a source batch before it returns, and may // await that batch's commit receipt. +type SourceBatchValue = + typeof sourceBatch extends fc.Arbitrary ? Value : never const handlerSourceBatch = fc.oneof( { weight: 3, arbitrary: fc.constant(undefined) }, { weight: 1, - arbitrary: fc - .tuple(sourceBatch, fc.boolean()) - .map(([batch, awaitReceipt]) => ({ ...batch, awaitReceipt })), + arbitrary: fc.tuple(sourceBatch, fc.boolean()).map( + ([batch, awaitReceipt]) => ({ ...batch, awaitReceipt }), + // So a pinned handler batch is a member of the language. + (value) => { + const { awaitReceipt, ...batch } = value as { + awaitReceipt?: unknown + } + if (typeof awaitReceipt !== `boolean`) + throw new Error(`not a handler source batch`) + return [batch as SourceBatchValue, awaitReceipt] + }, + ), }, ) export const optimisticStep: fc.Arbitrary = fc.oneof( @@ -770,17 +781,22 @@ export const optimisticHistory = fc.record({ steps: fc.array(optimisticStep, { minLength: 2, maxLength: 24 }), }) // The core grammar plus store steps. While a hold is outstanding, an -// in-handler batch does not await acceptance. +// in-handler batch does not await acceptance. Every mapped piece has an +// unmapper, and no step is an object constant (fast-check compares those by +// identity), so a pinned or segment-built history is a member of the language. // A deferred transaction's signal aborts before its turn. -const abortStep: fc.Arbitrary = fc - .nat(3) - .map((slot) => ({ type: `abort-deferred` as const, slot })) +const abortStep: fc.Arbitrary = fc.record({ + type: fc.constant(`abort-deferred` as const), + slot: fc.nat(3), +}) // Holds outnumber releases, so a hold often spans several settlements. -const storeStep: fc.Arbitrary = fc.constantFrom( - { type: `hold-store` as const }, - { type: `hold-store` as const }, - { type: `release-store` as const }, -) +const storeStep: fc.Arbitrary = fc.record({ + type: fc.constantFrom( + `hold-store` as const, + `hold-store` as const, + `release-store` as const, + ), +}) function excludeHeldAcceptance( steps: Array, ): Array { @@ -800,35 +816,60 @@ function excludeHeldAcceptance( // A handler that commits its own key's server row and returns at once, the // way a source acknowledges a write when it commits it. The shared grammar's // handler batches rarely write their own key. -const acknowledgedEdit: fc.Arbitrary = fc - .tuple(historyRow, fc.boolean(), fc.boolean()) - .map(([row, isDelete, optimistic]) => - isDelete - ? { - type: `delete` as const, - key: row.id, - optimistic, - inHandler: { - type: `sync` as const, - rows: [], - deletes: [row.id], - truncate: false, - copies: 1, - }, - } - : { - type: `edit` as const, - key: row.id, - fields: { a: row.a, b: row.b, c: row.c }, - optimistic, - inHandler: { - type: `sync` as const, - rows: [row], - truncate: false, - copies: 1, - }, - }, - ) +const acknowledgedEditStep = ([row, optimistic]: [HistoryRow, boolean]) => ({ + type: `edit` as const, + key: row.id, + fields: { a: row.a, b: row.b, c: row.c }, + optimistic, + inHandler: { + type: `sync` as const, + rows: [row], + truncate: false, + copies: 1, + }, +}) +const acknowledgedDeleteStep = ([key, optimistic]: [number, boolean]) => ({ + type: `delete` as const, + key, + optimistic, + inHandler: { + type: `sync` as const, + rows: [], + deletes: [key], + truncate: false, + copies: 1, + }, +}) +// An unmapper rebuilds the step from its source and accepts only an exact match. +function unmapping( + build: (source: Source) => OptimisticStep, + source: (step: OptimisticStep) => Source, +) { + return (value: unknown): Source => { + const recovered = source(value as OptimisticStep) + if (!isDeepStrictEqual(build(recovered), value)) + throw new Error(`not an acknowledged handler write`) + return recovered + } +} +const acknowledgedEdit: fc.Arbitrary = fc.oneof( + fc.tuple(historyRow, fc.boolean()).map( + acknowledgedEditStep, + unmapping(acknowledgedEditStep, (step) => [ + (step.type === `edit` && step.inHandler?.type === `sync` + ? step.inHandler.rows[0] + : undefined) as HistoryRow, + step.type === `edit` && step.optimistic, + ]), + ), + fc.tuple(fc.integer({ min: 1, max: 3 }), fc.boolean()).map( + acknowledgedDeleteStep, + unmapping(acknowledgedDeleteStep, (step) => [ + step.type === `delete` ? step.key : 0, + step.type === `delete` && step.optimistic, + ]), + ), +) const settleAll: Array = Array.from({ length: 24 }, () => ({ type: `settle` as const, slot: 0, @@ -846,6 +887,13 @@ const observeEveryKey: Array = [ copies: 1, }, ] +const wrappingTail = [...settleAll, ...observeEveryKey] +/** A wrapping-sync history's steps: its body, then the settle-and-observe tail. */ +export function wrappingSteps( + body: Array, +): Array { + return [...excludeHeldAcceptance(body), ...wrappingTail] +} /** * The grammar for a wrapping-sync lane: the core grammar plus held durable * writes and acknowledged edits. @@ -868,11 +916,13 @@ export const wrappingSyncHistory = fc.record({ // Settle every outstanding transaction before the release, so an // outstanding hold meets a drain. A settle with nothing active is skipped. // Then observe every key through one later source transaction. - .map((steps) => [ - ...excludeHeldAcceptance(steps), - ...settleAll, - ...observeEveryKey, - ]), + .map(wrappingSteps, (value) => { + const steps = value as Array + const body = steps.slice(0, steps.length - wrappingTail.length) + if (!isDeepStrictEqual(wrappingSteps(body), steps)) + throw new Error(`not a wrapping-sync step list`) + return body + }), }) /** diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 41696f7d6b..e557790600 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -467,7 +467,7 @@ const ownerGroups: ReadonlyArray< `generation-notice`, ], [ - `db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts`, + `db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts`, `sqlite-persistence`, `optimistic-history`, ], From 409680083517af02fdf3789ea39d9c70a7c97d5f Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 12:41:21 -0700 Subject: [PATCH 020/168] test(oracles): move the FIFO retry oracle onto oracleProperty, with planned faults The FIFO retry oracle judged one delayed head and ready tails with successful providers; failures were out of scope. It is now the `oracleProperty` `offline.fifo-retry`, and each transaction's attempt plan is history data: retriable provider failures, a final success or permanent failure, and a storage fault on the deletion-phase write or the deletion. A model of the README's contract judges provider calls, caller settlements, and the outbox before the deadline, just before it, at it, and after every retry. A calibration found a defect in the first check: it classified a tail that ran before the head as a missed wakeup. Calls outside the full attempt order now break `fifo`; a correct prefix of the wrong length breaks `progress`. - Law: one transaction at a time in creation order; a retried transaction keeps its FIFO position; a permanent failure removes its row and the queue continues; a failed phase write or deletion rejects the caller with the storage error and stops the queue (README: FIFO Sequential Processing, Retry decisions, offline-execution settlement). - Reach: a segment requires a phase-write fault before a queued peer on every run; fixed seed 20260916 also entered deletion faults 4, permanent failures before a peer 4, retried heads 5, retried tails 9. - RED/Mutant (old oracle against new, on mutated production): a ready tail that passes the delayed head fails both (new: fifo). A retry that moves behind its peers, a queue that continues after a storage fault, and a permanent failure that stops the queue pass all old tests and fail the new one (fifo, fifo, progress). - GREEN: 10 of 10. - Limits: one mutation function, no retry hooks, no leadership changes, no storage fault on a permanent failure's removal; backoff lengths are not judged. An attempt outside the plan never settles, so a runaway executor stops at its first extra call instead of hanging the driver. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/fifo-retry-oracle.property.test.ts | 659 +++++++++++++----- 2 files changed, 472 insertions(+), 189 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3589bfeccf..99f80b6b24 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -587,7 +587,7 @@ that test identifiers must copy production's private data structures. | Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | | PowerSync | Fixed tests only | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are covered by fixed tests in `correctness.test.ts`; no oracle owns them (tracked in #2264). | | SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared-handle driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | -| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per lane for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight pins enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. A segment requires a phase-write fault before a queued peer on every run. Mutants that let a retried transaction move behind its peers, keep running after a storage fault, or stop the queue at a permanent failure fail it; the previous fault-free oracle passed all three. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per lane for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight pins enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | | Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | | Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. | | Mixed-kind ordered prefixes | Bounded | Eager BasicIndex and BTreeIndex paths compare ascending and descending direct and live-query windows with an unindexed scan across mixed value kinds and paired source changes. One retained on-demand BTree history checks settled windows after a number/bigint transition, a finite request for a missing row, and a cursor request after index eligibility changes. Other provider paging histories remain open. | diff --git a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts index c9b8b57cc6..7ab2aa51ef 100644 --- a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts @@ -1,215 +1,498 @@ import fc from 'fast-check' -import { expect, it, vi } from 'vitest' +import { vi } from 'vitest' import { KeyScheduler } from '../src/executor/KeyScheduler' import { TransactionExecutor } from '../src/executor/TransactionExecutor' import { OutboxManager } from '../src/outbox/OutboxManager' -import { oracleReplayTest } from '../../db/tests/oracle-config' +import { NonRetriableError } from '../src/types' +import { + differs, + oracleProperty, + pass, + violation, +} from '../../db/tests/oracle-property' import { FakeStorageAdapter } from './harness' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import type { Observation } from '../../db/tests/oracle-property' import type { OfflineTransaction } from '../src/types' /** - * # Why does a ready transaction wait? + * # Why does a ready transaction wait, and what stops the queue? * - * The scheduler executes transactions in first-in, first-out order. A later - * transaction must not pass the first transaction when the first transaction - * has a retry delay. + * Contract (the package README: "FIFO Sequential Processing", "Retry + * decisions", and the offline-execution settlement section): + * - The executor runs one transaction at a time, in creation order. A later + * transaction does not pass an earlier one that has a retry delay, and a + * retried transaction keeps its FIFO position. + * - A retriable provider failure retries the same transaction after its + * backoff; nothing behind it runs first. + * - A `NonRetriableError` removes its row and rejects its caller with that + * error; queued rows then continue in creation order. + * - Success means the provider returned and the storage adapter acknowledged + * the outbox deletion. If recording the `deletion-pending` phase or + * removing the row fails, the caller rejects with the storage error and + * the executor stops: it processes no queued peer. * - * The executor must execute nothing before the head is eligible. Under the - * controlled virtual clock, advancing to that deadline must execute all - * transactions in queue order and remove them from the outbox. + * Fault injection: each transaction's attempt plan is part of the history. + * The provider fails a planned number of times with a retriable error, then + * succeeds or fails permanently; a successful transaction may also have a + * planned storage fault on its phase write or its deletion. The faults are + * data, so a history replays exactly. * - * The package README's FIFO sequential-processing and automatic-retry - * contracts supply the order and eventual-retry laws. The established - * `KeyScheduler-oracle.property.test.ts` supplies the `nextAttemptAt` eligibility - * boundary. - * Advancing fake timers to that boundary is a bounded executor liveness check, - * not a user-facing wall-clock latency guarantee. Timer count and polling - * strategy are implementation details outside this oracle's judgment. - * This oracle covers an online executor with successful mutation functions, - * one delayed head, and ready tails. It does not judge failed mutation - * functions, leadership changes, or real browser timer scheduling. + * History grammar: two to five transactions with increasing `createdAt`; only + * the head has a future `nextAttemptAt` (2 to 10,000 ms), and the tail is + * ready. The delay axis moves the head's eligibility boundary; the + * checkpoints at delay-1 and delay distinguish early and late wakeups. * - * Input grammar: 2–5 distinct transactions have increasing `createdAt`; only - * the head has a future `nextAttemptAt` (2–10,000 ms). A count of 2 is the - * smallest head/tail witness; larger counts challenge complete tail draining. - * The delay axis moves the boundary while preserving the order law. Every - * generated pair reconstructs the delayed-head/ready-tail history. Removing - * either the tail or delay loses that history. A tail before the head by - * `createdAt`, duplicate IDs, or a ready head is outside this grammar; each - * changes the premise. The checkpoints at delay−1 and delay distinguish early - * and late wakeups, including the 2 ms marginal delay. + * Model: the attempt order and outcomes follow from the plans alone, without + * the scheduler, timers, or the outbox. At the deadline, attempts run until + * the first retriable failure (whose backoff is at least a second) or a + * storage fault; after every timer has run, every plan has played out up to + * the first storage fault. * - * fast-check varies the queue length and the retry delay. `expectedStateAt` - * models time without using the scheduler, timers, or the outbox. + * Driver: a real `TransactionExecutor`, `KeyScheduler`, and `OutboxManager` + * over fake storage that fails as planned, with a controlled virtual clock + * and jitter off. It records provider calls, the outbox, and each caller's + * settlement at each checkpoint. + * + * Limits: an online executor with one mutation function; no leadership + * changes, no `shouldRetry` or `beforeRetry` hooks, no storage fault on a + * permanent failure's removal, and no real browser timers. Backoff lengths + * are not judged, only that a retry waits past the deadline cut. */ -type ExpectedState = { - calls: Array - outboxCount: number +// --------------------------------------------------------------------------- +// History +// --------------------------------------------------------------------------- + +type Plan = { + /** Retriable provider failures before the final attempt. */ + failures: number + final: `success` | `permanent` + /** A storage fault after a successful provider call. */ + storageFault: `none` | `phase` | `delete` } +type History = { delay: number; plans: Array } -const FIFO_RETRY_PROPERTY = `offline.fifo-retry` -const fifoRetryOracle = readOfflineOracleConfig({ - property: FIFO_RETRY_PROPERTY, - defaultRuns: 30, +const plan: fc.Arbitrary = fc.oneof( + // Most transactions succeed at once, so a queue often drains past a fault. + { + weight: 3, + arbitrary: fc.record({ + failures: fc.constant(0), + final: fc.constant(`success` as const), + storageFault: fc.constant(`none` as const), + }), + }, + { + weight: 2, + arbitrary: fc.record({ + failures: fc.integer({ min: 0, max: 2 }), + final: fc.constantFrom(`success` as const, `permanent` as const), + storageFault: fc.constant(`none` as const), + }), + }, + { + weight: 1, + arbitrary: fc.record({ + failures: fc.integer({ min: 0, max: 1 }), + final: fc.constant(`success` as const), + storageFault: fc.constantFrom(`phase` as const, `delete` as const), + }), + }, +) +const fifoHistory: fc.Arbitrary = fc.record({ + delay: fc.integer({ min: 2, max: 10000 }), + plans: fc.array(plan, { minLength: 2, maxLength: 5 }), }) -function expectedStateAt( - elapsed: number, - delay: number, - transactionIds: ReadonlyArray, -): ExpectedState { - const deadlinePassed = elapsed >= delay - return { - calls: deadlinePassed ? [...transactionIds] : [], - outboxCount: deadlinePassed ? 0 : transactionIds.length, +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +type Settlement = { + id: string + outcome: `fulfilled` | `rejected` + message?: string +} +type State = { + calls: Array + outbox: Array + settled: Array +} +const idOf = (index: number) => `tx-${index}` +const retriable = (index: number, attempt: number) => + `retriable failure ${idOf(index)} attempt ${attempt}` +const permanent = (index: number) => `permanent failure ${idOf(index)}` +const storageFailure = (index: number) => `storage failed for ${idOf(index)}` + +/** + * What the public observations show after the plans have run, either only + * up to the first retriable failure (`untilRetry`, the deadline cut) or to + * the end. + */ +function playOut({ plans }: History, untilRetry: boolean): State { + const calls: Array = [] + const settled: Array = [] + const remaining = new Set(plans.map((_, index) => idOf(index))) + for (const [index, { failures, final, storageFault }] of plans.entries()) { + const id = idOf(index) + if (untilRetry && failures > 0) { + calls.push(id) + break + } + for (let attempt = 0; attempt <= failures; attempt++) calls.push(id) + if (final === `permanent`) { + remaining.delete(id) + settled.push({ id, outcome: `rejected`, message: permanent(index) }) + continue + } + if (storageFault !== `none`) { + // The row stays, and the executor stops before any queued peer. + settled.push({ id, outcome: `rejected`, message: storageFailure(index) }) + break + } + remaining.delete(id) + settled.push({ id, outcome: `fulfilled` }) } + return { calls, outbox: [...remaining], settled } } -function expectState(actual: ExpectedState, expected: ExpectedState): void { - expect(actual.calls).toEqual(expected.calls) - expect(actual.outboxCount).toBe(expected.outboxCount) +function fifoModel(history: History) { + const queued: State = { + calls: [], + outbox: history.plans.map((_, index) => idOf(index)), + settled: [], + } + return new Map([ + [`before deadline`, queued], + [`just before deadline`, queued], + [`at deadline`, playOut(history, true)], + [`after every retry`, playOut(history, false)], + ]) } -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of FIFO_RETRY_PROPERTY. -async function checkFifoRetry(seed: number | undefined): Promise { - await fc.assert( - fc.asyncProperty( - fc.integer({ min: 2, max: 5 }), - fc.integer({ min: 2, max: 10000 }), - async (count, delay) => { - let clearExecutor: (() => void) | undefined - let primaryFailure: unknown - let failed = false - try { - vi.useFakeTimers() - const scheduler = new KeyScheduler() - const outbox = new OutboxManager(new FakeStorageAdapter(), {}) - const calls: Array = [] - - // Record each transaction when the production executor runs it. - const executor = new TransactionExecutor( - scheduler, - outbox, - { - collections: {}, - mutationFns: { - persist: ({ transaction }) => { - calls.push(transaction.id) - return Promise.resolve() - }, - }, - }, - { - isOfflineEnabled: true, - resolveTransaction: () => {}, - rejectTransaction: () => {}, - registerRestorationTransaction: () => {}, - isOnline: () => true, - }, - ) - clearExecutor = () => executor.clear() - const transactions: Array = Array.from( - { length: count }, - (_unused, index) => ({ - id: `tx-${index}`, - mutationFnName: `persist`, - mutations: [], - keys: [], - idempotencyKey: `key-${index}`, - createdAt: new Date(index), - nextAttemptAt: Date.now() + (index === 0 ? delay : 0), - retryCount: 0, - version: 1, - }), - ) - const transactionIds = transactions.map( - (transaction) => transaction.id, - ) - - for (const transaction of transactions) { - await outbox.add(transaction) - scheduler.schedule(transaction) - } - await executor.executeAll() - - // A ready tail waits for the delayed head. The virtual-clock cuts - // distinguish early execution from failure to wake when eligible. - const beforeDeadline = expectedStateAt(0, delay, transactionIds) - const observedBeforeDeadline = { - calls: [...calls], - outboxCount: await outbox.count(), - } - expectState(observedBeforeDeadline, beforeDeadline) - // A scheduler that lets a ready tail bypass the delayed head - // reaches this checkpoint with an extra call. Calibrate the same - // comparison against that plausible wrong observation. - expect(() => - expectState( - { ...observedBeforeDeadline, calls: [transactionIds[1]!] }, - beforeDeadline, - ), - ).toThrow() - await vi.advanceTimersByTimeAsync(delay - 1) - const nearDeadline = expectedStateAt(delay - 1, delay, transactionIds) - expectState( - { calls: [...calls], outboxCount: await outbox.count() }, - nearDeadline, - ) - await vi.advanceTimersByTimeAsync(1) - const atDeadline = expectedStateAt(delay, delay, transactionIds) - const observedAtDeadline = { - calls: [...calls], - outboxCount: await outbox.count(), - } - expectState(observedAtDeadline, atDeadline) - // A scheduler that never wakes at eligibility leaves the whole - // queue untouched at this controlled-clock checkpoint. - expect(() => - expectState( - { calls: [], outboxCount: transactionIds.length }, - atDeadline, - ), - ).toThrow() - } catch (error) { - primaryFailure = error - failed = true - } - - const cleanupFailures: Array = [] - const cleanups: Array<[string, (() => void) | undefined]> = [ - [`executor.clear`, clearExecutor], - [`real timer restore`, () => vi.useRealTimers()], - ] - for (const [name, cleanup] of cleanups) { - if (!cleanup) continue - try { - cleanup() - } catch (error) { - cleanupFailures.push(new Error(`${name} failed`, { cause: error })) - } - } - if (cleanupFailures.length > 0) { - throw new AggregateError( - cleanupFailures, - `FIFO retry oracle cleanup failed`, - { cause: failed ? primaryFailure : undefined }, - ) - } - if (failed) throw primaryFailure - }, - ), - oracleLaneOptions(fifoRetryOracle, seed), +// Reach: which fault paths a history takes, from its plans. +const states = [ + `retried head`, + `retried tail`, + `permanent failure before a peer`, + `phase write fault`, + `deletion fault`, + `fault before a queued peer`, +] as const +function reachedBy({ plans }: History): Array { + const reached: Array = [] + const stop = plans.findIndex( + (entry) => entry.final === `success` && entry.storageFault !== `none`, ) + const played = stop < 0 ? plans : plans.slice(0, stop + 1) + if (played[0]!.failures > 0) reached.push(`retried head`) + if (played.slice(1).some((entry) => entry.failures > 0)) + reached.push(`retried tail`) + if (played.slice(0, -1).some((entry) => entry.final === `permanent`)) + reached.push(`permanent failure before a peer`) + if (stop >= 0) { + reached.push( + plans[stop]!.storageFault === `phase` + ? `phase write fault` + : `deletion fault`, + ) + if (stop < plans.length - 1) reached.push(`fault before a queued peer`) + } + return reached } -oracleReplayTest(it).each([20260916])( - `waits for the FIFO head and executes eligible work at its deadline (seed %s)`, - checkFifoRetry, -) -oracleReplayTest(it, FIFO_RETRY_PROPERTY).each([undefined])( - `waits for the FIFO head and executes eligible work at its deadline (seed %s)`, - checkFifoRetry, -) +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- + +async function driveFifo( + { delay, plans }: History, + observe: (checkpoint: string, value: unknown) => void, +) { + vi.useFakeTimers() + // A storage fault stops the executor, which logs the failed retry batch. + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + let executor: TransactionExecutor | undefined + try { + // Fails the planned phase write or deletion for one transaction. + const storage = new (class extends FakeStorageAdapter { + override async set(key: string, value: string): Promise { + const index = plans.findIndex((_, i) => key.endsWith(idOf(i))) + if ( + index >= 0 && + plans[index]!.storageFault === `phase` && + value.includes(`"deletion-pending"`) + ) + throw new Error(storageFailure(index)) + return super.set(key, value) + } + override async delete(key: string): Promise { + const index = plans.findIndex((_, i) => key.endsWith(idOf(i))) + if (index >= 0 && plans[index]!.storageFault === `delete`) + throw new Error(storageFailure(index)) + return super.delete(key) + } + })() + const outbox = new OutboxManager(storage, {}) + const scheduler = new KeyScheduler() + const calls: Array = [] + const attempts = new Map() + const settled: Array = [] + executor = new TransactionExecutor( + scheduler, + outbox, + { + collections: {}, + jitter: false, + mutationFns: { + persist: ({ transaction }) => { + const id = transaction.id + calls.push(id) + const attempt = attempts.get(id) ?? 0 + attempts.set(id, attempt + 1) + const index = Number(id.slice(3)) + const { failures, final } = plans[index]! + // An attempt the plan does not have never settles, so a runaway + // executor stops at its first extra call, which the trace shows. + if (attempt > failures) return new Promise(() => {}) + if (attempt < failures) + return Promise.reject(new Error(retriable(index, attempt))) + if (final === `permanent`) + return Promise.reject(new NonRetriableError(permanent(index))) + return Promise.resolve() + }, + }, + }, + { + isOfflineEnabled: true, + resolveTransaction: (id) => settled.push({ id, outcome: `fulfilled` }), + rejectTransaction: (id, error) => + settled.push({ id, outcome: `rejected`, message: error.message }), + registerRestorationTransaction: () => {}, + isOnline: () => true, + }, + ) + const record = async (checkpoint: string) => + observe(checkpoint, { + calls: [...calls], + outbox: (await outbox.getAll()).map((entry) => entry.id), + settled: [...settled], + } satisfies State) + + for (const [index] of plans.entries()) { + const transaction: OfflineTransaction = { + id: idOf(index), + mutationFnName: `persist`, + mutations: [], + keys: [], + idempotencyKey: `key-${index}`, + createdAt: new Date(index), + nextAttemptAt: Date.now() + (index === 0 ? delay : 0), + retryCount: 0, + version: 1, + } + await outbox.add(transaction) + scheduler.schedule(transaction) + } + await executor.executeAll() + await record(`before deadline`) + await vi.advanceTimersByTimeAsync(delay - 1) + await record(`just before deadline`) + await vi.advanceTimersByTimeAsync(1) + await record(`at deadline`) + // Backoff doubles from a second to a minute; run every retry timer. + for (let round = 0; round < 20 && vi.getTimerCount() > 0; round++) + await vi.advanceTimersByTimeAsync(60_000) + await record(`after every retry`) + } finally { + executor?.clear() + warn.mockRestore() + vi.useRealTimers() + } +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- +// +// At each checkpoint the provider calls, the outbox, and the settlements +// equal the model's. Calls that are not a prefix of the full attempt order +// break the FIFO law; a correct prefix of the wrong length at a cut breaks +// the progress law (a late wakeup, or a queue that stalls); settlements and the outbox after a fault are the stop law. + +function checkFifo( + _history: History, + trace: ReadonlyArray, + owed: ReturnType, +) { + const order = owed.get(`after every retry`)!.calls + for (const [checkpoint, expected] of owed) { + const seen = trace.find((entry) => entry.checkpoint === checkpoint) + ?.value as State | undefined + if (!seen) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}"`, + `trace`, + ) + // Calls in the full attempt order, but too few or too many for this cut, + // break progress; any other difference breaks FIFO order. + const callLaw = seen.calls.every((id, index) => id === order[index]) + ? `progress` + : `fifo` + const wrong = + differs( + checkpoint, + callLaw, + seen.calls, + expected.calls, + `provider calls differ from creation order and the attempt plans`, + ) ?? + differs( + checkpoint, + `settlement`, + seen.settled, + expected.settled, + `caller settlements differ from the plans`, + ) ?? + differs( + checkpoint, + `stop`, + seen.outbox, + expected.outbox, + `the outbox differs from the plans`, + ) + if (wrong) return wrong + } + return pass +} + +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +const ok: Plan = { failures: 0, final: `success`, storageFault: `none` } + +oracleProperty({ + id: `offline.fifo-retry`, + law: { + statement: `The executor runs transactions one at a time in creation order: a ready tail waits for a delayed or retrying head, a permanent failure lets queued rows continue, and a failed phase write or deletion rejects its caller with the storage error and stops the queue.`, + authority: `packages/offline-transactions/README.md: FIFO Sequential Processing, Retry decisions, and offline-execution settlement`, + limits: `An online executor with one mutation function and no retry hooks; no leadership changes; no storage fault on a permanent failure's removal; backoff lengths are not judged.`, + owner: `Offline execution`, + }, + histories: { + arbitrary: fifoHistory, + states: [`phase write fault`, `fault before a queued peer`], + measured: states.filter( + (state) => + state !== `phase write fault` && state !== `fault before a queued peer`, + ), + runs: 60, + fixedSeeds: [20260916], + // A storage fault in the middle of the queue: the stop law needs a peer + // behind the fault. + segments: [ + { + name: `a phase write fault before a queued peer`, + enters: [`phase write fault`, `fault before a queued peer`], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { ...ok }, + { failures: 0, final: `success`, storageFault: `phase` }, + ...generated.plans.slice(0, 3), + ], + }), + }, + ], + pins: [ + { + name: `a ready tail waits for a delayed head with the smallest delay`, + history: { delay: 2, plans: [{ ...ok }, { ...ok }] }, + }, + { + name: `a retried head keeps its place`, + enters: [`retried head`], + history: { + delay: 500, + plans: [ + { failures: 2, final: `success`, storageFault: `none` }, + { ...ok }, + { ...ok }, + ], + }, + }, + { + name: `a permanent failure lets the queue continue`, + enters: [`permanent failure before a peer`], + history: { + delay: 10, + plans: [ + { ...ok }, + { failures: 1, final: `permanent`, storageFault: `none` }, + { ...ok }, + ], + }, + }, + { + name: `a failed deletion stops the queue`, + enters: [`deletion fault`, `fault before a queued peer`], + history: { + delay: 10, + plans: [ + { failures: 0, final: `success`, storageFault: `delete` }, + { ...ok }, + ], + }, + }, + ], + }, + model: fifoModel, + reached: (history) => reachedBy(history), + driver: driveFifo, + check: checkFifo, + // Wrong answers the check must reject: a ready tail that passes the + // delayed head, a scheduler that never wakes at eligibility, and a queue + // that keeps running after a storage fault. + calibrations: [ + { + name: `a ready tail passes the delayed head`, + law: `fifo`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `before deadline` + ? { + ...entry, + value: { ...(entry.value as State), calls: [idOf(1)] }, + } + : entry, + ), + }, + { + name: `the head never wakes at its deadline`, + law: `progress`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `at deadline` + ? { ...entry, value: { ...(entry.value as State), calls: [] } } + : entry, + ), + }, + { + name: `the queue continues past a storage fault`, + law: `stop`, + corrupt: (trace) => { + const last = trace.find( + ({ checkpoint }) => checkpoint === `after every retry`, + )?.value as State | undefined + if (!last || last.outbox.length < 2) return undefined + return trace.map((entry) => + entry.checkpoint === `after every retry` + ? { ...entry, value: { ...last, outbox: last.outbox.slice(0, 1) } } + : entry, + ) + }, + }, + ], +}) From f6bd4318c4c7aec8b50eec69792a9b7847ec3456 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:02:09 -0700 Subject: [PATCH 021/168] test(oracles): track fixed-seed budgets in oracleProperty definitions A fixed-seed campaign `{ seed: s, numRuns: n }` that moves onto `oracleProperty` becomes `histories: { runs: n, fixedSeeds: [s] }`. The run-count script did not read `fixedSeeds`, so it reported the seed's budget as removed (the KeyScheduler port hit this). - Law: every run budget an oracle uses is tracked wherever it is written (scripts/run-budget-oracle.property.test.js; maintainer decision of 2026-10-27 on removals). - Reach: the grammar now moves fixed-seed campaigns into an `oracleProperty` definition's `fixedSeeds`; the new state `seed-migrated-to-framework` is required and was reached. - RED: on the previous script, 8 of 8 campaigns fail with a migrated fixed seed as counterexample. - GREEN: 8 of 8. - Mutant: a script that reads only literal seeds in `fixedSeeds`, not imported constants, fails both "every source" campaigns. - Limits: inline `histories` and inline `fixedSeeds` arrays only. Co-authored-by: Isaac --- scripts/check-oracle-run-counts.mjs | 29 +++++++++++------- scripts/run-budget-oracle.property.test.js | 35 ++++++++++++++++------ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/scripts/check-oracle-run-counts.mjs b/scripts/check-oracle-run-counts.mjs index 7a4d4c86d6..7a56a68865 100644 --- a/scripts/check-oracle-run-counts.mjs +++ b/scripts/check-oracle-run-counts.mjs @@ -15,8 +15,9 @@ * A budget is keyed by its registered property where the code names one * (`oraclePropertyOptions(n, 'p')`, `oracleRandomParameters(n, _, 'p')`, * `{ property: 'p', defaultRuns: n }`, or the framework definition - * `oracleProperty({ id: 'p', histories: { runs: n } })`), and otherwise by its fixed seed - * (`{ seed: s, numRuns: oracleRuns(n) }`). A key or count is a literal, a + * `oracleProperty({ id: 'p', histories: { runs: n } })`), and otherwise by its + * fixed seed (`{ seed: s, numRuns: oracleRuns(n) }`, or a seed `s` in that + * definition's `fixedSeeds`, whose lane runs the same `n`). A key or count is a literal, a * constant in the same file, or a constant that the file imports. An import * follows relative re-exports (`export { x } from`, `export * from`), so a * constant defined in a support module is tracked where the oracle uses it. @@ -330,14 +331,22 @@ function runBudgets(file, revision, reader) { !candidate.computed && nameOf(candidate.key) === `histories`, )?.value - if ( - typeof property === `string` && - histories?.type === `ObjectExpression` - ) - record( - `property ${property}`, - propertyValue(histories, `runs`, file, reader, revision), - ) + if (histories?.type === `ObjectExpression`) { + const runs = propertyValue(histories, `runs`, file, reader, revision) + if (typeof property === `string`) record(`property ${property}`, runs) + // Each fixed seed's lane runs the same `runs` histories. + const fixedSeeds = histories.properties.find( + (candidate) => + candidate.type === `Property` && + !candidate.computed && + nameOf(candidate.key) === `fixedSeeds`, + )?.value + if (fixedSeeds?.type === `ArrayExpression`) + for (const element of fixedSeeds.elements) { + const seed = value(element) + if (typeof seed === `number`) record(`fixed seed ${seed}`, runs) + } + } } if (node.callee.name === `oracleRandomParameters`) { const property = value(third) diff --git a/scripts/run-budget-oracle.property.test.js b/scripts/run-budget-oracle.property.test.js index 2c7b83bf6b..11594e7777 100644 --- a/scripts/run-budget-oracle.property.test.js +++ b/scripts/run-budget-oracle.property.test.js @@ -3,7 +3,8 @@ * scripts/check-oracle-run-counts.mjs, wherever it is written. A budget is the * numRuns of a fixed-seed campaign, or the run count of a registered property, * whether the property passes it to `oraclePropertyOptions` or declares it as - * `oracleProperty({ id, histories: { runs } })`. + * `oracleProperty({ id, histories: { runs } })`; a fixed seed in that + * definition's `fixedSeeds` runs `runs` histories. * When the branch lowers a budget, or removes it from every tracked oracle * file, the script reports it, so the change is visible to the reviewer. * @@ -207,9 +208,10 @@ function resolveBudget(skeleton, index, sources, files) { return { index, form: skeleton.form, - // A property budget written through `oraclePropertyOptions` at base that - // is an `oracleProperty` definition at head. - migrates: skeleton.form === `property` && skeleton.migrates, + // A property budget written through `oraclePropertyOptions`, or a + // fixed-seed campaign, at base that is an `oracleProperty` definition at + // head: the property's `runs`, or a seed in its `fixedSeeds`. + migrates: skeleton.form !== `framework` && skeleton.migrates, source, keySource: skeleton.keySource, wrapped: skeleton.form !== `framework` && skeleton.wrapped, @@ -341,7 +343,12 @@ function renderFiles(program, revision) { const seed = 1000 + i // The definition of an imported key and count lives in support module A. // The form a budget is written in at this revision. - const form = isHead && budget.migrates ? `framework` : budget.form + const form = + isHead && budget.migrates + ? budget.form === `seed` + ? `framework-seed` + : `framework` + : budget.form if (budget.keySource === `imported`) { support.line( budget.form !== `seed` @@ -385,7 +392,7 @@ function renderFiles(program, revision) { : use(`RUNS_${i}`, files.support) // A migrated budget drops its oracleRuns wrapper, as the framework scales. const runs = - budget.wrapped && form !== `framework` + budget.wrapped && !form.startsWith(`framework`) ? `oracleRuns(${countText})` : countText const keyText = @@ -399,7 +406,9 @@ function renderFiles(program, revision) { ? `export const opts${i} = oraclePropertyOptions(${runs}, ${keyText})` : form === `framework` ? `export const opts${i} = oracleProperty({ id: ${keyText}, law: {}, histories: { arbitrary: fc.integer(), runs: ${runs} } })` - : `export function campaign${i}() { fc.assert(fc.property(fc.integer(), () => undefined), { numRuns: ${runs}, seed: ${keyText} }) }` + : form === `framework-seed` + ? `export const opts${i} = oracleProperty({ id: 'seeded.${i}', law: {}, histories: { arbitrary: fc.integer(), runs: ${runs}, fixedSeeds: [${keyText}] } })` + : `export function campaign${i}() { fc.assert(fc.property(fc.integer(), () => undefined), { numRuns: ${runs}, seed: ${keyText} }) }` hostText.line(call) // The oracle file uses a campaign in A by importing it. if (inSupport) { @@ -407,7 +416,9 @@ function renderFiles(program, revision) { .get(files.oracle) .import( supportSpecifier, - budget.form !== `seed` ? `opts${i}` : `campaign${i}`, + budget.form !== `seed` || (isHead && budget.migrates) + ? `opts${i}` + : `campaign${i}`, ) } } @@ -553,6 +564,7 @@ const STATES = [ `imported-key`, `framework-definition`, `migrated-to-framework`, + `seed-migrated-to-framework`, `support-only-change`, `oracle-and-support-change`, `lowered-budget`, @@ -579,7 +591,12 @@ function recordStates(program) { if (budget.source === `reexported`) states.add(`re-exported-count`) if (budget.keySource === `imported`) states.add(`imported-key`) if (budget.form === `framework`) states.add(`framework-definition`) - if (budget.migrates) states.add(`migrated-to-framework`) + if (budget.migrates) + states.add( + budget.form === `seed` + ? `seed-migrated-to-framework` + : `migrated-to-framework`, + ) if (budget.after < budget.before) states.add(`lowered-budget`) if (budget.callAt === `support`) states.add(`campaign-in-support`) if (budget.callAt === `oracle` && budget.fate === `swap`) { From 43295b7ed71ca8a9f3f68b75d7fc804a58056c86 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:02:10 -0700 Subject: [PATCH 022/168] test(oracles): move the KeyScheduler ledger onto oracleProperty, and generate its whole grammar The KeyScheduler oracle ran its ledger model and the real scheduler in lockstep with inline assertions. It is now the `oracleProperty` `offline.key-scheduler-fifo-ledger`: the model owes a record per command, the driver records the scheduler's snapshot, what `start` was given, and what a removal returned, and the check maps each field to a law (fifo, update, ledger, serial, retirement). The model throws a named error for a command the lifecycle does not allow. Measured reach exposed a starved generator. Ported unchanged, 4 of 150 histories per lane started any transaction and 1 retried: fast-check's default array size kept histories near 10 commands (at most 12) although the grammar declared 1 to 50, and the biased `fc.nat()` selector picked the first few choices. With `size: 'medium'` and `fc.noBias`, 37 to 50 of 150 start work and 13 to 19 retry. The generated language is tokens mapped through `buildLegalHistory`, so `tokensOf` inverts it by search and accepts only an exact rebuild; pinned command lists are members and shrink. The "every modeled transition" pin removed `[active, sibling, missing]`, which the grammar could not generate; the active-removal choice can now name `missing`. - Law: one globally serial FIFO queue; equal creation times keep scheduling order; a delayed head blocks younger work; a failure updates retry data before release without moving the transaction; reconciliation retires only unissued IDs; clear retires all work (the established FIFO tests and TransactionExecutor's calling order). - Reach: every command type is measured; the pins enter all of them. - RED/Mutant (old oracle against new, on mutated scheduler code): a ready transaction passing the head, a retry moved to the back, a removal that retires the active ID, clear keeping the active ID, and reordered equal creation times fail both versions (new: fifo, fifo+ledger, retirement, serial, fifo+ledger). A grammar whose `nextFreeSlot` reuses a pending slot fails with the model's "already pending" error. - GREEN: 16 of 16, with eight calibrations, including the old five observation mutants. - Limits: persistence, caller settlement, leadership, and real timers have separate owners; at most five IDs. Co-authored-by: Isaac --- .../KeyScheduler-oracle.property.test.ts | 1075 ++++++++++------- 1 file changed, 609 insertions(+), 466 deletions(-) diff --git a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts index 4b2be872be..dbe9876e9b 100644 --- a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts @@ -1,17 +1,12 @@ -import { fc } from '@fast-check/vitest' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { isDeepStrictEqual } from 'node:util' +import fc from 'fast-check' +import { vi } from 'vitest' import { KeyScheduler } from '../src/executor/KeyScheduler' -import { - oraclePropertyOptions, - oracleReplayTest, - oracleRuns, -} from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' +import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { BASE_TIME, createTransaction } from './key-scheduler-support' +import type { Observation } from '../../db/tests/oracle-property' import type { OfflineTransaction } from '../src/types' -const FIFO_LEDGER_PROPERTY = `offline.key-scheduler-fifo-ledger` - type Command = | { type: `schedule` @@ -63,9 +58,6 @@ type Snapshot = { runningCount: number } -const FIXED_SEED = 1815 -const RUNS = oracleRuns(150) - /** * # Which offline transaction may run next? * @@ -87,7 +79,8 @@ const RUNS = oracleRuns(150) * IDs are unique while pending. At most five IDs exist in generated histories. * Slot choice prevents duplicate pending IDs; creation time controls FIFO order; * delay and clock advance control readiness; payload makes updates observable. - * The selector reaches every legal command, including removal during a run. + * The selector reaches every legal command, including removal during a run; + * it is unbiased, so each legal command is about equally likely. * Histories use 1-50 tokens plus automatic retry steps. Slots span 0-4, * creation times 0-3 seconds, delays and advances 0-5 seconds, and payloads * -5 through 5. The domain includes equal creation times, zero and positive @@ -96,16 +89,23 @@ const RUNS = oracleRuns(150) * completion without active work, and any intervening command before retry. * * Production driver and refinement check: the driver calls the real - * executor-facing scheduler methods. After every command, it compares the next - * eligible ID and payload, ordered pending records, counts, and active state. + * executor-facing scheduler methods and records, after every command, the next + * eligible ID and payload, ordered pending records, counts, and active state, + * plus what `start` was given and what a removal returned. The model computes + * the same records from the ledger alone; the check compares them per command, + * each field under its law: `fifo` (the next eligible ID), `update` (payloads), + * `ledger` (ordered pending records and counts), `serial` (the active count), + * and `retirement` (what a removal or clear retires). * - * Reach and controls: one fixed history reaches every transition. Two fixed - * histories cross retry-deadline order. Five observation mutants prove - * the comparison rejects bypass, double issue, stale payload, stale clear, and - * failed selective retirement. The generated property runs at fixed seed 1815 - * and at a random seed with the same budget. Set TANSTACK_DB_ORACLE_SEED, - * TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY to replay a failure - * directly. + * Reach and controls: one pinned history reaches every transition. Two pinned + * histories cross retry-deadline order, and two more pin an active removal + * with a waiting sibling and a blocked FIFO head at its retry deadline. Five + * calibrations prove the comparison rejects bypass, double issue, stale + * payload, stale clear, and failed selective retirement, and three more reject + * a stale next payload, revoked work kept in the pending ledger, and a wrong + * count. The property is `offline.key-scheduler-fifo-ledger` on + * `oracleProperty`; a failure prints the seed, path, and property to set to + * replay it, and a path replay runs only the random lane. * * Limits: persistence, caller promise settlement, leadership, and real timers * have separate owners. The model does not promise fairness beyond FIFO order. @@ -141,7 +141,9 @@ type PlanningState = { // scheduler has no corresponding retryable state. const commandToken = fc.record({ - selector: fc.nat(), + // Unbiased: fast-check biases small naturals, which picked the first few + // choices almost always, so start, fail, and retry were rarely generated. + selector: fc.noBias(fc.nat()), slot: fc.integer({ min: 0, max: 4 }), createdAt: fc.integer({ min: 0, max: 3 }), delay: fc.integer({ min: 0, max: 5 }), @@ -249,9 +251,15 @@ function buildLegalHistory(tokens: Array): Array { }) } if (state.activeId) { + // A replay snapshot may name the active ID, a waiting sibling, and an + // ID the scheduler does not hold. choices.push({ type: `remove`, - ids: [state.activeId, ...removable.slice(0, 1).map(({ id }) => id)], + ids: [ + state.activeId, + ...removable.slice(0, 1).map(({ id }) => id), + ...(Math.abs(token.payload) % 2 === 1 ? [`missing`] : []), + ], }) } if (planningNext(state)) choices.push({ type: `start` }) @@ -277,9 +285,68 @@ function buildLegalHistory(tokens: Array): Array { return history } -const legalHistory = fc - .array(commandToken, { minLength: 1, maxLength: 50 }) - .map(buildLegalHistory) +// The tokens a command list came from, found by search and accepted only if +// they rebuild that exact list. A pinned command list is then a member of the +// generated language, and fast-check can shrink it. +function tokensOf(history: ReadonlyArray): Array { + const tokens: Array = [] + for (let index = 0; index < history.length; index++) { + const command = history[index]! + const prefix = buildLegalHistory(tokens) + const retry = command.type === `fail` ? history[index + 1] : undefined + const base: CommandToken = { + selector: 0, + slot: command.type === `schedule` ? command.slot : 0, + createdAt: command.type === `schedule` ? command.createdAt : 0, + delay: + command.type === `schedule` + ? command.delay + : retry?.type === `retry` + ? retry.delay + : 0, + payload: + command.type === `schedule` || command.type === `bulkUpdate` + ? command.payload + : retry?.type === `retry` + ? retry.payload + : 0, + duration: command.type === `advance` ? command.duration : 0, + } + // A removal's IDs come from the token's payload and slot. + const candidates = + command.type === `remove` + ? Array.from({ length: 11 * 5 }, (_, n) => ({ + ...base, + payload: (n % 11) - 5, + slot: Math.floor(n / 11), + })) + : [base] + let found: CommandToken | undefined + for (const candidate of candidates) + for (let selector = 0; selector < 12 && !found; selector++) { + const token = { ...candidate, selector } + const built = buildLegalHistory([...tokens, token]) + if ( + isDeepStrictEqual(built.slice(prefix.length, prefix.length + 1), [ + command, + ]) + ) + found = token + } + if (!found) throw new Error(`command ${index} is not legal here`) + tokens.push(found) + if (retry?.type === `retry`) index++ + } + if (!isDeepStrictEqual(buildLegalHistory(tokens), history)) + throw new Error(`not a legal scheduler history`) + return tokens +} + +const legalHistory: fc.Arbitrary> = fc + // fast-check's default size keeps arrays near ten elements whatever + // maxLength says; `medium` draws lengths across the declared 1 to 50. + .array(commandToken, { minLength: 1, maxLength: 50, size: `medium` }) + .map(buildLegalHistory, (value) => tokensOf(value as Array)) function cloneTransaction(transaction: OfflineTransaction): OfflineTransaction { return { @@ -308,29 +375,6 @@ function expectedNext(model: Model): OfflineTransaction | undefined { return first && first.nextAttemptAt <= model.now ? first : undefined } -function snapshot( - scheduler: KeyScheduler, - observedNext: OfflineTransaction | undefined, -): Snapshot { - return { - next: observedNext - ? { - id: observedNext.id, - payload: observedNext.metadata?.payload, - } - : undefined, - pending: scheduler.getAllPendingTransactions().map((transaction) => ({ - id: transaction.id, - createdAt: transaction.createdAt.getTime(), - nextAttemptAt: transaction.nextAttemptAt, - retryCount: transaction.retryCount, - payload: transaction.metadata?.payload, - })), - pendingCount: scheduler.getPendingCount(), - runningCount: scheduler.getRunningCount(), - } -} - function expectedSnapshot(model: Model): Snapshot { const next = expectedNext(model) return { @@ -352,458 +396,557 @@ function expectedSnapshot(model: Model): Snapshot { } } -function assertSnapshot( - history: ReadonlyArray, - commandIndex: number, - actual: Snapshot, - expected: Snapshot, -): void { - expect(actual, JSON.stringify({ history, commandIndex })).toEqual(expected) +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- +// +// The ledger replays the commands and owes, after each one, the scheduler's +// snapshot, the transaction a `start` is given, and the IDs a removal +// retires (requested pending IDs other than the active one, once each). A +// command the lifecycle does not allow is a setup error with its name: the +// grammar never generates one, so a pin or a grammar change that does is +// caught here. + +type Owed = { + snapshot: Snapshot + started?: { id: string; payload: unknown } + removed?: Array } - -type ObservationMutant = { - commandIndex: number - apply: (snapshot: Snapshot) => Snapshot -} - -function runHistory( - history: Array, - observationMutant?: ObservationMutant, -): Set { - vi.useFakeTimers() - vi.setSystemTime(BASE_TIME) - const scheduler = new KeyScheduler() - const model: Model = { - now: BASE_TIME, - nextSequence: 0, - pending: [], - } - const coverage = new Set() - - history.forEach((nextCommand, commandIndex) => { - // TransactionExecutor updates a failed transaction before asking for more - // work. Other commands in this short state are not legal lifecycle calls. - if (model.retryableId && nextCommand.type !== `retry`) { - throw new Error(`retry must immediately follow fail`) +const checkpointOf = (index: number, command: Command) => + `${index}: ${command.type}` + +function ledgerModel(history: ReadonlyArray): Map { + const model: Model = { now: BASE_TIME, nextSequence: 0, pending: [] } + const owed = new Map() + for (const [index, command] of history.entries()) { + const extra: Partial = {} + const illegal = (reason: string) => { + throw new Error(`command ${index} (${command.type}): ${reason}`) } - - if (nextCommand.type === `schedule`) { - const id = `tx-${nextCommand.slot}` - if (model.pending.some(({ transaction }) => transaction.id === id)) { - throw new Error(`cannot schedule duplicate transaction ${id}`) - } - const transaction = createTransaction( - id, - BASE_TIME + nextCommand.createdAt * 1000, - model.now + nextCommand.delay * 1000, - nextCommand.payload, + // TransactionExecutor updates a failed transaction before asking for + // more work, so nothing else may come between a failure and its retry. + if (model.retryableId && command.type !== `retry`) + illegal(`retry must immediately follow fail`) + if (command.type === `schedule`) { + if ( + model.pending.some( + ({ transaction }) => transaction.id === `tx-${command.slot}`, + ) ) - scheduler.schedule(transaction) + illegal(`tx-${command.slot} is already pending`) model.pending.push({ - transaction: cloneTransaction(transaction), + transaction: createTransaction( + `tx-${command.slot}`, + BASE_TIME + command.createdAt * 1000, + model.now + command.delay * 1000, + command.payload, + ), sequence: model.nextSequence++, }) - } else if (nextCommand.type === `getNext`) { - scheduler.getNext() - } else if (nextCommand.type === `start`) { - const transaction = expectedNext(model) - if (!transaction) throw new Error(`cannot start without eligible work`) - const observedNext = scheduler.getNext() - expect({ - id: observedNext?.id, - payload: observedNext?.metadata?.payload, - }).toEqual({ + } else if (command.type === `start`) { + const transaction = expectedNext(model) ?? illegal(`no eligible work`) + extra.started = { id: transaction.id, payload: transaction.metadata?.payload, - }) - scheduler.markStarted(observedNext!) + } model.activeId = transaction.id - } else if (nextCommand.type === `complete`) { - if (!model.activeId) - throw new Error(`cannot complete without active work`) - const index = model.pending.findIndex( - ({ transaction }) => transaction.id === model.activeId, + } else if (command.type === `complete`) { + if (!model.activeId) illegal(`no active work`) + model.pending = model.pending.filter( + ({ transaction }) => transaction.id !== model.activeId, ) - const transaction = model.pending[index]!.transaction - scheduler.markCompleted(transaction) - model.pending.splice(index, 1) model.activeId = undefined - } else if (nextCommand.type === `fail`) { - if (!model.activeId) throw new Error(`cannot fail without active work`) - const transaction = model.pending.find( - ({ transaction: candidate }) => candidate.id === model.activeId, - )!.transaction - model.retryableId = transaction.id - } else if (nextCommand.type === `retry`) { - if (!model.retryableId) throw new Error(`cannot retry before a failure`) + } else if (command.type === `fail`) { + if (!model.activeId) illegal(`no active work`) + model.retryableId = model.activeId + } else if (command.type === `retry`) { + if (!model.retryableId) illegal(`no failure to retry`) const entry = model.pending.find( ({ transaction }) => transaction.id === model.retryableId, )! - const failedTransaction = entry.transaction - const updated = { - ...failedTransaction, - retryCount: failedTransaction.retryCount + 1, - nextAttemptAt: model.now + nextCommand.delay * 1000, - metadata: { payload: nextCommand.payload }, + entry.transaction = { + ...entry.transaction, + retryCount: entry.transaction.retryCount + 1, + nextAttemptAt: model.now + command.delay * 1000, + metadata: { payload: command.payload }, } - scheduler.updateTransaction(cloneTransaction(updated)) - scheduler.markFailed(failedTransaction) - entry.transaction = updated model.activeId = undefined model.retryableId = undefined - } else if (nextCommand.type === `bulkUpdate`) { - const updated = model.pending.map(({ transaction }) => ({ - ...transaction, - metadata: { payload: nextCommand.payload }, - })) - scheduler.updateTransactions(updated.map(cloneTransaction)) - for (const [index, transaction] of updated.entries()) { - model.pending[index]!.transaction = transaction - } - } else if (nextCommand.type === `advance`) { - const duration = nextCommand.duration * 1000 - vi.advanceTimersByTime(duration) - model.now += duration - } else if (nextCommand.type === `remove`) { - const expectedRemoved = [ - ...new Set(nextCommand.ids.filter((id) => id !== model.activeId)), + } else if (command.type === `bulkUpdate`) { + for (const entry of model.pending) + entry.transaction = { + ...entry.transaction, + metadata: { payload: command.payload }, + } + } else if (command.type === `advance`) { + model.now += command.duration * 1000 + } else if (command.type === `remove`) { + extra.removed = [ + ...new Set(command.ids.filter((id) => id !== model.activeId)), ] - expect(scheduler.removePendingTransactions(nextCommand.ids)).toEqual( - expectedRemoved, - ) - const ids = new Set(nextCommand.ids) + const ids = new Set(command.ids) model.pending = model.pending.filter( ({ transaction }) => transaction.id === model.activeId || !ids.has(transaction.id), ) - } else { - scheduler.clear() + } else if (command.type === `clear`) { model.pending = [] model.activeId = undefined model.retryableId = undefined } + owed.set(checkpointOf(index, command), { + snapshot: expectedSnapshot(model), + ...extra, + }) + } + return owed +} - coverage.add(nextCommand.type) - const observed = snapshot(scheduler, scheduler.getNext()) - const actual = - observationMutant?.commandIndex === commandIndex - ? observationMutant.apply(observed) - : observed - assertSnapshot(history, commandIndex, actual, expectedSnapshot(model)) - }) +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- +// +// The driver calls the scheduler as TransactionExecutor does, under a fake +// clock. It starts whatever `getNext` returns, fails the transaction it +// started, and builds the retry update from it; it never reads the model. - return coverage +function snapshotOf(scheduler: KeyScheduler): Snapshot { + const next = scheduler.getNext() + return { + next: next ? { id: next.id, payload: next.metadata?.payload } : undefined, + pending: scheduler.getAllPendingTransactions().map((transaction) => ({ + id: transaction.id, + createdAt: transaction.createdAt.getTime(), + nextAttemptAt: transaction.nextAttemptAt, + retryCount: transaction.retryCount, + payload: transaction.metadata?.payload, + })), + pendingCount: scheduler.getPendingCount(), + runningCount: scheduler.getRunningCount(), + } } -declareOracle({ check: [runHistory], model: [expectedSnapshot] }) - -describe(`KeyScheduler generated lifecycle`, () => { - afterEach(() => vi.useRealTimers()) - - // The fixed campaign stands aside during any path replay. The random - // campaign is the replay of FIFO_LEDGER_PROPERTY: it reseeds under a seed - // alone and runs the recorded path under a path replay of that property. - oracleReplayTest(it)( - `matches the FIFO retry ledger at the fixed seed`, - () => { - fc.assert( - fc.property(legalHistory, (history) => { - runHistory(history) - }), - { seed: FIXED_SEED, numRuns: RUNS }, - ) - }, - ) +function driveScheduler( + history: ReadonlyArray, + observe: (checkpoint: string, value: unknown) => void, +) { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + try { + const scheduler = new KeyScheduler() + let active: OfflineTransaction | undefined + for (const [index, command] of history.entries()) { + const extra: Partial = {} + if (command.type === `schedule`) { + scheduler.schedule( + createTransaction( + `tx-${command.slot}`, + BASE_TIME + command.createdAt * 1000, + Date.now() + command.delay * 1000, + command.payload, + ), + ) + } else if (command.type === `getNext`) { + scheduler.getNext() + } else if (command.type === `start`) { + const next = scheduler.getNext() + extra.started = next + ? { id: next.id, payload: next.metadata?.payload } + : undefined + if (next) scheduler.markStarted(next) + active = next && cloneTransaction(next) + } else if (command.type === `complete`) { + if (active) scheduler.markCompleted(active) + active = undefined + } else if (command.type === `retry`) { + if (active) { + scheduler.updateTransaction({ + ...cloneTransaction(active), + retryCount: active.retryCount + 1, + nextAttemptAt: Date.now() + command.delay * 1000, + metadata: { payload: command.payload }, + }) + scheduler.markFailed(active) + } + active = undefined + } else if (command.type === `bulkUpdate`) { + scheduler.updateTransactions( + scheduler.getAllPendingTransactions().map((transaction) => ({ + ...cloneTransaction(transaction), + metadata: { payload: command.payload }, + })), + ) + } else if (command.type === `advance`) { + vi.advanceTimersByTime(command.duration * 1000) + } else if (command.type === `remove`) { + extra.removed = scheduler.removePendingTransactions(command.ids) + } else if (command.type === `clear`) { + scheduler.clear() + active = undefined + } + observe(checkpointOf(index, command), { + snapshot: snapshotOf(scheduler), + ...extra, + }) + } + } finally { + vi.useRealTimers() + } +} - oracleReplayTest(it, FIFO_LEDGER_PROPERTY)( - `matches the FIFO retry ledger at a random or replayed seed`, - () => { - fc.assert( - fc.property(legalHistory, (history) => { - runHistory(history) - }), - oraclePropertyOptions(150, FIFO_LEDGER_PROPERTY), +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- + +function checkLedger( + _history: ReadonlyArray, + trace: ReadonlyArray, + owed: ReturnType, +) { + for (const [checkpoint, expected] of owed) { + const seen = trace.find((entry) => entry.checkpoint === checkpoint) + ?.value as Owed | undefined + if (!seen) + return differs( + checkpoint, + `trace`, + undefined, + expected, + `the driver recorded no "${checkpoint}"`, + )! + const wrong = + differs( + checkpoint, + `fifo`, + seen.started?.id, + expected.started?.id, + `start was given another transaction`, + ) ?? + differs( + checkpoint, + `fifo`, + seen.snapshot.next?.id, + expected.snapshot.next?.id, + `another transaction is next`, + ) ?? + differs( + checkpoint, + `update`, + seen.started?.payload, + expected.started?.payload, + `start was given a stale payload`, + ) ?? + differs( + checkpoint, + `update`, + seen.snapshot.next?.payload, + expected.snapshot.next?.payload, + `the next transaction has a stale payload`, + ) ?? + differs( + checkpoint, + `retirement`, + seen.removed, + expected.removed, + `a removal retired other IDs`, + ) ?? + differs( + checkpoint, + `serial`, + seen.snapshot.runningCount, + expected.snapshot.runningCount, + `the active count differs`, + ) ?? + differs( + checkpoint, + `retirement`, + seen.snapshot.pending.map(({ id }) => id).sort(), + expected.snapshot.pending.map(({ id }) => id).sort(), + `other transactions are pending`, + ) ?? + differs( + checkpoint, + `ledger`, + seen.snapshot, + expected.snapshot, + `the pending ledger differs`, ) - }, - ) + if (wrong) return wrong + } + return pass +} - it(`reconstructs active removal with a waiting sibling`, () => { - const token = (selector: number, slot = 0): CommandToken => ({ - selector, - slot, - createdAt: 0, - delay: 0, - payload: 1, - duration: 0, - }) - const history = buildLegalHistory([ - token(4), // schedule tx-0 - token(5, 1), // schedule tx-1 - token(6), // start tx-0 - token(6), // request removal of both IDs while tx-0 is active - token(6), // complete tx-0 - ]) - - expect(history).toEqual([ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - { type: `remove`, ids: [`tx-0`, `tx-1`] }, - { type: `complete` }, - ]) - runHistory(history) - - const duplicateSlotRequest = buildLegalHistory([token(4), token(5)]) - expect(duplicateSlotRequest[1]).toEqual({ - type: `schedule`, - slot: 1, - createdAt: 0, - delay: 0, - payload: 1, - }) - }) - - it(`reconstructs a blocked FIFO head at the retry deadline`, () => { - const token = ( - selector: number, - slot: number, - delay = 0, - duration = 0, - ): CommandToken => ({ - selector, - slot, - createdAt: slot, - delay, - payload: 1, - duration, - }) - const history = buildLegalHistory([ - token(4, 0), // schedule tx-0 - token(5, 1), // schedule tx-1 - token(6, 0), // start tx-0 - token(8, 0, 5), // fail tx-0, then retry after five seconds - token(0, 0), // tx-1 is ready but cannot bypass tx-0 - token(2, 0, 0, 4), - token(0, 0), // still before the retry deadline - token(2, 0, 0, 1), - token(6, 0), // tx-0 becomes eligible at the deadline - ]) - - expect(history.map(({ type }) => type)).toEqual([ - `schedule`, - `schedule`, - `start`, - `fail`, - `retry`, - `getNext`, - `advance`, - `getNext`, - `advance`, - `start`, - ]) - expect(history[4]).toEqual({ type: `retry`, delay: 5, payload: 1 }) - runHistory(history) - }) - - it(`replays a generated wrong count at the same checkpoint`, () => { - const wrongCount = fc.property(legalHistory, (history) => { - runHistory(history, { - commandIndex: history.length - 1, - apply: (actual) => ({ - ...actual, - pendingCount: actual.pendingCount + 1, - }), - }) - }) - const first = fc.check(wrongCount, { seed: FIXED_SEED, numRuns: RUNS }) - expect(first.failed).toBe(true) - expect(String(first.errorInstance)).toContain(`AssertionError:`) - expect(String(first.errorInstance)).toContain(`"commandIndex":`) - const replayPath = first.counterexamplePath - if (replayPath === null) throw new Error(`Missing shrink path`) - - const replay = fc.check(wrongCount, { - seed: first.seed, - path: replayPath, - numRuns: RUNS, - }) - expect(replay.failed).toBe(true) - expect(replay.counterexample).toEqual(first.counterexample) - expect(String(replay.errorInstance).split(`\n`)[0]).toEqual( - String(first.errorInstance).split(`\n`)[0], +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +const commandTypes = [ + `schedule`, + `getNext`, + `start`, + `fail`, + `retry`, + `bulkUpdate`, + `remove`, + `advance`, + `complete`, + `clear`, +] as const +const schedule = ( + slot: number, + createdAt: number, + delay: number, + payload: number, +): Command => ({ type: `schedule`, slot, createdAt, delay, payload }) + +// Corrupts the first checkpoint whose recorded value `applies` to, or none. +const corruptFirst = + (applies: (value: Owed, checkpoint: string) => Owed | undefined) => + (trace: ReadonlyArray) => { + const at = trace.findIndex( + ({ checkpoint, value }) => + applies(value as Owed, checkpoint) !== undefined, ) - }) - - it(`executes every modeled transition in a fixed replay`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 0, delay: 0, payload: 2 }, - { type: `getNext` }, - { type: `start` }, - { type: `remove`, ids: [`tx-0`, `tx-1`, `missing`] }, - { type: `fail` }, - { type: `retry`, delay: 2, payload: 3 }, - { type: `getNext` }, - { type: `bulkUpdate`, payload: 4 }, - { type: `advance`, duration: 2 }, - { type: `start` }, - { type: `complete` }, - { type: `clear` }, - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 5 }, - ] - expect(runHistory(history)).toEqual( - new Set([ - `schedule`, - `getNext`, - `start`, - `fail`, - `retry`, - `bulkUpdate`, - `remove`, - `advance`, - `complete`, - `clear`, - ]), + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { ...entry, value: applies(entry.value as Owed, entry.checkpoint) } + : entry, ) - }) - - it.each([ - [ - `an earlier retry deadline than its sibling`, - [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 5, payload: 2 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 2, payload: 3 }, - { type: `advance`, duration: 2 }, - { type: `start` }, - ], - ], - [ - `a later retry deadline than its sibling`, - [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 1, payload: 2 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 2, payload: 3 }, - { type: `advance`, duration: 2 }, - { type: `start` }, - ], + } + +oracleProperty({ + id: `offline.key-scheduler-fifo-ledger`, + law: { + statement: `KeyScheduler is one globally serial FIFO queue: equal creation times keep scheduling order, a delayed head blocks younger work, a failure updates retry data before releasing the active transaction without moving it, replay reconciliation retires only unissued IDs, and clear retires all work and leaves the scheduler reusable.`, + authority: `the established KeyScheduler FIFO tests and TransactionExecutor's calling order (packages/offline-transactions/src/executor)`, + limits: `Persistence, caller promise settlement, leadership, and real timers have separate owners; no fairness beyond FIFO order; at most five IDs.`, + owner: `Offline execution`, + }, + histories: { + arbitrary: legalHistory, + states: [], + measured: commandTypes, + runs: 150, + fixedSeeds: [1815], + pins: [ + { + name: `every modeled transition`, + enters: [...commandTypes], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 0, 0, 2), + { type: `getNext` }, + { type: `start` }, + { type: `remove`, ids: [`tx-0`, `tx-1`, `missing`] }, + { type: `fail` }, + { type: `retry`, delay: 2, payload: 3 }, + { type: `getNext` }, + { type: `bulkUpdate`, payload: 4 }, + { type: `advance`, duration: 2 }, + { type: `start` }, + { type: `complete` }, + { type: `clear` }, + schedule(0, 0, 0, 5), + ], + }, + { + name: `an active removal with a waiting sibling`, + enters: [`remove`, `complete`], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 0, 0, 1), + { type: `start` }, + // Request removal of both IDs while tx-0 is active: only tx-1 retires. + { type: `remove`, ids: [`tx-0`, `tx-1`] }, + { type: `complete` }, + ], + }, + { + name: `a blocked FIFO head at its retry deadline`, + enters: [`retry`, `advance`], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 1, 0, 1), + { type: `start` }, + { type: `fail` }, + { type: `retry`, delay: 5, payload: 1 }, + // tx-1 is ready but cannot bypass tx-0. + { type: `getNext` }, + { type: `advance`, duration: 4 }, + // Still before the retry deadline. + { type: `getNext` }, + { type: `advance`, duration: 1 }, + // tx-0 becomes eligible at the deadline. + { type: `start` }, + ], + }, + ...( + [ + [`an earlier retry deadline than its sibling`, 5], + [`a later retry deadline than its sibling`, 1], + ] as const + ).map(([name, siblingDelay]) => ({ + name, + enters: [`retry` as const], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 1, siblingDelay, 2), + { type: `start` }, + { type: `fail` }, + { type: `retry`, delay: 2, payload: 3 }, + { type: `advance`, duration: 2 }, + { type: `start` }, + ] satisfies Array, + })), ], - ] satisfies Array<[string, Array]>)( - `covers %s`, - (_name, history) => { - runHistory(history) + }, + model: ledgerModel, + reached: (history) => [...new Set(history.map(({ type }) => type))], + driver: driveScheduler, + check: checkLedger, + calibrations: [ + { + name: `a ready sibling bypasses the delayed FIFO head`, + law: `fifo`, + corrupt: corruptFirst((value) => + value.snapshot.next === undefined && + value.snapshot.runningCount === 0 && + value.snapshot.pending.length >= 2 + ? { + ...value, + snapshot: { + ...value.snapshot, + next: { + id: value.snapshot.pending[1]!.id, + payload: value.snapshot.pending[1]!.payload, + }, + }, + } + : undefined, + ), }, - ) - - it(`rejects bypassing the delayed FIFO head on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 0, payload: 2 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 5, payload: 3 }, - ] - - expect(() => - runHistory(history, { - commandIndex: 4, - apply: (actual) => ({ - ...actual, - next: { id: `tx-1`, payload: 2 }, - }), - }), - ).toThrow() - }) - - it(`rejects permitting two active transactions on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - ] - - expect(() => - runHistory(history, { - commandIndex: 1, - apply: (actual) => ({ ...actual, runningCount: 2 }), - }), - ).toThrow() - }) - - it(`rejects losing an updated payload on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 0, payload: 9 }, - ] - - expect(() => - runHistory(history, { - commandIndex: 3, - apply: (actual) => ({ - ...actual, - next: actual.next ? { ...actual.next, payload: 1 } : undefined, - }), - }), - ).toThrow() - }) - - it(`rejects retaining stale work after clear on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - { type: `clear` }, - ] - - expect(() => - runHistory(history, { - commandIndex: 2, - apply: (actual) => ({ - ...actual, - pending: [ - { - id: `tx-0`, - createdAt: BASE_TIME, - nextAttemptAt: BASE_TIME, - retryCount: 0, - payload: 1, - }, - ], - pendingCount: 1, - }), - }), - ).toThrow() - }) - - it(`rejects retaining selectively revoked work on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 0, payload: 2 }, - { type: `remove`, ids: [`tx-0`] }, - ] - - expect(() => - runHistory(history, { - commandIndex: 2, - apply: (actual) => ({ - ...actual, - pending: [ - { - id: `tx-0`, - createdAt: BASE_TIME, - nextAttemptAt: BASE_TIME, - retryCount: 0, - payload: 1, + { + name: `two transactions are active`, + law: `serial`, + corrupt: corruptFirst((value) => + value.snapshot.runningCount === 1 + ? { ...value, snapshot: { ...value.snapshot, runningCount: 2 } } + : undefined, + ), + }, + { + name: `a retried transaction starts with its old payload`, + law: `update`, + corrupt: (trace) => { + const retried = trace.findIndex(({ checkpoint }) => + checkpoint.endsWith(`retry`), + ) + const at = trace.findIndex( + ({ checkpoint, value }, index) => + index > retried && + retried >= 0 && + checkpoint.endsWith(`start`) && + (value as Owed).started !== undefined, + ) + if (at < 0) return undefined + return trace.map((entry, index) => { + if (index !== at) return entry + const value = entry.value as Owed + return { + ...entry, + value: { + ...value, + started: { ...value.started!, payload: `stale` }, }, - ...actual.pending, - ], - pendingCount: actual.pendingCount + 1, - }), + } + }) + }, + }, + { + name: `clear keeps stale work`, + law: `retirement`, + corrupt: corruptFirst((value, checkpoint) => + checkpoint.endsWith(`clear`) + ? { + ...value, + snapshot: { + ...value.snapshot, + pending: [ + { + id: `stale`, + createdAt: BASE_TIME, + nextAttemptAt: BASE_TIME, + retryCount: 0, + payload: 1, + }, + ], + pendingCount: 1, + }, + } + : undefined, + ), + }, + { + name: `the next transaction keeps a stale payload`, + law: `update`, + corrupt: corruptFirst((value) => + value.snapshot.next + ? { + ...value, + snapshot: { + ...value.snapshot, + next: { ...value.snapshot.next, payload: `stale` }, + }, + } + : undefined, + ), + }, + { + name: `revoked work stays in the pending ledger`, + law: `retirement`, + corrupt: corruptFirst((value) => { + const revoked = value.removed?.[0] + return revoked + ? { + ...value, + snapshot: { + ...value.snapshot, + pending: [ + { + id: revoked, + createdAt: BASE_TIME, + nextAttemptAt: BASE_TIME, + retryCount: 0, + payload: 1, + }, + ...value.snapshot.pending, + ], + pendingCount: value.snapshot.pendingCount + 1, + }, + } + : undefined }), - ).toThrow() - }) + }, + { + name: `the pending count is off by one`, + law: `ledger`, + corrupt: corruptFirst((value) => ({ + ...value, + snapshot: { + ...value.snapshot, + pendingCount: value.snapshot.pendingCount + 1, + }, + })), + }, + { + name: `a selective removal keeps the revoked work`, + law: `retirement`, + corrupt: corruptFirst((value) => + value.removed && value.removed.length > 0 + ? { ...value, removed: [] } + : undefined, + ), + }, + ], }) From e2de35820b28fb114adbf80421ee4625207c3ae6 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:02:12 -0700 Subject: [PATCH 023/168] test(oracles): repair what the round-3 loss audits found Loss audits of the persisted optimistic lane, FIFO retry, and KeyScheduler ports (old version against new, one fresh reader each): - FIFO retry: the law statement and prose state the progress law (eligible work runs when its deadline passes) and its liveness-not- latency limit, cite the KeyScheduler oracle for the eligibility boundary, and restore the grammar's rationale and exclusions. The never-wakes calibration leaves the whole queue untouched, not only the calls. The driver installs fake timers inside its `try`, runs each release even when another fails, and keeps its own error as the cause of a cleanup failure. - Persisted lane: a pin restores the partial optimistic insert and update behind a held store, as core edits in the language. - Optimistic check: six more violations carry diff values (handler batch, reentrant suffix, aborted open batch, callback shape, receipts, cleanup receipts). - Framework header: `oracleProperty` does not do cleanup; the header said it did. Cleanup stays with the driver. - Coverage map: the scheduler entry records the starved-generator finding. Design questions the audits raised (generated share of a measured state, reach denominators, per-history calibrations, behavior titles, segment budget share) are recorded for the design rerun. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 7 +- ...optimistic-history-oracle.property.test.ts | 23 ++++++ .../db/tests/optimistic-history-oracle.ts | 9 +++ packages/db/tests/oracle-property.ts | 9 +-- .../tests/fifo-retry-oracle.property.test.ts | 71 ++++++++++++++----- 5 files changed, 97 insertions(+), 22 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 99f80b6b24..ed37b3b22c 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1271,7 +1271,12 @@ formulation supplies matching and nonmatching controls. Other `singleResult` forms, join forms, and scheduling cuts remain outside these owners. The offline scheduler owner (`packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts`) -checks FIFO eligibility and retry order at the scheduler boundary. It does not +checks FIFO eligibility and retry order at the scheduler boundary, as the +`oracleProperty` `offline.key-scheduler-fifo-ledger`. Before its port the +generator drew histories of at most 12 commands (fast-check's default array +size) with a biased selector, so 4 of 150 histories per lane started any +transaction and 1 retried; generation now draws 1 to 50 unbiased commands, +and about a quarter of histories start work and a tenth retry. It does not observe the executor's awaited outbox update between `updateTransaction` and `markFailed`, or public caller settlement. Those receiving cuts belong with `packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts`. diff --git a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts index 64c4388645..2bf7de957c 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts @@ -307,6 +307,29 @@ oracleProperty({ }, ], pins: [ + { + // The same history with partial optimistic fields, written as core + // edits whose handler batches do not await acceptance. + name: `a partial insert and update stay over their server rows behind a held store`, + history: { + initial: [], + steps: wrappingSteps( + insertThenUpdateBehindHeldStore.map((step) => + step.type === `edit` && step.inHandler + ? { + ...step, + fields: { a: step.fields.a }, + inHandler: { + ...step.inHandler, + deletes: [], + awaitReceipt: false, + }, + } + : step, + ), + ), + }, + }, { name: `a settled update stays over its insert while the update's server row waits behind a held store`, history: { diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index fb2b70a1b5..8ee49553d1 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -2922,6 +2922,7 @@ export function checkOptimisticHistory( checkpoint, `receipt of source transaction ${id} ${status}, owed ${owed}`, `cleanup`, + { actual: status, expected: owed }, ) } continue @@ -2953,6 +2954,7 @@ export function checkOptimisticHistory( checkpoint, `the handler returned before writing its source transaction`, `handler-batch`, + { actual: seen.handlerWrote, expected: true }, ) if (owed.completion && seen.completion !== owed.completion) return violation( @@ -2973,6 +2975,7 @@ export function checkOptimisticHistory( checkpoint, `the subscriber did not commit the suffix during the trigger`, `reentrant-suffix`, + { actual: seen.suffixCommitted, expected: true }, ) if ( seen.abortedOpen && @@ -2982,6 +2985,10 @@ export function checkOptimisticHistory( checkpoint, `an aborted open transaction: ${JSON.stringify(seen.abortedOpen)}`, `aborted-open-batch`, + { + actual: seen.abortedOpen, + expected: { receiptRejected: true, acceptanceRejected: true }, + }, ) for (const publication of seen.raw) { const difference = eventSemanticsDifference( @@ -3004,6 +3011,7 @@ export function checkOptimisticHistory( checkpoint, `callback key ${JSON.stringify(change.key)} for row ${change.value.id}`, `callback-shape`, + { actual: change.key, expected: change.value.id }, ) const difference = eventSemanticsDifference( new Map(publication.before), @@ -3072,6 +3080,7 @@ export function checkOptimisticHistory( checkpoint, `receipt of source transaction ${id} ${status} before it applied`, `receipts`, + { actual: status, expected: `pending` }, ) for (const [name, rows] of [ [`Collection`, seen.rows], diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index a1beb132d6..03dd2fde34 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -3,10 +3,11 @@ * production driver, and check. * * An oracle file used to assemble these parts by hand: `declareOracle`, a loop - * of `it` calls, assertions spread through the driver, and its own cleanup and - * failure text. `oracleProperty` takes the parts and does the mechanics that - * every oracle shares, so an oracle file holds only the parts that state its - * law. + * of `it` calls, assertions spread through the driver, and its own failure + * text. `oracleProperty` takes the parts and does the mechanics that every + * oracle shares, so an oracle file holds only the parts that state its law. + * Cleanup stays with the driver: a driver releases what it created before it + * returns or throws, so the framework never holds a production resource. * * The parts keep their jobs separate (oracle rubric, ORC-003): * diff --git a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts index 7ab2aa51ef..10b84b6090 100644 --- a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts @@ -22,6 +22,13 @@ import type { OfflineTransaction } from '../src/types' * - The executor runs one transaction at a time, in creation order. A later * transaction does not pass an earlier one that has a retry delay, and a * retried transaction keeps its FIFO position. + * - Eligible work runs when it becomes eligible: at the head's deadline the + * executor runs the head and every ready transaction behind it, in order, + * until a retriable failure or a storage fault. This is a bounded liveness + * check on a controlled clock, not a wall-clock latency promise; timer count + * and polling strategy are outside the judgment. The `nextAttemptAt` + * eligibility boundary itself is the KeyScheduler oracle's + * (`KeyScheduler-oracle.property.test.ts`). * - A retriable provider failure retries the same transaction after its * backoff; nothing behind it runs first. * - A `NonRetriableError` removes its row and rejects its caller with that @@ -39,8 +46,13 @@ import type { OfflineTransaction } from '../src/types' * * History grammar: two to five transactions with increasing `createdAt`; only * the head has a future `nextAttemptAt` (2 to 10,000 ms), and the tail is - * ready. The delay axis moves the head's eligibility boundary; the - * checkpoints at delay-1 and delay distinguish early and late wakeups. + * ready. Two is the smallest head-and-tail witness; larger counts test that + * the whole tail drains, and removing either the tail or the delay loses the + * delayed-head history. The delay axis moves the head's eligibility boundary; + * the checkpoints at delay-1 and delay distinguish early and late wakeups, + * including the 2 ms marginal delay. A tail created before the head, + * duplicate IDs, and a ready head are outside the grammar: each changes the + * premise. * * Model: the attempt order and outcomes follow from the plans alone, without * the scheduler, timers, or the outbox. At the deadline, attempts run until @@ -209,11 +221,14 @@ async function driveFifo( { delay, plans }: History, observe: (checkpoint: string, value: unknown) => void, ) { - vi.useFakeTimers() - // A storage fault stops the executor, which logs the failed retry batch. - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) let executor: TransactionExecutor | undefined + let warn: { mockRestore: () => void } | undefined + let primary: unknown + let failed = false try { + vi.useFakeTimers() + // A storage fault stops the executor, which logs the failed retry batch. + warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) // Fails the planned phase write or deletion for one transaction. const storage = new (class extends FakeStorageAdapter { override async set(key: string, value: string): Promise { @@ -304,11 +319,28 @@ async function driveFifo( for (let round = 0; round < 20 && vi.getTimerCount() > 0; round++) await vi.advanceTimersByTimeAsync(60_000) await record(`after every retry`) - } finally { - executor?.clear() - warn.mockRestore() - vi.useRealTimers() + } catch (error) { + primary = error + failed = true } + // Each release runs even when another fails; a cleanup failure keeps the + // driver's own error as its cause. + const failures: Array = [] + for (const release of [ + () => executor?.clear(), + () => warn?.mockRestore(), + () => vi.useRealTimers(), + ]) + try { + release() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) + throw new AggregateError(failures, `FIFO retry driver cleanup failed`, { + cause: failed ? primary : undefined, + }) + if (failed) throw primary } // --------------------------------------------------------------------------- @@ -376,9 +408,9 @@ const ok: Plan = { failures: 0, final: `success`, storageFault: `none` } oracleProperty({ id: `offline.fifo-retry`, law: { - statement: `The executor runs transactions one at a time in creation order: a ready tail waits for a delayed or retrying head, a permanent failure lets queued rows continue, and a failed phase write or deletion rejects its caller with the storage error and stops the queue.`, - authority: `packages/offline-transactions/README.md: FIFO Sequential Processing, Retry decisions, and offline-execution settlement`, - limits: `An online executor with one mutation function and no retry hooks; no leadership changes; no storage fault on a permanent failure's removal; backoff lengths are not judged.`, + statement: `The executor runs transactions one at a time in creation order: a ready tail waits for a delayed or retrying head, eligible work runs when its deadline passes, a permanent failure lets queued rows continue, and a failed phase write or deletion rejects its caller with the storage error and stops the queue.`, + authority: `packages/offline-transactions/README.md: FIFO Sequential Processing, Retry decisions, and offline-execution settlement; the eligibility boundary from KeyScheduler-oracle.property.test.ts`, + limits: `An online executor with one mutation function, no retry hooks, and jitter off; no leadership changes; no storage fault on a permanent failure's removal; backoff lengths, timer counts, and polling strategy are not judged; liveness on a controlled clock, not wall-clock latency.`, owner: `Offline execution`, }, histories: { @@ -472,12 +504,17 @@ oracleProperty({ { name: `the head never wakes at its deadline`, law: `progress`, - corrupt: (trace) => - trace.map((entry) => - entry.checkpoint === `at deadline` - ? { ...entry, value: { ...(entry.value as State), calls: [] } } + // The whole queue is untouched: no calls, every row still stored. + corrupt: (trace) => { + const queued = trace.find( + ({ checkpoint }) => checkpoint === `before deadline`, + )?.value as State | undefined + return trace.map((entry) => + entry.checkpoint === `at deadline` && queued + ? { ...entry, value: queued } : entry, - ), + ) + }, }, { name: `the queue continues past a storage fault`, From 3b5ec0360910fdf3398b53a7b1aec648f97990fb Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:10:15 -0700 Subject: [PATCH 024/168] test(oracles): move the multiset consolidation oracle onto oracleProperty The consolidation oracle is now the `oracleProperty` `ivm.multiset-consolidate`. Its laws are about reference identity (the retained record is the first input object), and its values include symbols, functions, bigints, NaN, and -0, none of which is plain data. The driver names an output object, array, function, or symbol by the input record that is the same reference, and a primitive, which has no identity, by its type and text; the check decodes names against the plan's own records and applies the model's identity rule. Grammar changes: the keyed witnesses that were separate `it` cases (a delimiter key beside an object tuple, reference values that still merge, signed zero and non-finite keys, NaN values) are now witnesses of the `keyedCollision` dimension, named by index so a pinned witness is a member; structural steps are records, not mapped values; arrays use `size: 'medium'` so the declared 24 steps are drawn. A mutant that treated every 2-element array as keyed passed the old and the first new version: no history held only pairs whose first element is no key. A new structural step `[true, { v }]` and a pin close that. - Law: one record per identity, the first in input order (-0 written as 0 for unkeyed numbers), with the summed multiplicity; zero sums absent; identity by the shape of the whole multiset; the input unchanged (contract comment on MultiSet.consolidate(), b5d92ceb). - Reach: every mode is measured, with "records merged", "join tuples merged", and "only pairs without a key"; pins enter each mode. The last two are rare in generated lanes (0 or 1 of 300), so pins carry them. - RED/Mutant (old oracle against new, on mutated src/multiset.ts): unmerged fresh join tuples, the last record retained, zero-sum keyed entries kept, and -0 not normalized fail both (new: merge, retained, merge, retained). Every pair keyed passes the old oracle and fails the new one (merge). - GREEN: 29 of 29, with four calibrations (repeated identity, unsummed multiplicity, a later record retained, a changed input). - Limits: output order is not judged; MultiSet directly, not the query shapes that produce these records; structural identity uses a 32-bit hash. Co-authored-by: Isaac --- ...ltiset-consolidate-oracle.property.test.ts | 793 ++++++++++++------ 1 file changed, 535 insertions(+), 258 deletions(-) diff --git a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts index 9555e9bf6e..f649045248 100644 --- a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts +++ b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, it as vitestIt } from 'vitest' -import { fc, test as fcTest } from '@fast-check/vitest' +import fc from 'fast-check' import { MultiSet } from '../src/multiset.js' import { - oraclePropertyOptions, - oracleReplayTest, - oracleRuns, -} from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' + differs, + oracleProperty, + pass, + violation, +} from '../../db/tests/oracle-property.js' import { contentsOf } from './multiset-support.js' +import type { Observation } from '../../db/tests/oracle-property.js' import type { Data } from './multiset-support.js' import type { MultiSetArray } from '../src/multiset.js' @@ -108,7 +108,8 @@ const KEYED_KEYS: ReadonlyArray = [ // Model. It does not use production identity helpers or `hash`. type Expected = { - groups: Array<{ first: Data; sum: number }> + /** `firstIndex` is the input position of the identity's first record. */ + groups: Array<{ first: Data; firstIndex: number; sum: number }> sameIdentity: (left: Data, right: Data) => boolean } @@ -170,10 +171,15 @@ function expectedConsolidation(records: MultiSetArray): Expected { onePrimitiveType && Object.is(data, -0) ? 0 : data const groups: Expected['groups'] = [] - for (const [data, multiplicity] of records) { + for (const [index, [data, multiplicity]] of records.entries()) { const group = groups.find(({ first }) => sameIdentity(first, data)) if (group) group.sum += multiplicity - else groups.push({ first: retained(data), sum: multiplicity }) + else + groups.push({ + first: retained(data), + firstIndex: index, + sum: multiplicity, + }) } return { groups: groups.filter(({ sum }) => sum !== 0), sameIdentity } } @@ -212,15 +218,19 @@ type StructuralStep = // Equal objects whose two keys tie under locale collation, inserted in // either order. They must merge. | { fresh: `collationTie`; reversed: boolean } + // A pair whose first element is no key: a multiset of only these is not + // keyed, so equal object contents must merge structurally. + | { fresh: `unkeyedPair`; v: number } type Mode = `keyed` | `keyedCollision` | `numbers` | `strings` | `structural` | `fallback` // `keyedCollision` selects grammar witnesses; it exercises the same keyed // production path as `keyed`. `caseName` labels a replay, not product state. +// A `keyedCollision` history has one step: the witness index and the scale +// of its multiplicities. type History = { mode: Mode steps: Array<[unknown, number]> - caseName?: string } // Each pair has distinct keyed identities under the documented rule. They @@ -283,16 +293,67 @@ const collisionCases: Array<{ name: string; records: MultiSetArray }> = [ ], }, ] -const collisionHistoryArb: fc.Arbitrary = fc - .tuple(fc.constantFrom(...collisionCases), fc.constantFrom(-2, -1, 1, 2)) - .map(([collision, multiplicity]) => ({ - mode: `keyedCollision`, - caseName: collision.name, - steps: collision.records.map(([data], index) => [ - data, - index === 0 ? multiplicity : -multiplicity, - ]), - })) +// Keyed witnesses whose records the generated keyed grammar cannot build: +// reference-identity values that must still merge with themselves, a signed +// zero key, non-finite keys, and a delimiter key beside an object tuple. +const delimiterFirst = { v: 1 } +const delimiterSecond = { v: 2 } +collisionCases.push( + { + name: `a direct object and an object tuple stay apart across a delimiter key`, + records: [ + [[`a|ref_1`, delimiterSecond], 1], + [[`a`, [delimiterFirst, delimiterSecond]], -1], + ], + }, + { + name: `the same symbol value still merges`, + records: [ + [[`k`, firstSymbol], 1], + [[`k`, firstSymbol], 2], + ], + }, + { + name: `the same function value still merges`, + records: [ + [[`k`, firstFunction], 1], + [[`k`, firstFunction], 2], + ], + }, + { + name: `signed zero keys still merge`, + records: [ + [[-0, `v`], 1], + [[0, `v`], 2], + ], + }, + { + name: `non-finite numeric keys stay separate`, + records: [ + [[NaN, `v`], 1], + [[Infinity, `v`], 2], + [[-Infinity, `v`], 3], + ], + }, + { + name: `NaN values still merge`, + records: [ + [[`k`, NaN], 1], + [[`k`, NaN], 2], + ], + }, +) +// A witness is named by index, and its multiplicities are scaled, so the +// history is plain data and a pinned witness is a member of the language. +const collisionHistoryArb: fc.Arbitrary = fc.record({ + mode: fc.constant(`keyedCollision` as const), + steps: fc.tuple( + fc.tuple( + fc.integer({ min: 0, max: collisionCases.length - 1 }), + fc.constantFrom(-2, -1, 1, 2), + ), + ), +}) const historyArb: fc.Arbitrary = fc.oneof( collisionHistoryArb, @@ -303,20 +364,21 @@ const historyArb: fc.Arbitrary = fc.oneof( fc.tuple(fc.nat(KEYED_KEYS.length - 1), keyedSpecArb), multiplicityArb, ), - { maxLength: 24 }, + { maxLength: 24, size: `medium` }, ), }), fc.record({ mode: fc.constant(`numbers` as const), steps: fc.array( fc.tuple(fc.constantFrom(0, -0, 1, 2, 3, NaN), multiplicityArb), - { maxLength: 24 }, + { maxLength: 24, size: `medium` }, ), }), fc.record({ mode: fc.constant(`strings` as const), steps: fc.array(fc.tuple(fc.constantFrom(`p`, `q`, `r`), multiplicityArb), { maxLength: 24, + size: `medium`, }), }), fc.record({ @@ -325,18 +387,23 @@ const historyArb: fc.Arbitrary = fc.oneof( fc.tuple( fc.oneof( fc.constantFrom(1, `1`, true, null), - fc.nat(2).map((v): StructuralStep => ({ fresh: `object`, v })), - fc - .tuple(fc.nat(1), fc.nat(1)) - .map((pair): StructuralStep => ({ fresh: `array`, pair })), - fc.boolean().map((reversed): StructuralStep => ({ - fresh: `collationTie`, - reversed, - })), + fc.record({ fresh: fc.constant(`object` as const), v: fc.nat(2) }), + fc.record({ + fresh: fc.constant(`array` as const), + pair: fc.tuple(fc.nat(1), fc.nat(1)), + }), + fc.record({ + fresh: fc.constant(`collationTie` as const), + reversed: fc.boolean(), + }), + fc.record({ + fresh: fc.constant(`unkeyedPair` as const), + v: fc.nat(1), + }), ), multiplicityArb, ), - { minLength: 1, maxLength: 24 }, + { minLength: 1, maxLength: 24, size: `medium` }, ), }), fc.record({ @@ -346,7 +413,7 @@ const historyArb: fc.Arbitrary = fc.oneof( fc.tuple(fc.nat(FALLBACK_KEYS.length - 1), fallbackSpecArb), multiplicityArb, ), - { minLength: 1, maxLength: 23 }, + { minLength: 1, maxLength: 23, size: `medium` }, ), }), ) @@ -363,10 +430,7 @@ function buildRecords(history: History): MultiSetArray { ? [spec.i, spec.i, spec.i] : fromLeaf(spec) const records: MultiSetArray = history.steps.map(([step, m]) => { - if (history.mode === `keyedCollision`) { - const [key, value] = step as [string | number, Data] - return [[key, Array.isArray(value) ? [...value] : value], m] - } + if (history.mode === `keyedCollision`) return [step, m] if (history.mode === `keyed` || history.mode === `fallback`) { const [keyIndex, spec] = step as [number, Spec] return [ @@ -389,268 +453,481 @@ function buildRecords(history: History): MultiSetArray { if (value.reversed) entries.reverse() return [Object.fromEntries(entries), m] } + if (value.fresh === `unkeyedPair`) return [[true, { v: value.v }], m] return [value.fresh === `object` ? { v: value.v } : [...value.pair], m] } return [value, m] }) // One non-keyed record makes the whole multiset unkeyed. if (history.mode === `fallback`) records.push([{ v: 1 }, 1]) + if (history.mode === `keyedCollision`) { + const [[caseIndex, scale]] = history.steps as [[number, number]] + return collisionCases[caseIndex]!.records.map(([data, m]) => [ + Array.isArray(data) && Array.isArray(data[1]) + ? [data[0], [...data[1]]] + : data, + m * scale, + ]) + } return records } // --------------------------------------------------------------------------- -// Production driver and refinement check. - -function expectRecordsConsolidated(records: MultiSetArray): void { - const inputSnapshot = records.map(([data, m]) => [data, m] as const) - const contentIds = new Map() - const inputContents = contentsOf(records, contentIds) - const { groups: expected, sameIdentity } = expectedConsolidation(records) - - const actual = new MultiSet(records).consolidate().getInner() +// Production driver +// --------------------------------------------------------------------------- +// +// The driver consolidates the plan's records and records plain data. An +// output object, array, function, or symbol is named by the first input +// record that is the same reference (or as fresh when none is); a primitive, +// which has no identity, by its type and text, with -0 kept distinct. It also records the +// input's references, multiplicities, and contents before and after. + +type Name = { input: number } | { type: string; text: string } +type Consolidated = { + output: Array<{ data: Name; multiplicity: number }> + inputBefore: string + inputAfter: string + /** Positions whose data or multiplicity changed (`Object.is`). */ + inputChanged: Array +} - // Consolidation does not change the input. - expect(records.length).toBe(inputSnapshot.length) - records.forEach(([data, m], index) => { - expect(data).toBe(inputSnapshot[index]![0]) - expect(m).toBe(inputSnapshot[index]![1]) - }) - expect(contentsOf(records, contentIds), `input record contents`).toBe( - inputContents, - ) +const isReference = (data: Data) => + (data !== null && typeof data === `object`) || + typeof data === `function` || + typeof data === `symbol` +const primitiveName = (data: Data): Name => ({ + type: typeof data, + text: Object.is(data, -0) ? `-0` : String(data), +}) - // Record every output entry. A duplicate identity or a zero multiplicity - // stays visible. - const seen = new Set() - for (const [data, multiplicity] of actual) { - const index = expected.findIndex(({ first }) => sameIdentity(first, data)) - expect( - index, - `unexpected output entry ${seen.size}`, - ).toBeGreaterThanOrEqual(0) - expect(seen.has(index), `duplicate identity ${index}`).toBe(false) - seen.add(index) - const group = expected[index]! - expect(multiplicity, `multiplicity of group ${index}`).toBe(group.sum) - expect(data, `retained record of group ${index}`).toBe(group.first) - } - expect( - expected.flatMap((_, index) => (seen.has(index) ? [] : [index])), - `missing identities`, - ).toEqual([]) +function nameOf(records: MultiSetArray, data: Data): Name { + if (!isReference(data)) return primitiveName(data) + const index = records.findIndex(([input]) => input === data) + return index >= 0 + ? { input: index } + : { type: `fresh ${typeof data}`, text: `` } } -function expectConsolidation(history: History): void { - expectRecordsConsolidated(buildRecords(history)) +/** The records one history builds, and the mode they were built in. */ +type Plan = { mode: Mode; records: MultiSetArray } + +function driveConsolidation( + { records }: Plan, + observe: (checkpoint: string, value: unknown) => void, +) { + const before = records.map(([data, m]) => [data, m] as const) + const ids = new Map() + const inputBefore = contentsOf(records, ids) + const output = new MultiSet(records).consolidate().getInner() + observe(`consolidated`, { + output: output.map(([data, multiplicity]) => ({ + data: nameOf(records, data), + multiplicity, + })), + inputBefore, + inputAfter: contentsOf(records, ids), + inputChanged: before.flatMap(([data, m], index) => + Object.is(records[index]?.[0], data) && Object.is(records[index]?.[1], m) + ? [] + : [index], + ), + } satisfies Consolidated) } -declareOracle({ - check: [expectRecordsConsolidated], - model: [expectedConsolidation], - grammar: [historyArb], -}) - // --------------------------------------------------------------------------- -// Campaigns. The fixed and random campaigns run the same property, grammar, -// check, and budget. A path replay of `ivm.multiset-consolidate` runs its -// recorded history once, and every other test stands aside. A seed alone -// reseeds the random campaign. - -const FIXED_SEED = 2026929 -const CONSOLIDATE = `ivm.multiset-consolidate` - -const it = oracleReplayTest(vitestIt) - -describe(`MultiSet consolidation oracle`, () => { - it.each(collisionCases)(`$name`, ({ records }) => - expectRecordsConsolidated(records), - ) - - it(`keeps a direct object and an object tuple apart across a delimiter key`, () => { - const first = { v: 1 } - const second = { v: 2 } - expectRecordsConsolidated([ - [[`a|ref_1`, second], 1], - [[`a`, [first, second]], -1], - ]) - }) +// Check +// --------------------------------------------------------------------------- +// +// Each output record must name an owed identity once, with the identity's +// summed multiplicity (`merge`), and be the identity's first input record, +// normalized as rule 2 says (`retained`); every owed identity must appear +// (`merge`); the input must be unchanged (`input`). + +function owedName(first: Data, firstIndex: number): Name { + return isReference(first) ? { input: firstIndex } : primitiveName(first) +} - it.each<[string, MultiSetArray]>([ - [ - `the same symbol value still merges`, - [ - [[`k`, firstSymbol], 1], - [[`k`, firstSymbol], 2], - ], - ], - [ - `the same function value still merges`, - [ - [[`k`, firstFunction], 1], - [[`k`, firstFunction], 2], - ], - ], - [ - `signed zero keys still merge`, - [ - [[-0, `v`], 1], - [[0, `v`], 2], - ], - ], - [ - `non-finite numeric keys stay separate`, - [ - [[NaN, `v`], 1], - [[Infinity, `v`], 2], - [[-Infinity, `v`], 3], - ], - ], - [ - `NaN values still merge`, - [ - [[`k`, NaN], 1], - [[`k`, NaN], 2], - ], - ], - ])(`%s`, (_name, records) => expectRecordsConsolidated(records)) +function decode(records: MultiSetArray, name: Name): Data | undefined { + if (`input` in name) return records[name.input]?.[0] + if (name.type === `number`) return Number(name.text) + if (name.type === `string`) return name.text + if (name.type === `boolean`) return name.text === `true` + if (name.type === `bigint`) return BigInt(name.text) + if (name.type === `undefined`) return undefined + return name.text === `null` ? null : name.text +} - it(`matches the identity model across generated multisets (${FIXED_SEED})`, () => { - fc.assert( - fc.property(historyArb, (history) => expectConsolidation(history)), - { numRuns: oracleRuns(300), seed: FIXED_SEED }, +function checkConsolidation( + { records }: Plan, + trace: ReadonlyArray, + { groups, sameIdentity }: Expected, +) { + const seen = trace.find(({ checkpoint }) => checkpoint === `consolidated`) + ?.value as Consolidated | undefined + if (!seen) + return violation(`consolidated`, `the driver recorded nothing`, `trace`) + const inputWrong = + differs( + `consolidated`, + `input`, + seen.inputChanged, + [], + `input records changed`, + ) ?? + differs( + `consolidated`, + `input`, + seen.inputAfter, + seen.inputBefore, + `input record contents changed`, ) - }) - oracleReplayTest(fcTest, CONSOLIDATE).prop( - [historyArb], - oraclePropertyOptions(300, CONSOLIDATE), - )( - `matches the identity model across generated multisets (random)`, - expectConsolidation, + if (inputWrong) return inputWrong + const matched = new Set() + for (const [position, { data, multiplicity }] of seen.output.entries()) { + const value = decode(records, data) + const index = groups.findIndex(({ first }) => sameIdentity(first, value)) + if (index < 0) + return violation( + `consolidated`, + `output entry ${position} ${JSON.stringify(data)} is no owed identity`, + `merge`, + ) + if (matched.has(index)) + return violation( + `consolidated`, + `output entry ${position} repeats identity ${index}`, + `merge`, + ) + matched.add(index) + const group = groups[index]! + const wrong = + differs( + `consolidated`, + `merge`, + multiplicity, + group.sum, + `identity ${index} has another multiplicity`, + ) ?? + differs( + `consolidated`, + `retained`, + data, + owedName(group.first, group.firstIndex), + `identity ${index} kept another record`, + ) + if (wrong) return wrong + } + const missing = groups.flatMap((_, index) => + matched.has(index) ? [] : [index], + ) + return ( + differs( + `consolidated`, + `merge`, + missing, + [], + `owed identities are missing`, + ) ?? pass ) +} - // Positive execution witness: the fixed campaign reaches every mode, and many - // histories have fewer result identities than distinct input records. That - // happens only when records merge or cancel. - it(`reaches every mode and merges records in the fixed campaign`, () => { - const sample = fc.sample(historyArb, { seed: FIXED_SEED, numRuns: 300 }) - const modes = new Set(sample.map((history) => history.mode)) - expect([...modes].sort()).toEqual( - [ - `fallback`, - `keyed`, - `keyedCollision`, - `numbers`, - `strings`, - `structural`, - ].sort(), - ) - const withMerges = sample.filter((history) => { - const records = buildRecords(history) - return ( - expectedConsolidation(records).groups.length < - new Set(records.map(([data]) => data)).size - ) - }) - expect(withMerges.length).toBeGreaterThan(30) - }) +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +const modes = [ + `keyed`, + `keyedCollision`, + `numbers`, + `strings`, + `structural`, + `fallback`, +] as const +const witness = (name: string): History => ({ + mode: `keyedCollision`, + steps: [[collisionCases.findIndex((entry) => entry.name === name), 1]], +}) - // Pinned witnesses for each identity rule. The generated campaigns can reach - // these, but pinning them makes each rule visible and stable. - it.each<[string, History]>([ - [ - `keyed object values merge by reference, not by contents`, +oracleProperty({ + id: `ivm.multiset-consolidate`, + law: { + statement: `MultiSet.consolidate() keeps one record per identity, the first in input order (with -0 written as 0 for unkeyed numbers), with the summed multiplicity; an identity whose sum is zero is absent; identity is keyed, single-primitive, or structural by the shape of the whole multiset; the input is unchanged.`, + authority: `the contract comment on MultiSet.consolidate() in packages/db-ivm/src/multiset.ts (b5d92ceb)`, + limits: `Output order is not part of the law. MultiSet directly, not the query shapes that produce these records. Structural identity uses a 32-bit hash; the small domain makes a collision unlikely, not impossible. Key order only for one collation tie.`, + owner: `Structural values and ordered primitives`, + }, + histories: { + arbitrary: historyArb, + states: [], + measured: [ + ...modes, + `records merged`, + `join tuples merged`, + `only pairs without a key`, + ], + runs: 300, + fixedSeeds: [2026929], + pins: [ + ...collisionCases.map(({ name }) => ({ + name, + enters: [`keyedCollision`], + history: witness(name), + })), { - mode: `keyed`, - steps: [ - [[0, { kind: `obj`, i: 0 }], 1], - [[0, { kind: `obj`, i: 1 }], -1], - [[0, { kind: `obj`, i: 0 }], 1], - ], + name: `keyed object values merge by reference, not by contents`, + enters: [`keyed`], + history: { + mode: `keyed`, + steps: [ + [[0, { kind: `obj`, i: 0 }], 1], + [[0, { kind: `obj`, i: 1 }], -1], + [[0, { kind: `obj`, i: 0 }], 1], + ], + }, }, - ], - [ - `fresh join tuples with the same elements merge`, { - mode: `keyed`, - steps: [ - [ + name: `fresh join tuples with the same elements merge`, + enters: [`keyed`, `records merged`], + history: { + mode: `keyed`, + steps: [ [ - 3, - { - kind: `tuple`, - a: { kind: `prim`, i: 3 }, - b: { kind: `prim`, i: 5 }, - }, + [ + 3, + { + kind: `tuple`, + a: { kind: `prim`, i: 3 }, + b: { kind: `prim`, i: 5 }, + }, + ], + 1, ], - 1, - ], - [ [ - 3, - { - kind: `tuple`, - a: { kind: `prim`, i: 3 }, - b: { kind: `prim`, i: 5 }, - }, + [ + 3, + { + kind: `tuple`, + a: { kind: `prim`, i: 3 }, + b: { kind: `prim`, i: 5 }, + }, + ], + -1, ], - -1, - ], - [ [ - 3, - { - kind: `tuple`, - a: { kind: `obj`, i: 2 }, - b: { kind: `prim`, i: 0 }, - }, + [ + 3, + { + kind: `tuple`, + a: { kind: `obj`, i: 2 }, + b: { kind: `prim`, i: 0 }, + }, + ], + 2, ], - 2, ], - ], + }, }, - ], - [ - `arrays of other lengths stay reference values`, { - mode: `keyed`, - steps: [ - [[1, { kind: `triple`, i: 0 }], 1], - [[1, { kind: `triple`, i: 0 }], 1], - ], + name: `arrays of other lengths stay reference values`, + enters: [`keyed`], + history: { + mode: `keyed`, + steps: [ + [[1, { kind: `triple`, i: 0 }], 1], + [[1, { kind: `triple`, i: 0 }], 1], + ], + }, }, - ], - [ - `one non-keyed record makes equal contents merge structurally`, { - mode: `fallback`, - steps: [ - [[0, { kind: `obj`, i: 0 }], 1], - [[0, { kind: `obj`, i: 1 }], 1], - ], + name: `one non-keyed record makes equal contents merge structurally`, + enters: [`fallback`, `records merged`], + history: { + mode: `fallback`, + steps: [ + [[0, { kind: `obj`, i: 0 }], 1], + [[0, { kind: `obj`, i: 1 }], 1], + ], + }, }, - ], - [ - `single-number data merges -0 with 0 and returns 0`, { - mode: `numbers`, - steps: [ - [-0, 1], - [0, 1], - [NaN, 1], - [NaN, 1], - ], + name: `single-number data merges -0 with 0 and returns 0`, + // A Set already treats -0 and 0, and NaN and NaN, as one record, so + // this merge does not count as `records merged`. + enters: [`numbers`], + history: { + mode: `numbers`, + steps: [ + [-0, 1], + [0, 1], + [NaN, 1], + [NaN, 1], + ], + }, }, - ], - [ - `unkeyed structural data keeps 1 and "1" apart`, { - mode: `structural`, - steps: [ - [1, 1], - [`1`, -1], - [1, 1], - ], + name: `single-string data merges equal strings`, + enters: [`strings`, `records merged`], + history: { + mode: `strings`, + steps: [ + [`p`, 1], + [`q`, 2], + [`p`, -1], + ], + }, + }, + { + name: `pairs without a key merge equal object contents structurally`, + enters: [`structural`, `only pairs without a key`, `records merged`], + history: { + mode: `structural`, + steps: [ + [{ fresh: `unkeyedPair`, v: 0 }, 1], + [{ fresh: `unkeyedPair`, v: 0 }, 1], + [{ fresh: `unkeyedPair`, v: 1 }, -1], + ], + }, + }, + { + name: `unkeyed structural data keeps 1 and "1" apart`, + enters: [`structural`], + history: { + mode: `structural`, + steps: [ + [1, 1], + [`1`, -1], + [1, 1], + ], + }, }, ], - ])(`%s`, (_name, history) => expectConsolidation(history)) + }, + resolve: (history): Plan => ({ + mode: history.mode, + records: buildRecords(history), + }), + model: ({ records }) => expectedConsolidation(records), + // A history's mode, and whether consolidation merged or cancelled records + // (fewer owed identities than distinct input records). + reached: ({ mode, records }, _trace, expected) => [ + mode, + ...(expected.groups.length < new Set(records.map(([data]) => data)).size + ? [`records merged`] + : []), + // Two fresh join tuples of one key with the same elements. + ...(mode === `keyed` && + expected.groups.some( + ({ first }) => + Array.isArray((first as [unknown, unknown])[1]) && + records.filter(([data]) => expected.sameIdentity(first, data)).length > + 1, + ) + ? [`join tuples merged`] + : []), + ...(records.length > 0 && + records.every( + ([data]) => + Array.isArray(data) && + data.length === 2 && + typeof data[0] !== `string` && + typeof data[0] !== `number`, + ) + ? [`only pairs without a key`] + : []), + ], + driver: driveConsolidation, + check: checkConsolidation, + calibrations: [ + { + name: `one identity appears twice`, + law: `merge`, + corrupt: (trace) => + trace.map((entry) => { + const value = entry.value as Consolidated + return entry.checkpoint === `consolidated` && value.output.length > 0 + ? { + ...entry, + value: { + ...value, + output: [...value.output, value.output[0]!], + }, + } + : entry + }), + }, + { + name: `a multiplicity is not summed`, + law: `merge`, + corrupt: (trace) => + trace.map((entry) => { + const value = entry.value as Consolidated + return entry.checkpoint === `consolidated` && value.output.length > 0 + ? { + ...entry, + value: { + ...value, + output: value.output.map((record, index) => + index === 0 + ? { ...record, multiplicity: record.multiplicity + 1 } + : record, + ), + }, + } + : entry + }), + }, + { + // A later record of the same identity, under another reference, named + // in place of the first. + name: `a later record of an identity is retained`, + law: `retained`, + corrupt: (trace, { records }) => { + const value = trace.find( + ({ checkpoint }) => checkpoint === `consolidated`, + )?.value as Consolidated | undefined + if (!value) return undefined + const { sameIdentity } = expectedConsolidation(records) + for (const [position, { data }] of value.output.entries()) { + if (!(`input` in data)) continue + const first = records[data.input]![0] + const later = records.findIndex( + ([candidate], index) => + index > data.input && + candidate !== first && + sameIdentity(first, candidate), + ) + if (later < 0) continue + return trace.map((entry) => + entry.checkpoint === `consolidated` + ? { + ...entry, + value: { + ...value, + output: value.output.map((record, index) => + index === position + ? { ...record, data: { input: later } } + : record, + ), + }, + } + : entry, + ) + } + return undefined + }, + }, + { + name: `consolidation changes the input`, + law: `input`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `consolidated` + ? { + ...entry, + value: { ...(entry.value as Consolidated), inputChanged: [0] }, + } + : entry, + ), + }, + ], }) From 070ef66c0c565bd47bf7dc0ee2e16029529cb11a Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:16:01 -0700 Subject: [PATCH 025/168] test(oracles): move cursor read histories onto oracleProperty, with an exact page-count law `cursor-pagination.history` is now an `oracleProperty`. Its grammar folds in what were fixed cases: invalid windows, the largest safe window end, peer reads issued at once, a window object changed right after its call, a backend whose first page is empty and nonterminal, and a page larger than the relation. The old property only checked that a reverse reread fetched nothing; the README's transport rules now give an exact law: after each step the pager has fetched the shortest prefix of backend pages that covers every window read so far (every page when a window drains the source; none for a zero limit). The model computes it from the backend's page sizes alone. - Law: a read returns exactly the requested slice of the whole filtered ordered relation; an invalid window rejects with RangeError before transport; a window is captured when called; zero limit does no transport; fetched pages are reused (cursor-pagination/README.md, "Contract before implementation"). - Reach: invalid windows, peer reads, empty first pages, zero limits, windows past the end, and changed windows are measured; each is entered by 13 to 100 of 100 histories per lane. - RED/Mutant (old file against new, on mutated src/cursor-pagination.ts): an empty page taken as exhaustion, over-fetching one page, a lazily read window, and an accepted unsafe window end fail both (new: transport, transport, invalid+slice, invalid). A zero limit that fetches passed both; a peer's fetch hid it in every history that had one, so a pin reads a lone zero limit first, and it now fails (transport). Unserialized reads still fail the file's held-response tests, which stay as they were. - GREEN: 39 of 39 in the file. - Limits: one scope per pager; reset, faults, and cancellation stay with the file's other properties. Co-authored-by: Isaac --- .../tests/cursor-pagination.oracle.test.ts | 505 ++++++++++++++---- 1 file changed, 393 insertions(+), 112 deletions(-) diff --git a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts index 402d2c5bca..2520550ed8 100644 --- a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts @@ -1,5 +1,10 @@ import fc from 'fast-check' import { describe, expect, it, vi } from 'vitest' +import { + differs, + oracleProperty, + pass, +} from '../../db/tests/oracle-property.js' import { createDeferred } from '../../db/src/deferred.js' import { oraclePropertyOptions, @@ -8,6 +13,7 @@ import { import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model-oracle.js' import { createCursorPager } from './cursor-pagination/pager.js' +import type { Observation } from '../../db/tests/oracle-property.js' import type { Row, Scope, Window } from './cursor-pagination/model-oracle.js' /** @@ -152,99 +158,6 @@ describe(`cursor pagination reference`, () => { }) describe(`opaque cursor adapter`, () => { - it.each([ - { offset: -1, limit: 1 }, - { offset: 0.5, limit: 1 }, - { offset: Infinity, limit: 1 }, - { offset: NaN, limit: 1 }, - { offset: 0, limit: -1 }, - { offset: 0, limit: 0.5 }, - { offset: 0, limit: Infinity }, - { offset: 0, limit: NaN }, - { offset: Number.MAX_SAFE_INTEGER, limit: 1 }, - ])( - `rejects invalid windows before transport: $offset/$limit`, - async (window) => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - await expect(pager.read(window)).rejects.toThrow(RangeError) - expect(backend.calls).toEqual([]) - await checkRead( - fixtureRows, - allAscending, - { offset: 0, limit: 2 }, - pager.read, - ) - }, - ) - - it(`accepts the largest safe window end`, async () => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - // Rejecting at MAX_SAFE_INTEGER would pass the invalid-window cases above. - await checkRead( - fixtureRows, - allAscending, - { offset: Number.MAX_SAFE_INTEGER - 1, limit: 1 }, - pager.read, - ) - expect(backend.calls).toHaveLength(Math.ceil(fixtureRows.length / 2)) - }) - - it(`captures each requested window before queued work starts`, async () => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - const window = { offset: 1, limit: 3 } - const expected = expectedRows(fixtureRows, allAscending, window) - const pending = pager.read(window) - window.offset = 7 - window.limit = 1 - expect(await pending).toEqual(expected) - }) - - it.each([1, 2, 5, 50])( - `fulfills windows across backend pages of %i`, - async (pageSize) => { - const backend = createBackend(fixtureRows, allAscending, pageSize) - const pager = createCursorPager(backend.fetchPage) - for (const window of [ - { offset: 0, limit: 0 }, - { offset: 0, limit: 3 }, - { offset: 2, limit: 6 }, - { offset: 0, limit: undefined }, - { offset: 30, limit: 2 }, - ]) { - await checkRead(fixtureRows, allAscending, window, pager.read) - } - expect(backend.calls).toHaveLength( - Math.ceil(fixtureRows.length / pageSize), - ) - }, - ) - - it(`matches whole-relation slices across random window histories`, async () => { - await assertCursorProperty( - fc.asyncProperty( - rowsArbitrary, - scopeArbitrary, - fc.integer({ min: 1, max: 8 }), - fc.array(windowArbitrary, { minLength: 1, maxLength: 20 }), - async (rows, scope, pageSize, windows) => { - const backend = createBackend(rows, scope, pageSize) - const pager = createCursorPager(backend.fetchPage) - for (const window of windows) - await checkRead(rows, scope, window, pager.read) - const calls = backend.calls.length - for (const window of [...windows].reverse()) - await checkRead(rows, scope, window, pager.read) - expect(backend.calls).toHaveLength(calls) - }, - ), - 100, - `cursor-pagination.history`, - ) - }) - it(`keeps answers invariant under backend page repartition`, async () => { await assertCursorProperty( fc.asyncProperty( @@ -321,25 +234,6 @@ describe(`opaque cursor adapter`, () => { }, ) - it(`serializes unequal peer windows without refetching cached pages`, async () => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - const windows = [ - { offset: 0, limit: 3 }, - { offset: 2, limit: 6 }, - { offset: 0, limit: 2 }, - ] - const results = await Promise.all( - windows.map((window) => pager.read(window)), - ) - expect(results).toEqual( - windows.map((window) => expectedRows(fixtureRows, allAscending, window)), - ) - expect(backend.calls).toHaveLength(4) - await checkRead(fixtureRows, allAscending, windows[0]!, pager.read) - expect(backend.calls).toHaveLength(4) - }) - it(`resets source generations and isolates filter/order scopes`, async () => { await assertCursorProperty( fc.asyncProperty( @@ -424,3 +318,390 @@ describe(`opaque cursor adapter`, () => { ) }) }) + +// --------------------------------------------------------------------------- +// Read histories over one pager, on oracleProperty +// --------------------------------------------------------------------------- +// +// Law (cursor-pagination/README.md, "Contract before implementation"): a +// successful read returns exactly the requested slice of the whole filtered, +// ordered relation, or fewer rows only at exhaustion; an invalid window +// rejects with RangeError before transport; reads of one pager serialize and +// reuse fetched pages; zero limit performs no transport work; short and empty +// nonterminal pages do not establish exhaustion. So the pages fetched after +// any step are the shortest prefix of the backend's pages that covers every +// window read so far, or every page when a window drains the source. +// +// History: rows, a scope, a backend page size (1 to 8, or 50, larger than +// any relation here), and whether the backend's +// first page is empty and nonterminal; then one to six steps, each one to +// three windows read at once, valid or invalid, with the caller's window +// objects optionally changed right after each call; then every valid window +// read again in reverse order, which must fetch nothing. +// +// Model: `expectedRows` for each valid window, `RangeError` for each invalid +// one, and the page count from the backend's page sizes alone (the model +// never sees tokens). Driver: the production pager over the opaque backend; +// it records each read's rows or error name and the backend's call count +// after each step. Limits: one scope per pager, no reset, faults, or +// cancellation (the reset and failure properties below own those). + +const invalidWindows: ReadonlyArray = [ + { offset: -1, limit: 1 }, + { offset: 0.5, limit: 1 }, + { offset: Infinity, limit: 1 }, + { offset: NaN, limit: 1 }, + { offset: 0, limit: -1 }, + { offset: 0, limit: 0.5 }, + { offset: 0, limit: Infinity }, + { offset: 0, limit: NaN }, + { offset: Number.MAX_SAFE_INTEGER, limit: 1 }, +] +type Read = { window: Window } | { invalid: number } +type ReadHistory = { + rows: Array + scope: Scope + pageSize: number + emptyFirst: boolean + steps: Array<{ reads: Array; changeAfterCall: boolean }> +} +const readArbitrary: fc.Arbitrary = fc.oneof( + { weight: 6, arbitrary: fc.record({ window: windowArbitrary }) }, + // The window whose end is the largest safe integer is valid. + { + weight: 1, + arbitrary: fc.record({ + window: fc.record({ + offset: fc.constant(Number.MAX_SAFE_INTEGER - 1), + limit: fc.constant(1), + }), + }), + }, + { + weight: 1, + arbitrary: fc.record({ + invalid: fc.integer({ min: 0, max: invalidWindows.length - 1 }), + }), + }, +) +const readHistory: fc.Arbitrary = fc.record({ + rows: rowsArbitrary, + scope: scopeArbitrary, + // A page of 50 is larger than any relation here: one terminal page. + pageSize: fc.oneof( + { weight: 4, arbitrary: fc.integer({ min: 1, max: 8 }) }, + { weight: 1, arbitrary: fc.constant(50) }, + ), + emptyFirst: fc.boolean(), + steps: fc.array( + fc.record({ + reads: fc.array(readArbitrary, { minLength: 1, maxLength: 3 }), + changeAfterCall: fc.boolean(), + }), + { minLength: 1, maxLength: 6 }, + ), +}) + +const windowOf = (read: Read): Window => + `window` in read ? read.window : invalidWindows[read.invalid]! +const validWindows = ({ steps }: ReadHistory) => + steps.flatMap(({ reads }) => + reads.flatMap((read) => (`window` in read ? [read.window] : [])), + ) + +type ReadResult = { rows: Array } | { error: string } +type StepRecord = { results: Array; calls: number } + +/** The backend's page sizes, from the filtered row count alone. */ +function pageSizes({ rows, scope, pageSize, emptyFirst }: ReadHistory) { + const count = expectedRows(rows, scope, { + offset: 0, + limit: undefined, + }).length + const pages = Array.from( + { length: Math.max(1, Math.ceil(count / pageSize)) }, + (_, index) => Math.min(pageSize, count - index * pageSize), + ) + return { count, pages: emptyFirst ? [0, ...pages] : pages } +} + +/** Pages a window needs: the shortest covering prefix, or every page. */ +function pagesFor(history: ReadHistory, { offset, limit }: Window): number { + if (limit === 0) return 0 + const { pages } = pageSizes(history) + if (limit === undefined) return pages.length + let rows = 0 + for (const [index, size] of pages.entries()) { + rows += size + if (rows >= offset + limit) return index + 1 + } + return pages.length +} + +function readModel(history: ReadHistory): Map { + const owed = new Map() + let calls = 0 + const resultOf = (read: Read): ReadResult => + `window` in read + ? { rows: expectedRows(history.rows, history.scope, read.window) } + : { error: `RangeError` } + for (const [index, { reads }] of history.steps.entries()) { + for (const read of reads) + if (`window` in read) + calls = Math.max(calls, pagesFor(history, read.window)) + owed.set(`step ${index}`, { results: reads.map(resultOf), calls }) + } + owed.set(`reread`, { + results: [...validWindows(history)].reverse().map((window) => ({ + rows: expectedRows(history.rows, history.scope, window), + })), + calls, + }) + return owed +} + +async function driveReads( + history: ReadHistory, + observe: (checkpoint: string, value: unknown) => void, +) { + const backend = createBackend( + history.rows, + history.scope, + history.pageSize, + history.emptyFirst, + ) + const pager = createCursorPager(backend.fetchPage) + const settle = (promise: Promise>) => + promise.then( + (rows): ReadResult => ({ rows }), + (error: unknown): ReadResult => ({ + error: error instanceof Error ? error.name : String(error), + }), + ) + for (const [index, { reads, changeAfterCall }] of history.steps.entries()) { + const windows = reads.map((read) => ({ ...windowOf(read) })) + const pending = windows.map((window) => settle(pager.read(window))) + // A pager captures each requested window when it is called. + if (changeAfterCall) + for (const window of windows) { + window.offset = 7 + window.limit = 1 + } + observe(`step ${index}`, { + results: await Promise.all(pending), + calls: backend.calls.length, + } satisfies StepRecord) + } + const results: Array = [] + for (const window of [...validWindows(history)].reverse()) + results.push(await settle(pager.read({ ...window }))) + observe(`reread`, { + results, + calls: backend.calls.length, + } satisfies StepRecord) +} + +function checkReads( + _history: ReadHistory, + trace: ReadonlyArray, + owed: ReturnType, +) { + for (const [checkpoint, expected] of owed) { + const seen = trace.find((entry) => entry.checkpoint === checkpoint) + ?.value as StepRecord | undefined + const wrong = + differs( + checkpoint, + `invalid`, + seen?.results.map((result) => `error` in result), + expected.results.map((result) => `error` in result), + `a read rejected or fulfilled against its window's validity`, + ) ?? + differs( + checkpoint, + `slice`, + seen?.results, + expected.results, + `a read returned another slice`, + ) ?? + differs( + checkpoint, + `transport`, + seen?.calls, + expected.calls, + `the pager fetched another number of pages`, + ) + if (wrong) return wrong + } + return pass +} + +const pinned = ( + name: string, + pageSize: number, + steps: ReadHistory[`steps`], +): { name: string; history: ReadHistory } => ({ + name, + history: { + rows: fixtureRows, + scope: allAscending, + pageSize, + emptyFirst: false, + steps, + }, +}) +const readsOf = (...windows: Array) => ({ + reads: windows.map((window) => ({ window })), + changeAfterCall: false, +}) + +oracleProperty({ + id: `cursor-pagination.history`, + law: { + statement: `A cursor pager returns exactly the requested slice of the whole filtered ordered relation, rejects an invalid window with RangeError before transport, captures each window when called, and fetches only the shortest prefix of backend pages that covers what was read, reusing fetched pages.`, + authority: `packages/query-db-collection/tests/cursor-pagination/README.md, "Contract before implementation"; the exported createCursorPager API`, + limits: `One scope per pager; no reset, faults, or cancellation in this property; a stable backend relation, not snapshot consistency for changing endpoints.`, + owner: `Opaque backend pagination`, + }, + histories: { + arbitrary: readHistory, + states: [], + measured: [ + `invalid window`, + `peer reads`, + `empty first page`, + `zero limit`, + `window past the end`, + `window changed after call`, + ], + runs: 100, + fixedSeeds: [fixedSeed], + pins: [ + ...[1, 2, 5, 50].map((pageSize) => + pinned(`windows across backend pages of ${pageSize}`, pageSize, [ + readsOf( + { offset: 0, limit: 0 }, + { offset: 0, limit: 3 }, + { offset: 2, limit: 6 }, + ), + readsOf({ offset: 0, limit: undefined }, { offset: 30, limit: 2 }), + ]), + ), + pinned(`unequal peer windows serialize without refetching`, 2, [ + readsOf( + { offset: 0, limit: 3 }, + { offset: 2, limit: 6 }, + { offset: 0, limit: 2 }, + ), + ]), + // Alone and first, so no peer's fetch hides a zero-limit fetch. + pinned(`a zero limit performs no transport work`, 2, [ + readsOf({ offset: 3, limit: 0 }), + readsOf({ offset: 0, limit: 0 }), + ]), + pinned(`the largest safe window end`, 2, [ + readsOf({ offset: Number.MAX_SAFE_INTEGER - 1, limit: 1 }), + ]), + pinned(`a window changed after its call`, 2, [ + { reads: [{ window: { offset: 1, limit: 3 } }], changeAfterCall: true }, + ]), + ...invalidWindows.map((_, invalid) => + pinned(`invalid window ${invalid} rejects before transport`, 2, [ + { reads: [{ invalid }], changeAfterCall: false }, + readsOf({ offset: 0, limit: 2 }), + ]), + ), + ], + }, + model: readModel, + reached: (history) => { + const all = history.steps.flatMap(({ reads }) => reads) + const { count } = pageSizes(history) + return [ + ...(all.some((read) => `invalid` in read) ? [`invalid window`] : []), + ...(history.steps.some(({ reads }) => reads.length > 1) + ? [`peer reads`] + : []), + ...(history.emptyFirst ? [`empty first page`] : []), + ...(validWindows(history).some((window) => window.limit === 0) + ? [`zero limit`] + : []), + ...(validWindows(history).some((window) => window.offset > count) + ? [`window past the end`] + : []), + ...(history.steps.some(({ changeAfterCall }) => changeAfterCall) + ? [`window changed after call`] + : []), + ] + }, + driver: driveReads, + check: checkReads, + calibrations: [ + { + name: `a read returns one page only`, + law: `slice`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => + (value as StepRecord).results.some( + (result) => `rows` in result && result.rows.length > 2, + ), + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { + ...entry, + value: { + ...(entry.value as StepRecord), + results: (entry.value as StepRecord).results.map((result) => + `rows` in result + ? { rows: result.rows.slice(0, 2) } + : result, + ), + }, + } + : entry, + ) + }, + }, + { + name: `a reread fetches again`, + law: `transport`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `reread` + ? { + ...entry, + value: { + ...(entry.value as StepRecord), + calls: (entry.value as StepRecord).calls + 1, + }, + } + : entry, + ), + }, + { + name: `an invalid window reaches transport and fulfills`, + law: `invalid`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => + (value as StepRecord).results.some((result) => `error` in result), + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { + ...entry, + value: { + ...(entry.value as StepRecord), + results: (entry.value as StepRecord).results.map((result) => + `error` in result ? { rows: [] } : result, + ), + }, + } + : entry, + ) + }, + }, + ], +}) From 65b4f0ceab37da208db493e70a02685c324956c0 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:30:13 -0700 Subject: [PATCH 026/168] test(oracles): require every oracleProperty id to be registered Only the random lane checked that a property id was registered, through `oraclePropertyOptions`. An enumerated source has no random lane, so all four enumerated ports registered unknown ids, with no replay manifest entry: `local-storage.peers`, `local-storage.write-order`, `adapter.handler-types`, and the new `sqlite-persistence.setup-reopen`. `oracleProperty` now checks its id when it registers; the four ids are registered and have manifest entries. - Law: a property registers its tests, or fails to load when its id is not a registered oracle property, a required state has no segment, or a segment names an undeclared state (self-oracle `registration`; authority: the registry in oracle-config.ts and the replay manifest). - Reach: drafts now include, rarely, an unregistered id; a pin registers an unregistered enumerated property (state `registration-error`). The pin was first rejected by the framework's own membership check, so the draft grammar gained the dimension. - RED: on the parent, the pin fails with law registration ("loaded, but its id is unregistered ..."). - GREEN: `pnpm --filter @tanstack/db test:oracle-framework`, 22 of 22; the manifest test, 81 of 81; every oracleProperty owner loads. - Mutant: the parent is the mutant (no check for enumerated sources). Co-authored-by: Isaac --- packages/db/tests/oracle-config.ts | 6 +++- .../oracle-property-self-oracle-fixture.ts | 7 +++-- .../tests/oracle-property-self-oracle.test.ts | 29 +++++++++++++++++-- packages/db/tests/oracle-property.ts | 4 +++ packages/db/tests/oracle-replay-manifest.ts | 16 ++++++++++ 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index be74a88c8d..b4d4f6f930 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -212,6 +212,8 @@ const staticOracleProperties = [ `sqlite-persistence.open-transaction-boundary`, `sqlite-persistence.request-local-failure-buffered-source`, `sqlite-persistence.optimistic-history`, + `sqlite-persistence.setup-reopen`, + `adapter.handler-types`, `trailbase.lifecycle`, `source-health.history`, `source-health.required-history`, @@ -278,6 +280,8 @@ const staticOracleProperties = [ `pooled-live-query.publication`, `flat-change-tracking.equivalence`, `local-storage.value-fidelity`, + `local-storage.peers`, + `local-storage.write-order`, `live-query-observer.hydration-terminal`, `optimistic-history.core`, `self-oracle.synthetic-0`, @@ -550,7 +554,7 @@ export const registeredOracleProperties = validateOraclePropertyRegistry([ ...includesMatrixProperties, ]) -function assertRegisteredOracleProperty(property: string): void { +export function assertRegisteredOracleProperty(property: string): void { if (!registeredOracleProperties.has(property)) { throw new Error(`unknown oracle property: ${property}`) } diff --git a/packages/db/tests/oracle-property-self-oracle-fixture.ts b/packages/db/tests/oracle-property-self-oracle-fixture.ts index 5571bb1f45..1be5346cde 100644 --- a/packages/db/tests/oracle-property-self-oracle-fixture.ts +++ b/packages/db/tests/oracle-property-self-oracle-fixture.ts @@ -64,6 +64,8 @@ export type SyntheticSpec = { /** Each history takes about 300 ms; with `timeout`, tests outlive it. */ slow?: boolean timeout?: number + /** Registers under an id no oracle configuration knows. */ + unregistered?: boolean } /** The synthetic language: one to three items, mostly ordinary ones. */ @@ -156,6 +158,7 @@ function syntheticCalibrations( } export function syntheticProperty(spec: SyntheticSpec): void { + const id = spec.unregistered ? `self-oracle.unregistered` : spec.id const parts = { model: () => undefined, // Without `reached`, the framework counts the driver's `reach` records. @@ -172,7 +175,7 @@ export function syntheticProperty(spec: SyntheticSpec): void { } if (spec.source.kind === `enumerated`) { oracleProperty({ - id: spec.id, + id, law, histories: spec.source.histories, // A label with `|`, which a rerun command must escape. @@ -183,7 +186,7 @@ export function syntheticProperty(spec: SyntheticSpec): void { return } oracleProperty({ - id: spec.id, + id, law, histories: { arbitrary: syntheticArbitrary, diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts index 79aadcc902..8d7d3183ca 100644 --- a/packages/db/tests/oracle-property-self-oracle.test.ts +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -8,8 +8,9 @@ * predicts. The laws, each judged per test or per probe: * * - `registration`: a property registers its lanes, pins, calibrations, and - * determinism test, or fails to load when a required state has no segment - * or a segment names an undeclared state. + * determinism test, or fails to load when its id is not a registered + * oracle property, a required state has no segment, or a segment names an + * undeclared state. * - `verdict`: a test fails with a violation of law L exactly when its first * failing history violates L. Within one history a driver error decides * first, then a law violation, then a missed segment state; a driver error @@ -93,6 +94,8 @@ type Draft = { /** `slow`: 300 ms per history under a 100 ms timeout. */ timing: `normal` | `slow` calibrations: Array<(typeof calibrationKinds)[number]> + /** An id no oracle configuration registers. */ + unregistered: boolean } type Suite = { drafts: Array } @@ -161,6 +164,11 @@ const draft: fc.Arbitrary = fc.record({ calibrations: fc.array(fc.constantFrom(...calibrationKinds), { maxLength: 3, }), + // Rare: a property under an id no oracle configuration registers. + unregistered: fc.oneof( + { weight: 9, arbitrary: fc.constant(false) }, + { weight: 1, arbitrary: fc.constant(true) }, + ), }) const suite: fc.Arbitrary = fc.record({ drafts: fc.array(draft, { minLength: 1, maxLength: 2 }), @@ -209,6 +217,7 @@ function resolveSuite({ drafts }: Suite): Plan { : [], nondeterministic: d.nondeterministic, reachVia: d.reachVia, + ...(d.unregistered ? { unregistered: true } : {}), ...(d.timing === `slow` ? { slow: true, timeout: 100 } : {}), calibrations: d.calibrations.map((kind, at) => ({ name: `cal-${at}`, @@ -306,6 +315,8 @@ function predictSpec( .map((entry) => entry.history) const tests = new Map() const generated = spec.source.kind === `generated` + // Every source, enumerated or generated, must name a registered property. + if (spec.unregistered) return { loads: false } if (generated) { const declared = new Set(spec.states) const entered = new Set(spec.segments.flatMap((segment) => segment.enters)) @@ -885,7 +896,7 @@ function checkSuite( if (seen.loaded) return violation( `run`, - `${spec.id} loaded, but a required state has no segment or a segment names an undeclared state`, + `${spec.id} loaded, but its id is unregistered, a required state has no segment, or a segment names an undeclared state`, `registration`, ) continue @@ -1066,6 +1077,7 @@ const generatedDraft = (overrides: Partial): Draft => ({ reachVia: `reached`, timing: `normal`, calibrations: [], + unregistered: false, ...overrides, }) const pin = ( @@ -1138,6 +1150,17 @@ oracleProperty({ segments: [{ prefix: [0], enters: [`saw-0`] }], }), ), + // An enumerated source has no random lane, which is where an id was + // checked before; registration itself must check it. + pin( + `an unregistered enumerated property fails to load`, + [`registration-error`], + { + ...generatedDraft({}), + source: { kind: `enumerated`, histories: [[0], [1]] }, + unregistered: true, + }, + ), pin( `a calibration skips a candidate whose clean trace fails`, [`calibration-skipped-candidate`, `calibration-applied`], diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 03dd2fde34..f6392f4a41 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -107,6 +107,7 @@ import { isDeepStrictEqual } from 'node:util' import fc from 'fast-check' import { describe, expect, it } from 'vitest' import { + assertRegisteredOracleProperty, oraclePropertyOptions, oracleReplayTest, oracleRuns, @@ -415,6 +416,9 @@ interface PropertyTest { export function oracleProperty( options: OraclePropertyOptions, ): void { + // Every source names a registered property, so its replay and manifest + // entries exist; an enumerated source has no random lane to check it. + assertRegisteredOracleProperty(options.id) const { id, histories } = options recordRegistration(id, options.law, histories) // The tests are built as data first, so every one registers unconditionally diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index e557790600..e66371233b 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -471,6 +471,16 @@ const ownerGroups: ReadonlyArray< `sqlite-persistence`, `optimistic-history`, ], + [ + `db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts`, + `sqlite-persistence`, + `setup-reopen`, + ], + [ + `db/tests/mutation-handler-types-oracle.test.ts`, + `adapter`, + `handler-types`, + ], [ `db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts`, `sqlite-resume`, @@ -531,6 +541,12 @@ const ownerGroups: ReadonlyArray< `local-storage`, `value-fidelity`, ], + [`db/tests/local-storage-peers-oracle.test.ts`, `local-storage`, `peers`], + [ + `db/tests/local-storage-write-order-oracle.test.ts`, + `local-storage`, + `write-order`, + ], [ `db/tests/query/bucket-facade-rollback-oracle.property.test.ts`, `bucket-facade`, From d782252d298c0d59d1d2be9dc81acc5bfa518eda Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:30:18 -0700 Subject: [PATCH 027/168] test(oracles): move the SQLite setup-reopen oracle onto an enumerated oracleProperty The setup-reopen oracle looped over 140 starting states inside one test and closed its databases only on success. It is now the enumerated `oracleProperty` `sqlite-persistence.setup-reopen`: each starting state is its own test, the driver closes both in-memory databases however the history ends, and the model owes the seven schema objects, the rows, and whether the work law applies. The old model took SQL text from a production fresh install; that is now an observation: the driver records a fresh install on a separate database, and the check compares the reopened schema with it (a differential law) and the fresh install's objects with the model's. - Law: after a fresh adapter's first read, the Collection's schema equals a fresh install's, both bookkeeping rows exist, and stored rows are unchanged, from every legal starting state; complete setup issues no DDL or writes and costs at most one call over a cached read (the adapter's per-Collection setup contract; issue #2204). - Reach: enumerated; every starting state runs (no declared reach for enumerated sources yet, recorded for the design rerun). - RED/Mutant: see the next run's results in the misfit record; four calibrations reject a lost trigger, a missing epoch row, dropped rows, and repeated DDL. - GREEN: 145 of 145. - Limits: one node:sqlite database per side; no cache-generation claims, concurrent adapters, or dropped row tables with rows. Co-authored-by: Isaac --- .../collection-setup-reopen-oracle.test.ts | 251 ++++++++++++++---- 1 file changed, 197 insertions(+), 54 deletions(-) diff --git a/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts index cfba613d5b..42529868d7 100644 --- a/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts @@ -1,8 +1,13 @@ import { DatabaseSync } from 'node:sqlite' -import { describe, expect, it } from 'vitest' import { SQLiteCorePersistenceAdapter, createPersistedTableName } from '../src' -import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { + differs, + oracleProperty, + pass, + violation, +} from '../../db/tests/oracle-property.js' import type { PersistedTx, SQLiteDriver } from '../src' +import type { Observation as TraceEntry } from '../../db/tests/oracle-property.js' /** * # Does reopening a persisted Collection repair setup without redoing it? @@ -26,11 +31,12 @@ import type { PersistedTx, SQLiteDriver } from '../src' * read after setup is cached in memory. A partial state may cost more. * * Model: a starting state is the set of setup facts present. The model names - * the seven schema objects (type, name, table) from the fact list itself. - * Their SQL text is taken from a production fresh install on a separate - * database, so that part checks reopen against fresh install rather than - * the absolute trigger and table definitions. The expected rows are the - * seeded rows. A state is complete exactly when every fact is present. + * the seven schema objects (type, name, table) from the fact list itself. The + * expected rows are the seeded rows. A state is complete exactly when every + * fact is present. SQL text is not in the model: the driver also records a + * production fresh install on a separate database, and the check compares + * the reopened schema with it (a differential law), and the fresh install's + * objects with the model's. * * History grammar, on one node:sqlite database: * - Interrupted setup: the registry row exists and setup stopped after the @@ -50,7 +56,11 @@ import type { PersistedTx, SQLiteDriver } from '../src' * first reads another Collection, so global initialization is not measured. * It then calls `loadSubset` on the target Collection, and calls it again for * the cached baseline. The setup and row observations are taken after the - * first `loadSubset` resolves. + * first `loadSubset` resolves. Each history opens two in-memory databases + * and closes both however the history ends. + * + * The property is `sqlite-persistence.setup-reopen` on `oracleProperty`, + * enumerated over all 140 starting states, each its own test. */ type Row = { id: string; title: string } @@ -259,14 +269,13 @@ function bookkeeping(database: DatabaseSync): { const readOptions = {} const target = (id: string) => ({ kind: `eager` as const, collectionId: id }) -async function freshInstallSchema(): Promise> { - const database = new DatabaseSync(`:memory:`) +async function freshInstallSchema( + database: DatabaseSync, +): Promise> { const { driver } = countingDriver(database) const adapter = new SQLiteCorePersistenceAdapter({ driver }) await adapter.loadSubset(target(collectionId), readOptions) - const schema = collectionSchema(database) - database.close() - return schema + return collectionSchema(database) } type Start = { @@ -284,8 +293,10 @@ type Observation = { cachedReadCalls: number } -async function reopen(start: Start): Promise { - const database = new DatabaseSync(`:memory:`) +async function reopen( + start: Start, + database: DatabaseSync, +): Promise { const install = countingDriver(database) const installer = new SQLiteCorePersistenceAdapter({ driver: install.driver, @@ -321,7 +332,6 @@ async function reopen(start: Start): Promise { const cachedBefore = totalCalls(reload.counts) await adapter.loadSubset(target(collectionId), readOptions) const cachedReadCalls = totalCalls(reload.counts) - cachedBefore - database.close() return { schema, @@ -371,47 +381,180 @@ const starts: Array = [ ] // The model: what a fresh adapter's first read must leave behind. -function expectedRows(start: Start): Array { +function setupModel(start: Start) { const resets = start.reopenSchemaVersion !== undefined - return start.seedRows && !resets ? seededRows : [] + return { + objects: modelSchemaObjects(), + rows: start.seedRows && !resets ? seededRows : [], + // The work law applies only when all nine facts hold and nothing resets. + workLawApplies: start.absent.length === 0 && !resets, + } } -function workLawApplies(start: Start): boolean { - return start.absent.length === 0 && start.reopenSchemaVersion === undefined +// The driver: a fresh install and a reopen, each on its own in-memory +// database, both closed however the history ends. +async function driveReopen( + start: Start, + observe: (checkpoint: string, value: unknown) => void, +) { + const databases: Array = [] + const open = () => { + const database = new DatabaseSync(`:memory:`) + databases.push(database) + return database + } + try { + observe(`fresh install`, await freshInstallSchema(open())) + observe(`reopened`, await reopen(start, open())) + } finally { + for (const database of databases) + try { + database.close() + } catch { + // A database that failed to open has nothing to release. + } + } } -declareOracle({ model: [modelSchemaObjects] }) - -describe(`collection setup reopen oracle`, () => { - it(`repairs every legal starting state to a fresh install and skips complete setup`, async () => { - const expectedSchema = await freshInstallSchema() - // The model's seven objects, independent of production setup. - expect( - expectedSchema.map(({ type, name, tbl_name }) => ({ - type, - name, - tbl_name, - })), - ).toEqual(modelSchemaObjects()) - - for (const start of starts) { - const observed = await reopen(start) - expect(observed.schema, `${start.name}: schema`).toEqual(expectedSchema) - expect(observed.bookkeeping, `${start.name}: bookkeeping`).toEqual({ - version: true, - epoch: true, - }) - expect(observed.rows, `${start.name}: rows`).toEqual(expectedRows(start)) - if (workLawApplies(start)) { - expect( - { exec: observed.openCalls.exec, run: observed.openCalls.run }, - `${start.name}: complete setup issues no DDL or writes`, - ).toEqual({ exec: 0, run: 0 }) - expect( - totalCalls(observed.openCalls) - observed.cachedReadCalls, - `${start.name}: setup overhead over a cached read`, - ).toBeLessThanOrEqual(1) - } - } - }) +// The check: the fresh install has the model's seven objects; the reopened +// Collection's schema equals the fresh install's, both bookkeeping rows +// exist, and its rows are the model's; when the work law applies, opening +// issues no DDL or writes and costs at most one call over a cached read. +function checkReopen( + _start: Start, + trace: ReadonlyArray, + owed: ReturnType, +) { + const fresh = trace.find(({ checkpoint }) => checkpoint === `fresh install`) + ?.value as Array | undefined + const seen = trace.find(({ checkpoint }) => checkpoint === `reopened`) + ?.value as Observation | undefined + if (!fresh || !seen) + return violation(`reopened`, `the driver recorded no reopen`, `trace`) + const wrong = + differs( + `fresh install`, + `setup`, + fresh.map(({ type, name, tbl_name }) => ({ type, name, tbl_name })), + owed.objects, + `a fresh install created other schema objects than the model names`, + ) ?? + differs( + `reopened`, + `setup`, + seen.schema, + fresh, + `the reopened schema differs from a fresh install`, + ) ?? + differs( + `reopened`, + `bookkeeping`, + seen.bookkeeping, + { version: true, epoch: true }, + `a bookkeeping row is missing`, + ) ?? + differs(`reopened`, `rows`, seen.rows, owed.rows, `stored rows changed`) + if (wrong || !owed.workLawApplies) return wrong ?? pass + return ( + differs( + `reopened`, + `work`, + { exec: seen.openCalls.exec, run: seen.openCalls.run }, + { exec: 0, run: 0 }, + `complete setup issued DDL or writes`, + ) ?? + (totalCalls(seen.openCalls) - seen.cachedReadCalls > 1 + ? violation( + `reopened`, + `setup cost ${totalCalls(seen.openCalls) - seen.cachedReadCalls} calls over a cached read, owed at most 1`, + `work`, + ) + : pass) + ) +} + +oracleProperty({ + id: `sqlite-persistence.setup-reopen`, + law: { + statement: `After a fresh adapter's first read of a registered Collection, its schema objects equal a fresh install's, both bookkeeping rows exist, and stored rows are unchanged, from every legal starting state; when all nine setup facts already hold, the read issues no DDL or writes and costs at most one call more than a cached read.`, + authority: `the SQLite core adapter's per-Collection setup contract; the accepted persistence goal that reloads are fast (issue #2204)`, + limits: `One node:sqlite database; no cache-generation claims, concurrent adapters, or dropped row tables that still hold rows. SQL text is checked against a production fresh install, not absolute definitions.`, + owner: `SQLite persistence`, + }, + histories: starts, + label: (start) => start.name, + model: setupModel, + driver: driveReopen, + check: checkReopen, + calibrations: [ + { + name: `a trigger is not restored`, + law: `setup`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `reopened` + ? { + ...entry, + value: { + ...(entry.value as Observation), + schema: (entry.value as Observation).schema.filter( + ({ type }) => type !== `trigger`, + ), + }, + } + : entry, + ), + }, + { + name: `the reset epoch row is missing`, + law: `bookkeeping`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `reopened` + ? { + ...entry, + value: { + ...(entry.value as Observation), + bookkeeping: { version: true, epoch: false }, + }, + } + : entry, + ), + }, + { + name: `repair drops stored rows`, + law: `rows`, + corrupt: (trace) => { + const seen = trace.find(({ checkpoint }) => checkpoint === `reopened`) + ?.value as Observation | undefined + if (!seen?.rows.length) return undefined + return trace.map((entry) => + entry.checkpoint === `reopened` + ? { ...entry, value: { ...seen, rows: [] } } + : entry, + ) + }, + }, + { + name: `complete setup runs DDL again`, + law: `work`, + corrupt: (trace, start) => + setupModel(start).workLawApplies + ? trace.map((entry) => + entry.checkpoint === `reopened` + ? { + ...entry, + value: { + ...(entry.value as Observation), + openCalls: { + ...(entry.value as Observation).openCalls, + exec: 1, + }, + }, + } + : entry, + ) + : undefined, + }, + ], }) From ab6a9bdc0c8f9419d1813125df3cc725be73b4f1 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 13:40:54 -0700 Subject: [PATCH 028/168] test(oracles): repair what the round-4 loss audits found Loss audits of the multiset, cursor, and setup-reopen ports (old version against new, one fresh reader each): - Cursor histories: an invalid window must reject with a RangeError instance (the name alone could be spoofed); any other rejection keeps its name and message. Invalid-window pins are named by their values. Histories allow ten steps, past the old 20 sequential windows. The largest-safe boundary and capture-at-call rationale are back. The authority now says where minimality comes from: the README's transport rules with the exact counts the old fixed tests pinned. - Multiset: collision witnesses are named by case name, not index; an `opposed` dimension restores the old rule that later records negate the first; each history clones witness objects (shared references stay shared), so no object is reused across runs; the printed limits keep the #1948 and non-finite key scope; pin rationale and a stale comment fixed. - Setup-reopen: the printed law keeps "exactly (type, name, table, SQL)", "for the registered schema version", and "a partial state may cost more"; the fresh-install-versus-model comparison has its own law (`fresh-install`); the prose no longer says one database. Design questions the audits raised again (behavior test titles, generated-share minimums for measured states, one printed law string) are recorded for the design rerun. Co-authored-by: Isaac --- ...ltiset-consolidate-oracle.property.test.ts | 49 +++++++++++++------ .../collection-setup-reopen-oracle.test.ts | 9 ++-- .../tests/cursor-pagination.oracle.test.ts | 40 ++++++++++----- 3 files changed, 68 insertions(+), 30 deletions(-) diff --git a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts index f649045248..a98469428c 100644 --- a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts +++ b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts @@ -226,8 +226,8 @@ type Mode = `keyed` | `keyedCollision` | `numbers` | `strings` | `structural` | `fallback` // `keyedCollision` selects grammar witnesses; it exercises the same keyed // production path as `keyed`. `caseName` labels a replay, not product state. -// A `keyedCollision` history has one step: the witness index and the scale -// of its multiplicities. +// A `keyedCollision` history has one step: `[[name, opposed], scale]`, the +// witness's name and how its multiplicities are signed and scaled. type History = { mode: Mode steps: Array<[unknown, number]> @@ -293,7 +293,7 @@ const collisionCases: Array<{ name: string; records: MultiSetArray }> = [ ], }, ] -// Keyed witnesses whose records the generated keyed grammar cannot build: +// More keyed witnesses, which the generated `keyed` steps cannot build: // reference-identity values that must still merge with themselves, a signed // zero key, non-finite keys, and a delimiter key beside an object tuple. const delimiterFirst = { v: 1 } @@ -343,13 +343,19 @@ collisionCases.push( ], }, ) -// A witness is named by index, and its multiplicities are scaled, so the -// history is plain data and a pinned witness is a member of the language. +// A witness is named by its case name, and its multiplicities are scaled, +// so the history is plain data and a pinned witness is a member of the +// language. `opposed` gives the first record the scale and every later one +// its negation, so distinct identities would cancel if wrongly merged; +// otherwise each record keeps its own multiplicity times the scale. const collisionHistoryArb: fc.Arbitrary = fc.record({ mode: fc.constant(`keyedCollision` as const), steps: fc.tuple( fc.tuple( - fc.integer({ min: 0, max: collisionCases.length - 1 }), + fc.tuple( + fc.constantFrom(...collisionCases.map(({ name }) => name)), + fc.boolean(), + ), fc.constantFrom(-2, -1, 1, 2), ), ), @@ -461,13 +467,24 @@ function buildRecords(history: History): MultiSetArray { // One non-keyed record makes the whole multiset unkeyed. if (history.mode === `fallback`) records.push([{ v: 1 }, 1]) if (history.mode === `keyedCollision`) { - const [[caseIndex, scale]] = history.steps as [[number, number]] - return collisionCases[caseIndex]!.records.map(([data, m]) => [ - Array.isArray(data) && Array.isArray(data[1]) - ? [data[0], [...data[1]]] - : data, - m * scale, - ]) + const [[[name, opposed], scale]] = history.steps as [ + [[string, boolean], number], + ] + // Fresh objects per history, with each original object mapped to one + // clone, so references a witness shares stay shared within a history. + const clones = new Map() + const fresh = (value: Data): Data => + Array.isArray(value) + ? value.map(fresh) + : value !== null && typeof value === `object` + ? (clones.get(value) ?? clones.set(value, { ...value }).get(value)!) + : value + return collisionCases + .find((entry) => entry.name === name)! + .records.map(([data, m], index) => [ + fresh(data), + opposed ? (index === 0 ? scale : -scale) : m * scale, + ]) } return records } @@ -645,7 +662,7 @@ const modes = [ ] as const const witness = (name: string): History => ({ mode: `keyedCollision`, - steps: [[collisionCases.findIndex((entry) => entry.name === name), 1]], + steps: [[[name, false], 1]], }) oracleProperty({ @@ -653,7 +670,7 @@ oracleProperty({ law: { statement: `MultiSet.consolidate() keeps one record per identity, the first in input order (with -0 written as 0 for unkeyed numbers), with the summed multiplicity; an identity whose sum is zero is absent; identity is keyed, single-primitive, or structural by the shape of the whole multiset; the input is unchanged.`, authority: `the contract comment on MultiSet.consolidate() in packages/db-ivm/src/multiset.ts (b5d92ceb)`, - limits: `Output order is not part of the law. MultiSet directly, not the query shapes that produce these records. Structural identity uses a 32-bit hash; the small domain makes a collision unlikely, not impossible. Key order only for one collation tie.`, + limits: `Output order is not part of the law. The keyed grammar includes the type, reference, and delimiter collisions of #1948 and non-finite numeric keys; MultiSet directly, not the query shapes that produce these records. Structural identity uses a 32-bit hash; the small domain makes a collision unlikely, not impossible. Key order only for one collation tie.`, owner: `Structural values and ordered primitives`, }, histories: { @@ -667,6 +684,8 @@ oracleProperty({ ], runs: 300, fixedSeeds: [2026929], + // Pinned witnesses for each identity rule. Generated histories can reach + // many of these, but pinning them makes each rule visible and stable. pins: [ ...collisionCases.map(({ name }) => ({ name, diff --git a/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts index 42529868d7..aff694be6e 100644 --- a/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts @@ -38,7 +38,8 @@ import type { Observation as TraceEntry } from '../../db/tests/oracle-property.j * the reopened schema with it (a differential law), and the fresh install's * objects with the model's. * - * History grammar, on one node:sqlite database: + * History grammar, each on its own node:sqlite database (the driver also + * opens a second one for the fresh install it compares with): * - Interrupted setup: the registry row exists and setup stopped after the * first k of the nine facts, for k in 0..9, in production setup order. No * rows exist yet, because no write can precede complete setup. @@ -434,7 +435,7 @@ function checkReopen( const wrong = differs( `fresh install`, - `setup`, + `fresh-install`, fresh.map(({ type, name, tbl_name }) => ({ type, name, tbl_name })), owed.objects, `a fresh install created other schema objects than the model names`, @@ -476,9 +477,9 @@ function checkReopen( oracleProperty({ id: `sqlite-persistence.setup-reopen`, law: { - statement: `After a fresh adapter's first read of a registered Collection, its schema objects equal a fresh install's, both bookkeeping rows exist, and stored rows are unchanged, from every legal starting state; when all nine setup facts already hold, the read issues no DDL or writes and costs at most one call more than a cached read.`, + statement: `After a fresh adapter's first read of a registered Collection, its schema objects are exactly a fresh install's (same type, name, table, and SQL), both bookkeeping rows exist, and stored rows are unchanged, from every legal starting state; when all nine setup facts already hold for the registered schema version, the read issues no DDL or writes and costs at most one call more than a cached read. A partial state may cost more.`, authority: `the SQLite core adapter's per-Collection setup contract; the accepted persistence goal that reloads are fast (issue #2204)`, - limits: `One node:sqlite database; no cache-generation claims, concurrent adapters, or dropped row tables that still hold rows. SQL text is checked against a production fresh install, not absolute definitions.`, + limits: `In-memory node:sqlite databases; no cache-generation claims, concurrent adapters, or dropped row tables that still hold rows. SQL text is checked against a production fresh install, not absolute definitions.`, owner: `SQLite persistence`, }, histories: starts, diff --git a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts index 2520550ed8..ba6a44f123 100644 --- a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts @@ -341,7 +341,10 @@ describe(`opaque cursor adapter`, () => { // // Model: `expectedRows` for each valid window, `RangeError` for each invalid // one, and the page count from the backend's page sizes alone (the model -// never sees tokens). Driver: the production pager over the opaque backend; +// never sees tokens). That the count is the shortest covering prefix is not +// one README sentence: it follows from its transport rules with the exact +// counts the previous fixed tests pinned (three peer windows on pages of two +// fetch 4 pages; a drained source fetches every page). Driver: the production pager over the opaque backend; // it records each read's rows or error name and the backend's call count // after each step. Limits: one scope per pager, no reset, faults, or // cancellation (the reset and failure properties below own those). @@ -367,7 +370,8 @@ type ReadHistory = { } const readArbitrary: fc.Arbitrary = fc.oneof( { weight: 6, arbitrary: fc.record({ window: windowArbitrary }) }, - // The window whose end is the largest safe integer is valid. + // The window whose end is the largest safe integer is valid. A pager that + // rejected at MAX_SAFE_INTEGER would still pass every invalid window. { weight: 1, arbitrary: fc.record({ @@ -398,7 +402,9 @@ const readHistory: fc.Arbitrary = fc.record({ reads: fc.array(readArbitrary, { minLength: 1, maxLength: 3 }), changeAfterCall: fc.boolean(), }), - { minLength: 1, maxLength: 6 }, + // Up to 30 reads, past the 20 sequential windows the previous history + // property drew. + { minLength: 1, maxLength: 10, size: `medium` }, ), }) @@ -474,14 +480,22 @@ async function driveReads( const settle = (promise: Promise>) => promise.then( (rows): ReadResult => ({ rows }), + // An invalid window must reject with a RangeError instance; any other + // rejection keeps its name and message for the failure. (error: unknown): ReadResult => ({ - error: error instanceof Error ? error.name : String(error), + error: + error instanceof RangeError + ? `RangeError` + : error instanceof Error + ? `${error.name}: ${error.message}` + : String(error), }), ) for (const [index, { reads, changeAfterCall }] of history.steps.entries()) { const windows = reads.map((read) => ({ ...windowOf(read) })) const pending = windows.map((window) => settle(pager.read(window))) - // A pager captures each requested window when it is called. + // Reads of one pager queue behind each other, so a window must be + // captured when it is called, not when its queued work starts. if (changeAfterCall) for (const window of windows) { window.offset = 7 @@ -559,7 +573,7 @@ oracleProperty({ id: `cursor-pagination.history`, law: { statement: `A cursor pager returns exactly the requested slice of the whole filtered ordered relation, rejects an invalid window with RangeError before transport, captures each window when called, and fetches only the shortest prefix of backend pages that covers what was read, reusing fetched pages.`, - authority: `packages/query-db-collection/tests/cursor-pagination/README.md, "Contract before implementation"; the exported createCursorPager API`, + authority: `packages/query-db-collection/tests/cursor-pagination/README.md, "Contract before implementation" (transport rules) and "Chosen implementation" (loading more fetches the missing suffix); the exact page counts the previous fixed tests pinned; the exported createCursorPager API`, limits: `One scope per pager; no reset, faults, or cancellation in this property; a stable backend relation, not snapshot consistency for changing endpoints.`, owner: `Opaque backend pagination`, }, @@ -605,11 +619,15 @@ oracleProperty({ pinned(`a window changed after its call`, 2, [ { reads: [{ window: { offset: 1, limit: 3 } }], changeAfterCall: true }, ]), - ...invalidWindows.map((_, invalid) => - pinned(`invalid window ${invalid} rejects before transport`, 2, [ - { reads: [{ invalid }], changeAfterCall: false }, - readsOf({ offset: 0, limit: 2 }), - ]), + ...invalidWindows.map(({ offset, limit }, invalid) => + pinned( + `invalid window ${offset}/${limit} rejects before transport`, + 2, + [ + { reads: [{ invalid }], changeAfterCall: false }, + readsOf({ offset: 0, limit: 2 }), + ], + ), ), ], }, From ef1f4432939c2993fd71d7ef7e86cc5e1ee61cd5 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Nov 2026 09:54:42 -0700 Subject: [PATCH 029/168] docs(glossary): add the oracle framework terms Adds the terms settled after the v5 design grammar: oracle property, generated, fixed, and constructed histories, campaign (fixed-seed, random, reseeded), campaign seed and replay path, run budget, verdict, declared state and reach, calibration, killed or survived, differential law, receiving path, oracle owner, decision record, limit, gap, and self-oracle. Separates reach (histories entering a declared state) from reach witness (the driver reaching a boundary), widens mutant to cover corrupted traces and hostile drivers, and says a seed-only run is a reseeded campaign, not a replay. Retires lane, pin, segment, and hostile mutant, and lists the oracle words to qualify at first use. Co-authored-by: Isaac --- docs/contributing/glossary.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index abe5dedc3b..9c94b7334a 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -205,17 +205,36 @@ crossing subsystem boundaries; these comparisons are not interchangeable. | observation | One recorded public fact. | | trace | An ordered sequence of observations. | | checkpoint or observation cut | The exact point where the contract requires comparison. | -| oracle replay | Re-running a recorded oracle failure from its seed, shrink path, actions, or other replay inputs. Do not use it for runtime truncate replay, retry, or a new random campaign. | +| oracle replay | Re-running a recorded oracle failure from its seed, shrink path, actions, or other replay inputs. Do not use it for runtime truncate replay, retry, or a new random campaign. A run with a chosen seed and no path is a reseeded campaign, not a replay. | | projection or abstraction | The parts of concrete state retained for one judgment. | | refinement check | An executable comparison at a named checkpoint that accepts only production observations allowed by the model. A sampled check is not a proof for every behavior. | | partial oracle | An oracle that judges only named parts of the contract. | -| reach witness | Evidence that the production driver reached the claimed boundary. | +| reach witness | Evidence that the production driver reached the claimed boundary, such as a real host, a provider call, or a protocol step. It concerns the production path. Compare reach, which concerns the history grammar. | +| declared state | A state that a law depends on. The oracle property names it, and its reach is counted. | +| reach | The number of histories that entered a declared state. A missed required reach fails the run. Reach counts states that were actually entered, not labels that the grammar assigns. Compare reach witness. | | versioned review record | Review evidence stored at a stable location with the exact reviewed head and append-only entries, so later edits cannot silently change the recorded verdict. | -| mutant | A deliberate wrong answer or implementation used to test oracle sensitivity. | +| mutant | A deliberate wrong answer or implementation used to test oracle sensitivity: a corrupted trace, a hostile production driver, or a production code change. Do not call it a hostile mutant; every mutant is hostile. | +| calibration | A test that applies a mutant and requires the check to report a violation of the named law. A calibration skips a candidate whose unaltered trace already fails. | +| killed or survived | A mutant is killed when the check reports a violation of the named law. A crash, timeout, or setup error is recorded separately and is not a kill. A mutant that passes survived. | | fault injection | A deliberate runtime failure, delay, cancellation, or malformed response used to test a system boundary. | | shrinking | Reducing a failing generated history while preserving the failure. | | bounded exhaustiveness | Executing every case inside stated finite bounds. | | held-out challenge | A challenge not used to shape the tested design. After it guides a change, retain it but stop calling it held out. | +| oracle property | One registered unit that checks laws. It has an id, a law, a history grammar, a reference model or a differential law, production drivers, a refinement check, and campaigns. | +| generated history | A history drawn from the history grammar by a seeded generator. | +| fixed history | A named history that is a member of the history grammar. It is checked at registration and runs on every campaign. A fixed regression is a fixed history kept from a found failure. Do not call it a pin, and do not call it an example, which this repository uses for ordinary example-based tests. fast-check's `examples` option is one mechanism for running fixed histories. | +| constructed history | A history that a declared builder produces inside the history grammar so that it enters a named declared state. It guarantees that state's reach in every campaign. Do not call it a segment. | +| campaign | One run plan over an oracle property. A fixed-seed campaign uses a documented seed. A random campaign draws a seed and logs it. A reseeded campaign is a random campaign whose seed was chosen to reproduce one. Do not call it a lane. | +| campaign seed and replay path | fast-check's coordinates for re-running one campaign and one shrunk failure. Qualify the seed as a campaign seed; a hydration seed is a product term. | +| run budget | The base number of generated histories in a campaign, before the run multiplier. A lowered or removed run budget is reported. Qualify it; a retry budget is a product term. | +| verdict | The result of a refinement check: pass, or a violation that names its checkpoint, law, and difference. | +| differential law | A law that compares observations from two production paths at a checkpoint where the contract owes the same answer. It uses no reference model. | +| receiving path | A further production path, such as a real host, SDK, browser, or provider, that shares an oracle's reference model and refinement check. A receiving witness is the evidence that the path ran and was compared. | +| oracle owner | The file that owns a law's judgment, as listed in the coverage map. Always qualify it; owner alone is a product term. | +| decision record | A recorded maintainer decision with an id, date, scope, and text. A law or a limit cites it as its authority. Production output is never authority. | +| limit | A declared statement of what an oracle does not establish, such as a state, dimension, production path, or comparison that it does not cover. It names an oracle owner and a decision record and reports whether it was in effect. A tool-detected hole is a gap, not a limit. | +| gap | A hole that a tool or review detects, such as a declared state that was never reached or a mutant that survived. A gap is resolved by an oracle change or recorded as a limit. Qualify it; a sequence gap is a product term. | +| self-oracle | An oracle whose subject is the oracle framework itself. | ## Grammar rules @@ -253,7 +272,9 @@ Use `settled` for a promise that fulfilled or rejected. Use `complete` for named coverage, content, history, or a non-promise operation only when no stronger boundary term applies. Use `success` only for fulfillment. Qualify overloaded words such as `state`, `request`, `snapshot`, `session`, `owner`, `release`, `recovery`, and -`generation` at first use. +`generation` at first use. In oracle prose, qualify `driver` (production +driver), `seed` (campaign seed), `owner` (oracle owner), `coverage` (oracle +coverage), and `budget` (run budget). ## Model alignment rule From 32b50068950e463a5717b74d514787d7d6edd6da Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Nov 2026 11:03:18 -0700 Subject: [PATCH 030/168] refactor(oracles): adopt the glossary's oracle vocabulary in code Applies the oracle terms added to the glossary in ef1f44329 across the framework, every oracle and its supporting tests, the lint plugin, and the oracle scripts. Behavior is unchanged: no model rule, grammar, comparison, run budget, seed, or assertion value moved. - oracleProperty: histories.pins and Pin become histories.fixed and FixedHistory; histories.segments and Segment become histories.constructed and ConstructedHistory. Failure messages and generated test names say "fixed history" and "constructed history". - "lane" splits by sense: a seeded run is a fixed-seed or random campaign; a host variant is a production path (WrappingSyncLane is now WrappingSyncPath; optimisticHistoryDriver takes productionPath); a grammar extension is "the grammar" (SessionLane is now SessionGrammar, persisted*Lane now persisted*Grammar, oracleLaneOptions now oracleCampaignOptions). The product sense of lane, for the SQLite scheduler's hydrate and persist queues, stays. - "hostile mutant" is now "mutant", which the glossary widened to cover corrupted traces and hostile production drivers. - A declared exclusion is a limit: the conformance option knownGaps is now limits. "Gap" stays for holes a tool or review detects. - A seed-only run is a reseeded campaign: readOracleRunConfig's replaySeed is now campaignSeed, and the manifest status seed-replay is now reseeded-campaign. replayPath and replayProperty stay. - Test titles that held retired terms are renamed; no replay-manifest title pattern referred to them. Also fixes scheduleFrom (oracle-property.ts): a rejected promise held until its release had no handler, so Node reported an unhandled rejection and Vitest failed the scheduler oracle's run although every test passed (RED: 9 unhandled rejections before this change). The schedule now marks the held promise handled; the rejection still reaches the caller on release. GREEN: both users of scheduleFrom (the scheduler and interruption-settlement oracles) pass with no unhandled rejections. Verification: tsc clean in all 20 touched packages; the changed test files pass in 18 packages (about 22,000 tests); the framework self-oracle passes 22/22; the lint plugin suite passes 4113/4113 and the script oracles 177/177; the oracle lint reports nothing; the run-budget tripwire reports no lowered or removed budget. Review records, dated evidence files, environment variable names, and run budget keys are unchanged. Co-authored-by: Isaac --- packages/angular-db/tests/conformance.test.ts | 2 +- .../tests/browser-coordinator.test.ts | 4 +- .../tests/multi-tab-electric-oracle.test.ts | 26 +- .../tests/opfs-page-lifecycle-oracle.test.ts | 6 +- .../tests/opfs-page-lifecycle.test.ts | 2 +- .../opfs-worker-diagnostics-oracle.test.ts | 4 +- .../per-collection-coordinator-oracle.test.ts | 26 +- .../per-collection-coordinator-support.ts | 2 +- .../tests/per-collection-coordinator.test.ts | 4 +- .../shared-driver-fairness-oracle.test.ts | 6 +- .../tests/shared-driver-fairness-oracle.ts | 12 +- .../tests/shared-driver-fairness.test.ts | 4 +- .../e2e/backend-oracle.test.ts | 6 +- .../tests/lazy-paging-driver.ts | 2 +- ...azy-paging-version-oracle.property.test.ts | 2 +- .../tests/snapshot-oracle.property.test.ts | 16 +- .../tests/hash-graph-oracle.property.test.ts | 2 +- .../hash-identity-oracle.property.test.ts | 8 +- .../index-refinement-oracle.property.test.ts | 6 +- ...ltiset-consolidate-oracle.property.test.ts | 8 +- .../db-ivm/tests/operators/groupBy.test.ts | 2 +- .../topk-support-window-oracle.test.ts | 14 +- ...tructural-identity-oracle.property.test.ts | 10 +- .../db-ivm/tests/structural-identity.test.ts | 2 +- ...ed-demand-coverage-campaign-oracle.test.ts | 2 +- .../persisted-demand-coverage-oracle.test.ts | 2 +- ...isted-demand-coverage-supplemental.test.ts | 2 +- .../tests/persisted-demand.test.ts | 2 +- .../tests/persisted-follower-indexes.test.ts | 6 +- ...optimistic-history-oracle.property.test.ts | 27 +- .../tests/persisted-options-type-oracle.ts | 4 +- .../tests/persisted-oracle.test.ts | 38 +- .../tests/persisted-reload-metadata.test.ts | 2 +- .../tests/persisted-reset-startup.test.ts | 2 +- .../tests/persisted-source-receipts.test.ts | 2 +- .../tests/persisted-support.ts | 6 +- .../tests/sqlite-core-adapter-oracle.test.ts | 8 +- .../tests/sqlite-resume-snapshot-support.ts | 4 +- .../tests/sqlite-temporal-value.test.ts | 2 +- .../tests/sync-metadata-calibration.test.ts | 41 +- .../tests/sync-metadata-missing-row.test.ts | 37 +- .../tests/sync-metadata-recovery.test.ts | 4 +- .../tests/sync-metadata-session-campaign.ts | 75 ++-- .../sync-metadata-session-persisted-driver.ts | 10 +- .../sync-metadata-session-persisted.test.ts | 197 ++++----- .../sync-metadata-session-sqlite-driver.ts | 24 +- .../sync-metadata-session-sqlite.test.ts | 35 +- .../tests/sync-metadata-write-work.test.ts | 22 +- .../indexed-db/packed-consumer.test.ts | 2 +- .../indexed-db/packed-consumer.ts | 4 +- packages/db/src/query/compiler/group-by.ts | 2 +- packages/db/tests/SortedMap.test.ts | 4 +- packages/db/tests/btree-map-oracle.test.ts | 2 +- .../tests/change-event-history-oracle.test.ts | 2 +- .../cleanup-queue-oracle.property.test.ts | 2 +- .../collection-cleanup-restart-oracle.test.ts | 4 +- ...ection-gc-mutation-oracle.property.test.ts | 2 +- ...ction-gc-retention-oracle.property.test.ts | 60 +-- ...ion-gc-transaction-oracle.property.test.ts | 2 +- ...on-row-metadata-composition-oracle.test.ts | 51 +-- ...on-state-retention-oracle.property.test.ts | 14 +- ...n-subscription-lifecycle-grammar-oracle.ts | 2 +- ...ecycle-publication-oracle.property.test.ts | 6 +- .../collection-subscription-lifecycle.test.ts | 2 +- .../collection-sync-reentrancy-oracle.test.ts | 2 +- .../tests/comparison-oracle.property.test.ts | 2 +- packages/db/tests/conformance/contract.ts | 2 +- .../db/tests/conformance/infinite-contract.ts | 4 +- .../conformance/infinite-suite-oracle.ts | 2 +- packages/db/tests/conformance/registration.ts | 6 +- .../tests/conformance/result-shape-oracle.ts | 7 +- packages/db/tests/conformance/suite.ts | 2 +- .../conformance/window-history-oracle.ts | 4 +- .../db/tests/cursor-oracle.property.test.ts | 2 +- ...rce-reconciliation-oracle.property.test.ts | 2 +- ...-client-hydration-authority-oracle.test.ts | 6 +- .../declared-reach-oracle.property.test.ts | 18 +- .../db/tests/documented-mutations-oracle.ts | 2 +- .../documented-mutations-support.test.ts | 2 +- .../db/tests/fixtures/census/src/cases.ts | 2 +- ...at-change-tracking-oracle.property.test.ts | 6 +- .../index-update-oracle.property.test.ts | 16 +- .../cross-tab-history-oracle.test.ts | 2 +- .../indexed-db/pending-history-oracle.test.ts | 2 +- .../db/tests/indexed-db/persistence.test.ts | 2 +- .../tests/live-query-bound-options-oracle.ts | 6 +- .../tests/live-query-bound-options-support.ts | 6 +- .../live-query-cache-options-oracle.test.ts | 2 +- ...-query-deferred-acquisition-oracle.test.ts | 2 +- .../live-query-deferred-acquisition.test.ts | 2 +- .../tests/live-query-observer-history.test.ts | 2 +- ...observer-hydration-oracle.property.test.ts | 10 +- .../local-only-direct-write-oracle.test.ts | 2 +- ...age-value-fidelity-oracle.property.test.ts | 6 +- .../local-storage-write-order-oracle.test.ts | 4 +- ...optimistic-history-oracle.property.test.ts | 8 +- .../db/tests/optimistic-history-oracle.ts | 60 +-- ...imistic-history-publication-oracle.test.ts | 4 +- ...mistic-transaction-oracle.property.test.ts | 4 +- packages/db/tests/oracle-campaign.ts | 2 +- packages/db/tests/oracle-config.ts | 28 +- ...property-scheduler-oracle.property.test.ts | 2 +- .../oracle-property-self-oracle-fixture.ts | 26 +- .../tests/oracle-property-self-oracle.test.ts | 390 +++++++++--------- packages/db/tests/oracle-property.ts | 208 +++++----- packages/db/tests/oracle-replay-manifest.ts | 16 +- packages/db/tests/oracle-replay.test.ts | 10 +- packages/db/tests/oracle-replay.ts | 8 +- .../production-error-messages-oracle.test.ts | 2 +- .../tests/production-error-messages.test.ts | 2 +- .../tests/proxy-detachment-contract.test.ts | 2 +- ...oxy-native-methods-oracle.property.test.ts | 4 +- .../db/tests/proxy-native-methods.test.ts | 2 +- packages/db/tests/proxy-oracle.test.ts | 2 +- .../proxy-revert-oracle.property.test.ts | 2 +- .../aggregate-select-oracle.property.test.ts | 8 +- .../db/tests/query/aggregate-select.test.ts | 2 +- .../tests/query/async-hold-outcomes.test.ts | 2 +- ...et-facade-rollback-oracle.property.test.ts | 20 +- .../query/bucket-facade-rollback.test.ts | 4 +- .../cold-join-reconciliation-oracle.test.ts | 2 +- .../query/compiler/evaluators-oracle.test.ts | 4 +- .../tests/query/compiler/evaluators.test.ts | 2 +- .../in-evaluator-oracle.property.test.ts | 2 +- .../group-result-key-oracle.property.test.ts | 6 +- .../tests/query/identity-output-shape.test.ts | 2 +- ...ncludes-collection-oracle.property.test.ts | 18 +- .../tests/query/includes-collection.test.ts | 2 +- ...-cross-formulation-oracle.property.test.ts | 6 +- .../query/includes-oracle.property.test.ts | 30 +- .../query/includes-publication-oracle.test.ts | 2 +- .../query/includes-query-shape-oracle.test.ts | 4 +- .../query/includes-route-grammar-oracle.ts | 2 +- .../query/includes-temporal-oracle.test.ts | 12 +- .../includes-work-counter-oracle.test.ts | 52 +-- .../join-result-key-oracle.property.test.ts | 12 +- ...ad-subset-replay-refinement-oracle.test.ts | 4 +- .../ordered-lifecycle-oracle.property.test.ts | 2 +- .../db/tests/query/ordered-lifecycle.test.ts | 2 +- ...ordered-source-loader-state-oracle.test.ts | 22 +- .../ordered-work-oracle.property.test.ts | 10 +- .../query/pagination-oracle.property.test.ts | 8 +- .../query/pooled-live-query-gc-oracle.test.ts | 4 +- .../pooled-live-query-oracle.property.test.ts | 12 +- .../virtual-row-fields-effect-oracle.test.ts | 10 +- .../query/virtual-row-fields-oracle.test.ts | 9 +- ...dicate-publication-oracle.property.test.ts | 26 +- packages/db/tests/request-errors-work.test.ts | 2 +- .../reverse-index-oracle.property.test.ts | 2 +- .../db/tests/source-health-oracle.test.ts | 24 +- packages/db/tests/source-health.test.ts | 14 +- .../db/tests/ssr-snapshot-admission-oracle.ts | 2 +- .../db/tests/sync-metadata-recovery-oracle.ts | 8 +- ...c-metadata-session-oracle.property.test.ts | 205 ++++----- .../db/tests/sync-metadata-session-oracle.ts | 198 ++++----- .../db/tests/sync-metadata-session.test.ts | 6 +- .../db/tests/temporal-index-order.test.ts | 4 +- ...nsaction-ownership-oracle.property.test.ts | 12 +- .../db/tests/utils-oracle.property.test.ts | 4 +- packages/db/tests/utils.test.ts | 8 +- packages/db/tests/virtual-props-cache.test.ts | 2 +- .../tests/async-hold-outcomes.test.ts | 2 +- .../tests/electric-live-query.test.ts | 4 +- .../tests/electric-oracle.property.test.ts | 10 +- ...gressive-offline-readiness-witness.test.ts | 2 +- .../tests/electric-retry-decision-model.ts | 4 +- ...ctric-sdk-delivery-oracle.property.test.ts | 16 +- .../schema-transform-conformance.test-d.ts | 2 +- .../electron-ipc-readiness-oracle.test.ts | 8 +- ...expo-persisted-demand-coverage.e2e.test.ts | 4 +- .../tests/expression-index-oracle.test.ts | 4 +- .../tests/expression-index.test.ts | 6 +- .../KeyScheduler-oracle.property.test.ts | 12 +- .../tests/fifo-retry-oracle.property.test.ts | 4 +- ...ruption-settlement-oracle.property.test.ts | 22 +- .../leadership-replay-oracle.property.test.ts | 52 +-- .../tests/leadership-replay.test.ts | 2 +- .../tests/oracle-config.ts | 13 +- ...saction-serializer-oracle.property.test.ts | 42 +- ...saction-settlement-oracle.property.test.ts | 18 +- packages/preact-db/tests/conformance.test.ts | 2 +- .../tests/infinite-query-conformance.test.ts | 2 +- ...rsor-pagination-oracle.integration.test.ts | 2 +- .../cursor-pagination.boundary-oracle.test.ts | 2 +- .../cursor-pagination.cache-oracle.test.ts | 2 +- .../tests/cursor-pagination.oracle.test.ts | 22 +- ...rsor-pagination.publication-oracle.test.ts | 2 +- .../includes-work-counter-oracle.test.ts | 4 +- .../tests/ownership-lifecycle.oracle.test.ts | 4 +- ...rsisted-direct-write-window.oracle.test.ts | 2 +- .../tests/persisted-direct-write.test.ts | 4 +- .../query-db-collection/tests/query.test-d.ts | 2 +- .../query-db-collection/tests/query.test.ts | 12 +- .../refetch-ordered-prefix-oracle.test.ts | 12 +- packages/react-db/tests/conformance.test.tsx | 2 +- .../descriptor-query-binding-oracle.test.tsx | 2 +- .../tests/infinite-query-conformance.test.tsx | 2 +- .../infinite-query-render-cuts-support.tsx | 2 +- .../tests/source-id-reuse-support.tsx | 2 +- .../suspense-indexing-config-oracle.test.tsx | 18 +- .../suspense-local-options-oracle.test.tsx | 4 +- .../tests/useLiveInfiniteQuery.test-d.tsx | 2 +- .../tests/op-sqlite-driver-oracle.test.ts | 6 +- .../tests/op-sqlite-driver.test.ts | 4 +- packages/solid-db/tests/conformance.test.tsx | 2 +- .../tests/conformance.svelte.test.ts | 2 +- .../infinite-query-conformance.svelte.test.ts | 2 +- .../ssr-runtime-error-oracle.svelte.test.ts | 2 +- .../svelte-db/tests/ssr-snapshot-fixture.ts | 2 +- ...ssr-snapshot-handoff-oracle.svelte.test.ts | 4 +- .../tests/lifecycle-oracle.property.test.ts | 16 +- .../tests/lifecycle.test.ts | 2 +- packages/vue-db/tests/conformance.test.ts | 2 +- .../tests/infinite-query-conformance.test.ts | 2 +- scripts/check-oracle-run-counts.mjs | 4 +- scripts/demand-coverage-mutants.mjs | 2 +- .../eslint-plugin-oracle/no-disabled.test.js | 10 +- .../eslint-plugin-oracle/no-unit-test.test.js | 8 +- .../replay-observation-oracle.test.js | 5 +- .../eslint-plugin-oracle/rules/no-disabled.js | 38 +- .../rules/no-unit-test.js | 8 +- .../rules/replayable-seed.js | 29 +- .../seed-and-purity.test.js | 12 +- .../release-history-oracle.property.test.js | 2 +- 224 files changed, 1603 insertions(+), 1498 deletions(-) diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 958697faa6..5a2d83c290 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -324,7 +324,7 @@ const angularDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts b/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts index 8990effe0a..6caef19b49 100644 --- a/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts @@ -885,7 +885,7 @@ describe(`BrowserCollectionCoordinator`, () => { * * Checkpoints: before the request (premise: no term while the read is * held), after bounded turns with the read still held (term and route), and - * after release (settlement and RPC count). Hostile mutants that skip any + * after release (settlement and RPC count). Mutants that skip any * need mark fail the held-read checkpoint or the settlement deadline. * * Per-election cells: a follower takes over from a leader that served an @@ -3375,7 +3375,7 @@ describe(`BrowserCollectionCoordinator`, () => { } } - // Reach witnesses above distinguish an RPC crossover from ordinary + // Reach checks above distinguish an RPC crossover from ordinary // leadership transfer. The exact-ID answer must settle the wrapper's // first receipt without cleanup or a second sync run. const actual = { diff --git a/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts index 11c87c0109..2595dccc87 100644 --- a/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts @@ -30,7 +30,7 @@ * open tab catches up, the durable rows and every open tab show the server * head, however earlier lagging commits left them. When a lagging tab * commits an older row and then closes for good, nothing redelivers the head - * to the faster tab, so this law fails today. That history is pinned with + * to the faster tab, so this law fails today. That history is a fixed history run with * `it.fails` until #2209 orders source writes by server position; generated * histories never close a tab for good. * @@ -79,7 +79,7 @@ * * The generated histories below judge Laws 1, 3, 4, and 5 over interleaved * server changes, deploys, deliveries, stops, and recoveries, against a - * model of the durable rows. One known gap is left out of them and pinned: + * model of the durable rows. One known limit is left out of them and kept as a fixed history: * a resumed tab that lags behind a peer can receive an update for a row the * peer already deleted, and Electric's resume check then puts it in `error`. * @@ -906,7 +906,7 @@ describe(`multi-tab Electric over one persisted database`, () => { | { kind: `advance`; tab: number; pick: number } | { kind: `catch up`; tab: number; pick: number } // A tab that closes for good. Generated histories never close a tab; - // only the pinned #2209 replay does (see below). + // only the fixed #2209 regression does (see below). | { kind: `close`; tab: number } | { kind: `stop`; tab: number; pick: number } | { @@ -978,7 +978,7 @@ describe(`multi-tab Electric over one persisted database`, () => { // reset/stop suffix of 4 + 2*tabCount steps (8 or 10), or a lagging-commit // suffix of 6 + 3*tabCount steps (12 or 15). Removing // the open delivery, stop, or peer commit loses the law's premise; recovering - // before the peer commit would test a different boundary. Existing pinned + // before the peer commit would test a different boundary. Existing fixed // histories still use the same driver without this prefix. const generatedHistory = fc .record({ @@ -1147,7 +1147,7 @@ describe(`multi-tab Electric over one persisted database`, () => { const runGeneratedHistory = async ( { tabCount, steps }: GeneratedHistory, - onKnownGap: () => void, + onKnownLimit: () => void, onRecovery?: ( divergentBeforeRecovery: boolean, how: `restart` | `reload`, @@ -1283,9 +1283,9 @@ describe(`multi-tab Electric over one persisted database`, () => { // Electric's updates carry only changed columns (the shape's default // replica), so an update cannot create a row the tab does not hold, // and Electric drops it. The tab holds the durable rows, or, inside a - // refetch, the snapshot it has received. Known gap: a resumed tab + // refetch, the snapshot it has received. Known limit: a resumed tab // instead reports an unseen row and fails (issue #2267). Generated - // campaigns leave that history out; the pinned replay below keeps it. + // campaigns leave that history out; the fixed regression below keeps it. const held = new Set(model.truncates ? [] : durable.keys()) const track = (message: Message) => { if (!(`key` in message)) return @@ -1300,7 +1300,7 @@ describe(`multi-tab Electric over one persisted database`, () => { message.headers.operation === `update` && !held.has(message.value.id) ) { - if (model.resumed && !model.truncates) onKnownGap() + if (model.resumed && !model.truncates) onKnownLimit() return false } track(message) @@ -1862,7 +1862,7 @@ describe(`multi-tab Electric over one persisted database`, () => { fc.asyncProperty(generatedHistory, async (history) => { let primary: { error: unknown } | undefined try { - // fc.pre discards a history that reaches the known gap. + // fc.pre discards a history that reaches the known limit. await runGeneratedHistory(history, () => fc.pre(false)) } catch (error) { primary = { error } @@ -1896,9 +1896,9 @@ describe(`multi-tab Electric over one persisted database`, () => { 600_000, ) - // Known gap #2267, pinned: the shrunk generated history in which a resumed tab + // Known limit #2267, as a fixed history: the shrunk generated history in which a resumed tab // that lags behind its peer receives an update for a row the peer already - // deleted. It replays through the same driver and checks with the known-gap + // deleted. It replays through the same driver and checks with the known-limit // detector off. Remove `fails` when Electric's resume check stops treating // rows from peers' commits as this stream's evidence. it.fails( @@ -2003,7 +2003,7 @@ describe(`multi-tab Electric over one persisted database`, () => { 60_000, ) - // Known gap #2209, pinned: tab A commits v1 and v2; tab B's lagging stream + // Known limit #2209, as a fixed history: tab A commits v1 and v2; tab B's lagging stream // then commits only v1, so the durable row falls back to v1, and B closes // for good. Nothing redelivers the head to A, so Law 2's final check fails. // Generated histories never close a tab for good, which leaves this @@ -2034,7 +2034,7 @@ describe(`multi-tab Electric over one persisted database`, () => { // tabs catch up, but after tab B stops, tab A catches up, and tab A stops, // the durable rows lose row 1. Law 2 expects every open tab and the durable // rows to show the head. The source-progress repair forces a fresh snapshot. - // It replays through the same driver and checks with the known-gap detector + // It replays through the same driver and checks with the known-limit detector // off. The generated source-prefix campaign extends this same law. it( `keeps a durable row after both tabs catch up to it and then stop`, diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts index 812039b765..2ee887678c 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts @@ -43,7 +43,7 @@ import type { BrowserOPFSWorkerErrorCode } from '../src/opfs-worker-protocol' * * The real worker answers earlier requests before close; the request-order * oracle owns that law. This file's controlled worker can answer in any order, - * so the close lane also checks that pending work settles when close settles + * so the close grammar also checks that pending work settles when close settles * first. * * Controlled page and worker objects implement only the browser event and @@ -898,9 +898,9 @@ and termination count. Synthetic events and a fake worker do not prove real bfcache admission, durability, native handle release, or wa-sqlite #88. The Chromium Web Lock fixture checks native queued-lock release separately. Challenge/replay: fixed examples exhaust the cheap structural products. Each -generated lane runs a fixed and an unseeded campaign with the same grammar and +generated property runs a fixed and an unseeded campaign with the same grammar and run budget. Verbose FastCheck output retains original/reduced traces. A path -replay selects only the direct replay of its lane's property, named by +replay selects only the direct replay of its property, named by TANSTACK_DB_ORACLE_PROPERTY: opfs.page-lifecycle, opfs.mixed-execute, opfs.terminal-event, opfs.close, or opfs.failure-cleanup. A seed without a path reseeds the unseeded campaigns. Run it with this file's package-local diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts index 6300b9ed04..86694a382f 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts @@ -1,7 +1,7 @@ /** * Scenario checks for the OPFS page lifecycle: open deadlines, abort during open, * and abort during option evaluation. `opfs-page-lifecycle-oracle.test.ts` owns - * the lifecycle law and its generated histories; these tests pin single timing + * the lifecycle law and its generated histories; these tests check single timing * scenarios with hand-written expectations. */ import { afterEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts index 0dd9f4bc07..766567530f 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts @@ -321,7 +321,7 @@ class, and two storage-root examples include WebKit's exact UnknownError. A non-Error cause is outside this promise. Removing either class or the absent case loses one of those fixed witnesses. Challenge/replay: the present-cause examples reject a response that drops the -cause; the absent-cause examples reject an invented cause. Pinned examples run +cause; the absent-cause examples reject an invented cause. Fixed histories run separately so both generated campaigns share one property and budget, and a reported shrink path selects its generated case directly. Verbose FastCheck output retains original/reduced traces. Set @@ -334,7 +334,7 @@ declareOracle({ check: [checkDiagnostic], model: [referenceMessage] }) describe(`OPFS worker diagnostics oracle`, () => { if (!oracleReplayRequested()) { it.each(diagnosticExamples)( - `preserves the pinned open-failure diagnostic (%#)`, + `preserves the fixed open-failure diagnostic (%#)`, checkDiagnostic, ) } diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts index 0f7219d939..9bced77a06 100644 --- a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts @@ -185,7 +185,7 @@ Reach, challenge, replay, cleanup, and limits: checker rejects an unowned apply. Every production-path test records a reached checkpoint before comparing the independent ledger. - Each generated property uses the same generator, driver, observations, and - run budget in a retained fixed-seed lane and a seedless random lane. Replay + run budget in a retained fixed-seed campaign and a seedless random campaign. Replay registers only the requested property when TANSTACK_DB_COORDINATOR_ORACLE_{PROPERTY,SEED,PATH} are supplied together; RUNS may adjust the shared budget. The thrown report retains the first @@ -643,7 +643,7 @@ describe(`per-collection coordinator wire and acknowledgement oracle`, () => { ), acknowledgedAfterResponseDelivery: acknowledged, } - // D13 refinement: one comparator owns both the production verdict and hostile + // D13 refinement: one comparator owns both the production verdict and mutant // entry-only/false-success calibrations above. expect(subsetAckViolations(trace)).toEqual([]) }, [ @@ -1209,24 +1209,24 @@ describe(`remote subset ownership lease oracle`, () => { expect( remoteLeaseTakeoverViolations(expectedRemoteLeaseTakeoverTrace), ).toEqual([]) - const hostile: RemoteLeaseTakeoverTrace = structuredClone( + const mutant: RemoteLeaseTakeoverTrace = structuredClone( expectedRemoteLeaseTakeoverTrace, ) - hostile.retiredOwnerAfterTakeover = { + mutant.retiredOwnerAfterTakeover = { loads: 1, unloads: 0, active: 1, exactUnloads: true, } - hostile.newOwnerAfterTakeover = { + mutant.newOwnerAfterTakeover = { loads: 0, unloads: 0, active: 0, exactUnloads: true, } - hostile.newOwnerAfterRelease.exactUnloads = false + mutant.newOwnerAfterRelease.exactUnloads = false expect( - remoteLeaseTakeoverViolations(hostile).map( + remoteLeaseTakeoverViolations(mutant).map( ({ checkpoint, field }) => `${checkpoint}:${field}`, ), ).toEqual([ @@ -1736,7 +1736,7 @@ describe(`per-collection adapter and SQLite-state oracle`, () => { ]) }) - it(`calibrates SQLite-state and fresh-registry checks against hostile results`, () => { + it(`calibrates SQLite-state and fresh-registry checks against mutant results`, () => { const expected: RawCollectionSnapshot = { registryRowCount: 1, schemaVersion: 11, @@ -2725,11 +2725,11 @@ routes on-demand demand it cannot serve to the elected owner. Its load, and so its live query's first ready, settles only when the rows the owner fetched for it are visible in the follower; if applying the owner's commit fails here, the load rejects and the follower's source enters error. The single-runtime -`persistence.follower-remote-demand` lane and the follower generation-notice +`persistence.follower-remote-demand` property and the follower generation-notice cells in db-sqlite-persistence-core/tests/persisted-oracle.test.ts own the runtime mechanism with a coordinator double. The generation-notice cells are -fixed histories: their rotation cell is pinned as failing under the known gap -#2139, and the fix adds their generated campaign back. This lane is their +fixed histories: their rotation cell is a fixed history that fails under the known limit +#2139, and the fix adds their generated campaign back. This production path is their receiving witness across the real boundary: two tabs, each with its own real BrowserCollectionCoordinator over Node's real BroadcastChannel and Web Locks, @@ -2751,7 +2751,7 @@ owner's row, and for the queued placement it holds the other subset's hydration read. It does not reorder the channel, which stays FIFO as in a browser. The custom source delivers each row once; a source that re-inserts an existing row on every ensure made the follower release and request its -demand again without end, on main as well, and is outside this lane. +demand again without end, on main as well, and is outside this production path. Checkpoint and observations: once the owner's reply has reached the follower and the apply is still outstanding, whether the consumer has settled. After @@ -2769,7 +2769,7 @@ persistence. Rotation: when only the follower's claim expires, it rotates into a generation no other tab joins and becomes that generation's owner. The -rotation cell pins that its rows stay visible and new demand settles from its +rotation cell checks that its rows stay visible and new demand settles from its own source. Another tab never owns the rotated generation in this topology, so the elected-owner notice path has no receiving witness here. */ diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts index 521a9c7bca..5341450121 100644 --- a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts @@ -758,7 +758,7 @@ export function routingGrammarPlan( } // Node's own BroadcastChannel and Web Locks, captured before any test installs -// the controlled seams. This lane swaps them in for its body only. +// the controlled seams. This test swaps them in for its body only. export const nativeBroadcastChannel = globalThis.BroadcastChannel export const nativeNavigator = globalThis.navigator diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts index d5ab71d7c4..8cb0c131d6 100644 --- a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts @@ -1,7 +1,7 @@ // Scenario tests for the per-collection coordinator: wire and acknowledgement // partitioning, persisted cache generation routing, remote subset lease // bookkeeping, per-collection adapter routing, SQLite state, sync-ingested -// ownership, and follower settlement across real tabs. Each test pins one +// ownership, and follower settlement across real tabs. Each test checks one // scenario with hand-written expectations. The laws these scenarios touch are // owned by per-collection-coordinator-oracle.test.ts, which generates histories // and compares production with independent models. @@ -1726,7 +1726,7 @@ describe(`follower remote-demand settlement across real tabs`, () => { // This topology never routes to another tab's owner, so the // elected-owner notice path stays with the single-runtime follower // generation-notice cells in persisted-oracle.test.ts. Their rotation cell - // is pinned as failing under the known gap #2139. + // is a fixed history that fails under the known limit #2139. it( `keeps a rotated follower's rows and serves its new demand from its own generation`, () => diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts index 38cb256ae3..52457741ac 100644 --- a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts @@ -1,15 +1,15 @@ /** * Node campaign for the shared-driver K=1 contract documented in * `shared-driver-fairness-oracle.ts`. Fixed histories prove neutral reach and a - * persist storm; generated legal histories vary both lane sizes, mutation + * persist storm; generated legal histories vary the sizes of both lanes, mutation * width, and tail order. Public rows and logical completion checkpoints are * checked independently of production scheduling, and an executable * named persist-first FIFO wrong answer proves the checker rejects the original * fault. Grammar controls reconstruct that witness, exercise the bounded * marginals, reject a nearby invalid storm, and ablate tail-order variation. * The identical generated property runs in retained fixed-seed and seedless - * random lanes. Supplying both TANSTACK_DB_DRIVER_FAIRNESS_SEED and - * TANSTACK_DB_DRIVER_FAIRNESS_PATH replaces those lanes with one checked + * random campaigns. Supplying both TANSTACK_DB_DRIVER_FAIRNESS_SEED and + * TANSTACK_DB_DRIVER_FAIRNESS_PATH replaces those campaigns with one checked * seed+shrink-path replay. An axis-removal calibration proves that removing * tail-order variation loses hydrate-before-later-persist histories. * The shared fixture holds the first `BEGIN IMMEDIATE` while cold `preload()` diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts index 43362d9f80..39a7a7776e 100644 --- a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts @@ -46,14 +46,14 @@ import { declareOracle } from '../../db/tests/oracle-declaration.js' * let an additional persist through or change the interleaving. The scheduler * trace records hydrate scope ends and barrier settlement, so a test can * check that the barrier waited for the work queued before it and not for - * later work. Two hostile controls take a scheduler turn instead: + * later work. Two mutant controls take a scheduler turn instead: * `empty-hydrate` enqueues an empty hydrate scope, which K=1 counts as a * hydrate, and `empty-regular` enqueues an empty regular task, which K=1 * counts as the persist slot after a hydrate. * * Challenge and replay: the named persist-first FIFO wrong answer must violate * the same K=1 checker while using the public/core/driver path. The generated - * property has fixed-seed and seedless-random lanes. Supplying both + * property has fixed-seed and seedless-random campaigns. Supplying both * TANSTACK_DB_DRIVER_FAIRNESS_SEED and TANSTACK_DB_DRIVER_FAIRNESS_PATH runs * only their checked oracle replay. Tail-order ablation must lose the legal * hydrate-before-later-persist cell. The grammar also reconstructs that known @@ -155,9 +155,9 @@ export type SharedDriverFairnessViolation = { } export type SharedDriverFairnessOptions = { - /** Test-only hostile control that preserves the current global FIFO fault. */ + /** Test-only mutant control that preserves the current global FIFO fault. */ schedulingFault?: `persist-first-fifo` - /** Test-only hostile barriers that take a scheduler turn K=1 counts. */ + /** Test-only mutant barriers that take a scheduler turn K=1 counts. */ barrierFault?: `empty-hydrate` | `empty-regular` } @@ -296,7 +296,7 @@ class ObservedDatabase implements BrowserWASQLiteDatabase { } /** - * A test-only hostile scheduler. It admits calls through the same public/core/ + * A test-only mutant scheduler. It admits calls through the same public/core/ * BrowserWASQLiteDriver path but serializes them in global admission order, so * a future fair production driver cannot accidentally make this control pass. */ @@ -659,7 +659,7 @@ export function findSharedDriverFairnessLawViolations( return violations } -/** Checker calibration only; the executable hostile control uses the fixture. */ +/** Checker calibration only; the executable mutant control uses the fixture. */ export function createPersistFirstFaultObservation( scenario: SharedDriverFairnessScenario, ): SharedDriverFairnessObservation { diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts index 62b76239a2..33b9e3b73d 100644 --- a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts @@ -7,10 +7,10 @@ import { } from './shared-driver-fairness-config' // Configuration tests for the shared BrowserWASQLiteDriver fairness oracle in -// `shared-driver-fairness-oracle.test.ts`, which owns the law. These pin the replay +// `shared-driver-fairness-oracle.test.ts`, which owns the law. These check the replay // and calibration environment parsing. describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { - it(`selects fixed and seedless lanes by default and only replay when requested`, () => { + it(`selects fixed-seed and random campaigns by default and replays only when requested`, () => { expect(createFairnessPropertyModes(readFairnessReplayConfig({}))).toEqual([ { label: `fixed-seed`, seed: FIXED_SEED }, { label: `seedless-random` }, diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index 7e9e859727..c0ff97ad06 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -382,9 +382,7 @@ it.each([`insert`, `update`, `delete`] as const)( draft(fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n })), draft(fc.constantFrom(NaN, Infinity, -Infinity, -0)), draft( - fc - .uint8Array({ maxLength: 12 }) - .map((bytes) => bytes.buffer), + fc.uint8Array({ maxLength: 12 }).map((bytes) => bytes.buffer), ), ) const histories = fc.record({ @@ -407,7 +405,7 @@ it.each([`insert`, `update`, `delete`] as const)( ] // Each operation owns its replay grammar. One insert/update bundle contains // six process histories; three delete bundles contain six too. Fixed and - // random lanes therefore retain the same six-history budget per operation. + // random campaigns therefore retain the same six-history budget per operation. const replayPrefix = `CONVEX_BACKEND_${operation.toUpperCase()}_ORACLE` await runOracleCampaign( replayPrefix, diff --git a/packages/convex-db-collection/tests/lazy-paging-driver.ts b/packages/convex-db-collection/tests/lazy-paging-driver.ts index e2a3d817a4..4437f98632 100644 --- a/packages/convex-db-collection/tests/lazy-paging-driver.ts +++ b/packages/convex-db-collection/tests/lazy-paging-driver.ts @@ -425,7 +425,7 @@ export function lazyFixture( } batches.push(batch) for (const listener of batchListeners) listener(batch) - // Hostile receiver controls may lie to the adapter while the observer + // Mutant receiver controls may lie to the adapter while the observer // retains the actual core receipt and its true application boundary. return adapterReceipt ? adapterReceipt(receipt) : receipt }, diff --git a/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts index 51312d762c..5727aceb0b 100644 --- a/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts @@ -84,7 +84,7 @@ async function runVersions(h: History, fault: Fault = `none`) { const publish = () => { const rows = [...f.source.base.values()].map((row) => ({ ...row })) compare(rows) - // Hostile publication controls run at the same observation boundary, then + // Mutant publication controls run at the same observation boundary, then // restore the correct final value. They test trace comparison, not a claim // that production itself emitted these deliberately injected observations. if (!injected && fault !== `none` && rows[0]?.text.endsWith(`revision-2`)) { diff --git a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts index ec4bd04683..c68a770281 100644 --- a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts @@ -10,7 +10,7 @@ * Every generated history covers valid and duplicate starts, then constructs * omission, field removal, empty results, * reintroduction, duplicates and recovery, then appends arbitrary snapshots. - * String, numeric and mixed key lanes share that history. The mixed lane + * String, numeric and mixed key grammars share that history. The mixed grammar * includes 1/'1'; finite numeric parameters include fractions and negatives. * Raw and selected runs compare the same law. Selection changes key shape and * uppercases text; the reference states that transformation independently. @@ -77,24 +77,24 @@ it(`refines complete snapshots throughout generated histories`, async () => { }), async (numberKey, text, suffix) => { reach.beginHistory() - const lanes = [ + const keyKinds = [ { name: `string-keys`, keys: [`a`, `b`, `c`] }, { name: `numeric-keys`, keys: [numberKey, numberKey + 0.5, 1] }, { name: `mixed-key-types`, keys: [1, `1`, numberKey] }, ] as const - for (const lane of lanes) { + for (const keyKind of keyKinds) { const first: KeyedRow = { _id: `first`, - key: lane.keys[0], + key: keyKind.keys[0], text: `lower:${text}`, optional: `remove`, } const second: KeyedRow = { _id: `second`, - key: lane.keys[1], + key: keyKind.keys[1], text, } - // The first two lane keys are always distinct. This constructive + // The first two keys are always distinct. This constructive // prefix forces adjacent laws even when the random suffix is empty. const history: Array> = [ [first, second], @@ -107,7 +107,7 @@ it(`refines complete snapshots throughout generated histories`, async () => { ...suffix.map((snapshot) => snapshot.map((value): KeyedRow => ({ _id: `source:${value.slot}`, - key: lane.keys[value.slot]!, + key: keyKind.keys[value.slot]!, text: value.text, ...(value.optional === undefined ? {} @@ -216,7 +216,7 @@ it(`refines complete snapshots throughout generated histories`, async () => { if (projected && selected.length > 0) reach.reach(`selected-key-representation`) if (!projected && selected.length > 0) - reach.reach(lane.name) + reach.reach(keyKind.name) if ( selected.some((value) => value.key === 1) && selected.some((value) => value.key === `1`) diff --git a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts index 73fc6297a7..3c464049ce 100644 --- a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts @@ -21,7 +21,7 @@ import { declareOracle } from '../../db/tests/oracle-declaration.js' * keep rare topology classes present even when random generation misses them. * The generated topology claim is limited to plain objects with numeric * labels and array edges, at most six nodes and three edges per node. Cycle - * rejection through other containers is pinned by hash-graph.test.ts, an + * rejection through other containers is fixed by hash-graph.test.ts, an * ordinary test file. This file does not establish collision freedom or * complexity limits. */ diff --git a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts index 6ce85f5817..acb61ffaa0 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -54,7 +54,7 @@ import { colliding } from './hash-collision-support.js' * equal, so every type marker has the same hash number. * * Authority: the method comments in `src/hashing/hash.ts`, and the behavior at - * `8283f2e8`, which this file pins before the hash dispatch refactor. + * `8283f2e8`, which this file fixes before the hash dispatch refactor. * * Limits: * - Arrays from another realm (`node:vm`) are outside the grammar. An ordinary @@ -902,8 +902,8 @@ describe(`hash identity oracle`, () => { expect(merged).toBeGreaterThan(0) }) - // Pinned witnesses for rules that a wrong design would plausibly break. - const pinned: Array<[string, Spec, Spec]> = [ + // Fixed witnesses for rules that a wrong design would plausibly break. + const fixedWitnesses: Array<[string, Spec, Spec]> = [ [ `a hole differs from undefined`, { k: `array`, items: [null, num(1)] }, @@ -1027,7 +1027,7 @@ describe(`hash identity oracle`, () => { { k: `obj`, entries: [], syms: [[1, num(1)]] }, ], ] - it.each(pinned)(`%s`, (_name, left, right) => { + it.each(fixedWitnesses)(`%s`, (_name, left, right) => { for (const seeds of [ [1, 2], [3, 4], diff --git a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts index 856e22cc87..eb1ba8ea96 100644 --- a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts +++ b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts @@ -44,7 +44,7 @@ * compared through the same prefix identity. * * Calibration: comparing prefixes with `===` kept a cancelled `NaN`-prefixed - * value, or threw `Mismatching prefixes`, and fails the pinned histories and + * value, or threw `Mismatching prefixes`, and fails the fixed histories and * both campaigns. * * Known omissions: `Index` joins, compaction, presence tracking, and structural @@ -165,7 +165,7 @@ const add = (value: Value, multiplicity: number): Addition => ({ }) // Each history cancels or merges `NaN`-prefixed values in a different layout. -const pinnedHistories: ReadonlyArray<{ +const fixedHistories: ReadonlyArray<{ name: string additions: Array }> = [ @@ -249,7 +249,7 @@ declareOracle({ const INDEX_REFINEMENT_PROPERTY = `ivm.index-refinement` describe(`Index refinement oracle`, () => { - for (const { name, additions } of pinnedHistories) { + for (const { name, additions } of fixedHistories) { oracleReplayTest(it)(`matches the multiset model when ${name}`, () => { expectRefinement(additions) expectRefinement(additions, `prefixIdentity`) diff --git a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts index a98469428c..3236c21d13 100644 --- a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts +++ b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts @@ -344,7 +344,7 @@ collisionCases.push( }, ) // A witness is named by its case name, and its multiplicities are scaled, -// so the history is plain data and a pinned witness is a member of the +// so the history is plain data and a fixed witness is a member of the // language. `opposed` gives the first record the scale and every later one // its negation, so distinct identities would cancel if wrongly merged; // otherwise each record keeps its own multiplicity times the scale. @@ -684,9 +684,9 @@ oracleProperty({ ], runs: 300, fixedSeeds: [2026929], - // Pinned witnesses for each identity rule. Generated histories can reach - // many of these, but pinning them makes each rule visible and stable. - pins: [ + // Fixed witnesses for each identity rule. Generated histories can reach + // many of these, but fixing them makes each rule visible and stable. + fixed: [ ...collisionCases.map(({ name }) => ({ name, enters: [`keyedCollision`], diff --git a/packages/db-ivm/tests/operators/groupBy.test.ts b/packages/db-ivm/tests/operators/groupBy.test.ts index acc2aaa916..594395f931 100644 --- a/packages/db-ivm/tests/operators/groupBy.test.ts +++ b/packages/db-ivm/tests/operators/groupBy.test.ts @@ -839,7 +839,7 @@ describe(`Operators`, () => { }) test(`min and max can return the deleted instance of a hash-equal value`, () => { - // Current behavior, pinned so a db-ivm change alerts the compiler owner. + // Current behavior, fixed so a db-ivm change alerts the compiler owner. // The delete's retraction is the last record the shared entry gets, so // its instance, the deleted value, is the one that comes back, in either // insertion order. diff --git a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts index a11acc87cb..604e5f2b0a 100644 --- a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts @@ -38,13 +38,13 @@ declareOracle({ * calibrate that checker without exercising production. * * The grammar covers five finite scalar keys, one fixed payload per key, - * support 0..3, and offset/limit 0..6. The numeric-window lane also reaches + * support 0..3, and offset/limit 0..6. The numeric-window grammar also reaches * safe-integer offsets and widths near Number.MAX_SAFE_INTEGER. Every input turn and window move has * a graph-run checkpoint for output rows and reported window size. - * The pinned 0/1/2 support crossing and boundary history distinguish presence + * The fixed 0/1/2 support crossing and boundary history distinguish presence * from raw multiplicity and inclusive from displaced window cuts. Row replacements, * multiple groups, negative net support, and downstream lazy acquisition are - * outside this owner. The numeric lane does observe the empty output message + * outside this owner. The numeric-window grammar does observe the empty output message * that lets a downstream graph respond to changed requested demand. */ @@ -223,7 +223,7 @@ describe.each([ expect(size()).toBe(selected.length), ) } - // Pin positivity crossings before every generated history. + // Fix positivity crossings before every generated history. const history = [1, 2, 1, 0, 2, 1, 0].map((support) => ({ slot: 0, support, @@ -261,7 +261,7 @@ describe.each([ * message is this operator boundary's demand signal, not source completeness. * Extra graph messages remain permitted, including for unchanged requested sets. * - * This lane keeps three to five positive typed keys installed. It varies the + * This grammar keeps three to five positive typed keys installed. It varies the * installed size, near-maximum offset, finite width, and empty-range gap. * The four profiles distinguish overflowing arithmetic, work proportional to * absent ranks, exact width retention after a partial move, and lost demand @@ -524,7 +524,7 @@ describe.each([ * with installed ranks; clamping its offset before applying width changes it. * Negative limits and fractional bounds have no authority here and stay out. * - * This lane adds the missing offset domain without replacing the support or + * This grammar adds the missing offset domain without replacing the support or * large-numeric campaigns. Installed cardinality, negative depth, width and gap * distinguish absolute intervals from JavaScript's end-relative slice indices. * A legal support tail adds nearby edits and moves. A constructive prefix @@ -536,7 +536,7 @@ describe.each([ * one-group top-K and the public orderBy wrapper. Where the public size getter * accepts a predicate, an always-true predicate must count the same selected * rows. Multiple groups, varying prefix predicates and downstream acquisition - * remain outside this lane. Mismatches + * remain outside this grammar. Mismatches * accumulate through the history so a size defect cannot hide row movement or * a source-edit defect. A runtime interruption remains a separate failure. */ diff --git a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts index e82ee18118..a07e11ccde 100644 --- a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts @@ -39,7 +39,7 @@ import { declareOracle } from '../../db/tests/oracle-declaration.js' * `lastIndex`, Maps and Sets with zero to two entries, arrays with an * enumerable non-index field, small binary values, holes, and nesting deeper * than the identity's depth cap. - * Pinned pairs cover distinct symbols with the same description, and different + * Fixed pairs cover distinct symbols with the same description, and different * object kinds with the same body. Hand-written collision pairs are ordinary * tests in structural-identity.test.ts. Fixed representation witnesses verify that the driver reaches each * prototype, key-order, and hidden-symbol variant it claims. The depth cap @@ -63,7 +63,7 @@ type Spec = const SYMBOL = Symbol(`s`) const ENUMERABLE_SYMBOL = Symbol(`enumerable`) -const { replayProperty, replaySeed } = readOracleRunConfig() +const { replayProperty, campaignSeed } = readOracleRunConfig() const LEAVES: ReadonlyArray = [ 0, -0, @@ -437,12 +437,12 @@ describe(`structuralIdentity`, () => { 2000, `structural-identity.equal-representations`, ) - if (replaySeed === undefined) + if (campaignSeed === undefined) await fc.assert(property, { numRuns: options.numRuns, seed: 20261014 }) await fc.assert(property, options) }) - // Named replays of counterexamples the random campaign found. Nested `deep` + // Named fixed histories of counterexamples the random campaign found. Nested `deep` // nodes compose, so these specs build equal values; an empty text leaf and the // leaf it shares a value with must also normalize alike. it.each([ @@ -597,7 +597,7 @@ describe(`structuralIdentity`, () => { 2000, `structural-identity.independent-values`, ) - if (replaySeed === undefined) + if (campaignSeed === undefined) await fc.assert(property, { numRuns: options.numRuns, seed: 1158727847 }) await fc.assert(property, options) }) diff --git a/packages/db-ivm/tests/structural-identity.test.ts b/packages/db-ivm/tests/structural-identity.test.ts index ccc03ad3ef..4320d07c22 100644 --- a/packages/db-ivm/tests/structural-identity.test.ts +++ b/packages/db-ivm/tests/structural-identity.test.ts @@ -7,7 +7,7 @@ import { equalHashValues, structuralIdentity } from '../src/hashing/hash.js' // Different values whose parts, read without lengths, join to the same text. // A random four-character string never embeds a whole later part, so these -// pairs pin the cases that a missing length prefix would merge. +// pairs fix the cases that a missing length prefix would merge. const collisionPairs: Array<[string, unknown, unknown]> = [ [`string spans a later key`, { a: `x|1:b="y` }, { a: `x`, b: `y` }], [`key spans a later key`, { [`a="1:x|b`]: `y` }, { a: `x`, b: `y` }], diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts index 257f112e13..8f3c355958 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts @@ -1177,7 +1177,7 @@ async function assertRun(property: string, result: fc.RunDetails) { } async function campaign(options: Parameters[1]) { - // A seed alone replays the whole campaign and must still enforce reach. + // A seed alone runs a reseeded campaign and must still enforce reach. // A path replays one input directly and does not owe campaign-wide reach. const reach = createOracleReach(PROPERTY, REACH_STATES, { replaying: oracleReplayRequested(), diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts index 9c97efc803..ed69824d6e 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts @@ -88,7 +88,7 @@ import type { SQLiteDriver } from '../src/index.js' * with the generated campaign and every receiving driver, * `tests/persisted-demand-coverage-model-oracle.ts`. They record the * source's log and each query's loads and observation, and state no expected - * answer (ORC-016). Two pinned checks whose laws other owners hold live in + * answer (ORC-016). Two fixed regressions whose laws other owners hold live in * `persisted-demand-coverage-supplemental.test.ts`. * * Limits: Electric, Query DB, React, and Solid have receiving witnesses in diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts index 7035909a08..9471c65c3f 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts @@ -20,7 +20,7 @@ import type { SQLiteDriver } from '../src/index.js' /** * Supplemental checks beside the persisted demand coverage oracle - * (`persisted-demand-coverage-oracle.test.ts`). These pin two behaviors that + * (`persisted-demand-coverage-oracle.test.ts`). These check two behaviors that * the opt-in must leave as they were, and whose laws other owners hold, so * they are ordinary examples rather than inputs to the coverage judgment * (ORC-016): diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts index 157e49e1db..0fabb2becf 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts @@ -2377,7 +2377,7 @@ describe(`persistedCollectionOptions`, () => { // The earlier history law cancelled only after remote acquisition had // begun. Holding the real adapter hydration seam exposes the distinct - // post-hydration transfer boundary. `retain` is the hostile control: + // post-hydration transfer boundary. `retain` is the mutant control: // the cancellation guard must not suppress live demand. expect({ ensures: ensure.mock.calls.length, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts index 4b3f4d100e..5be9133a2e 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts @@ -210,7 +210,7 @@ describe(`persistedCollectionOptions`, () => { await vi.advanceTimersByTimeAsync(200) // The old matrix supplied an AbortError or explicitly unloaded demand, // but never aborted an already-queued retry. The signal row owns that - // distinct boundary; offline is its hostile liveness control. + // distinct boundary; offline is its mutant liveness control. expect(result).toBe(failure) expect(followerUpstreamLoad).not.toHaveBeenCalled() if (action === `offline`) { @@ -1169,7 +1169,7 @@ describe(`persistedCollectionOptions`, () => { // Earlier laws covered external-sync commit classification and a // successful follower mutation acknowledgement. They never varied a // serialized applyLocalMutations failure by route. The elected-leader - // and non-durability rows are hostile controls for topology parity and + // and non-durability rows are mutant controls for topology parity and // for avoiding an over-broad fail-stop classifier. if (expectedDurabilityError) { expect(rejection).toMatchObject({ @@ -1692,7 +1692,7 @@ describe(`persistedCollectionOptions`, () => { await vi.waitFor(() => expect(sourceLoads).toBe(2)) }) - // This deliberately wrong global-eviction message is a hostile control for + // This deliberately wrong global-eviction message is a mutant control for // the two-run Browser generation-routing oracle. A global full reload makes // the warm run lose a valid source row, so it cannot implement cache // retirement. This driver checks that the public-row comparison detects diff --git a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts index 2bf7de957c..046c3f1aaf 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts @@ -21,7 +21,7 @@ import type { CollectionConfig } from '@tanstack/db' import type { HistoryRow, OptimisticStep, - WrappingSyncLane, + WrappingSyncPath, } from '../../db/tests/optimistic-history-oracle' import type { PersistedTx, PersistenceAdapter } from '../src' @@ -46,9 +46,9 @@ import type { PersistedTx, PersistenceAdapter } from '../src' * the wrapper discards it when its turn comes, and every settled row it alone * held drops in that publication. The model fixes that drop at the turn; an * implementation that dropped the hold at abort time would also be legal, and - * this lane would reject it. + * this wrapping-sync path would reject it. * - * Reach: two segments make the lane's own states required on every run. A + * Reach: two constructed histories make the wrapping-sync path's own states required on every run. A * hold across a drain (`deferred-hold`) comes after a generated history, so * the hold meets whatever that history left; an abort that releases a held * row (`abort-release`) comes first and releases the store itself, because a @@ -57,8 +57,8 @@ import type { PersistedTx, PersistenceAdapter } from '../src' * Grammar limits: a handler cannot await acceptance while a hold is * outstanding, because acceptance follows the held durable step; the grammar * clears `awaitReceipt` there, and its unmapper admits only such histories. - * The schema, partial-update, and reentrant lanes stay core-only. The adapter - * is a Map, so SQLite timing and coordinator messages are outside this lane. + * The schema, partial-update, and reentrant grammars stay core-only. The adapter + * is a Map, so SQLite timing and coordinator messages are outside this wrapping-sync path. */ function createHoldingAdapter() { const rows = new Map() @@ -115,13 +115,13 @@ function createHoldingAdapter() { } } -let laneRun = 0 +let productionPathRun = 0 -function persistedLane(): WrappingSyncLane & { +function persistedProductionPath(): WrappingSyncPath & { rows: Map } { const store = createHoldingAdapter() - const id = `persisted-optimistic-history-${++laneRun}` + const id = `persisted-optimistic-history-${++productionPathRun}` return { db: { createCollection, @@ -269,7 +269,7 @@ oracleProperty({ law: { statement: `A persisted Collection exposes the rows, publications, request outcomes, receipts, and tracked transactions that the optimistic-history model owes at every step, including while a durable store step is held and after a deferred source transaction aborts.`, authority: `packages/db/tests/optimistic-history-oracle.ts header (the optimistic and sync history contract, held rows behind a wrapping sync)`, - limits: `An in-memory adapter, not SQLite timing or coordinator messages; no awaited acceptance during a hold; no schema, partial-update, or reentrant lane.`, + limits: `An in-memory adapter, not SQLite timing or coordinator messages; no awaited acceptance during a hold; no schema, partial-update, or reentrant grammar.`, owner: `Optimistic state`, }, histories: { @@ -280,7 +280,7 @@ oracleProperty({ ), runs: 40, fixedSeeds: [22012], - segments: [ + constructed: [ { name: `a hold across a drain`, enters: [`deferred-hold`], @@ -306,7 +306,7 @@ oracleProperty({ }), }, ], - pins: [ + fixed: [ { // The same history with partial optimistic fields, written as core // edits whose handler batches do not await acceptance. @@ -344,7 +344,10 @@ oracleProperty({ reached: optimisticHistoryReach, // Each history gets its own adapter and Collection id. driver: (plan, observe) => - optimisticHistoryDriver({ wrapping: persistedLane() })(plan, observe), + optimisticHistoryDriver({ wrapping: persistedProductionPath() })( + plan, + observe, + ), check: checkOptimisticHistory, calibrations: optimisticHistoryCalibrations, timeout: 60_000, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts index 2608a7fe9c..7f7f2dd335 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts @@ -395,7 +395,7 @@ function collectionSource(shape: Shape, cell: Cell): string { * A supplemental transparency relation preserves the full payload across the * wrapper; QueryObserverResult internals have no independent model here. * This single model replaces standalone fixed refetch/nonexistent-method calls. - * Exact namespace equality pins the current API. An intentional documented + * Exact namespace equality checks the current API. An intentional documented * utility addition must update this model; closure does not prohibit additions. */ function querySource(shape: Shape): string { @@ -620,7 +620,7 @@ const utilityProperties = { * Every generated history crosses the structurally required forms. Reach is * recorded only after the generated consumer passes compilation. A replay uses * the shared seed/path/property interface: it runs the random campaign alone, - * without the pinned shapes. + * without the fixed shapes. * Fixed and fresh-seed campaigns use the identical grammar, budget, and check. */ export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 04a1ad80d7..16eb0ece82 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -3529,8 +3529,8 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { * ensure log at quiescence. The coordinator is a controlled double of an * elected owner; it does not prove the browser transport. * - * Known gap (#2139): production does not end a rotated follower's scoped - * recovery, so this lane's cells are pinned as failing until it does. + * Known limit (#2139): production does not end a rotated follower's scoped + * recovery, so this production path's cells are fixed histories that fail until it does. */ type FollowerGenerationNoticeHistory = { ownerCommit: `before-rotation` | `after-rotation` @@ -3838,7 +3838,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } } - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each([ { ownerCommit: `before-rotation`, later: `none` }, @@ -3851,7 +3851,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { assertFollowerGenerationNoticeHistory({ ...history, ownerRead: `ok` }), ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each( ([`gap`, `reconciliation`] as const).flatMap((noticeShape) => @@ -3869,7 +3869,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { assertFollowerGenerationNoticeHistory({ ...history, ownerRead: `ok` }), ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each([ { ownerCommit: `before-rotation`, later: `owner` }, @@ -3880,7 +3880,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { assertFollowerGenerationNoticeHistory({ ...history, ownerRead: `fails` }), ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails( `ignores a follower read failure when rotation starts while the read is in flight`, @@ -3907,10 +3907,10 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { `fails`, ), }) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. Some - // generated histories pass today, so a random draw cannot be pinned as - // failing; this fixed seed pins the gap. The fix for #2139 adds the random + // generated histories pass today, so a random draw cannot be a fixed history that + // fails; this fixed seed records the limit. The fix for #2139 adds the random // and replayed campaign back. it.fails( `checks generated follower generation-notice histories (fixed)`, @@ -4082,7 +4082,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { `settles a follower's remote demand with the owner's applied rows: $placement broadcast, apply $apply, $consumer`, assertFollowerRemoteDemandHistory, ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each( followerRemoteDemandHistories.filter( @@ -4136,9 +4136,9 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { * recovery in place, and a newer rotation supersedes an older one's * reacquisition. * - * Known gap (#2139): production does not yet end scoped recovery after a + * Known limit (#2139): production does not yet end scoped recovery after a * rotation, so the histories in which recovery should end, or in which a - * new demand waits for it, are pinned as failing. + * new demand waits for it, are fixed histories that fail. * * Limits. A failed reacquisition is not retried; the follower stays in * scoped recovery until a later recovery rotates again. @@ -4533,23 +4533,23 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), ), ) - // Known gap: see #2139. Recovery never ends today, so the histories in + // Known limit: see #2139. Recovery never ends today, so the histories in // which it should end, or in which a new demand must wait for it, fail. - const reacquisitionGap = ({ + const reacquisitionLimit = ({ fails, newDemand, }: RotatedReacquisitionHistory) => newDemand === true || fails === `none` || fails === `released` it.each( rotatedReacquisitionHistories.filter( - (history) => !reacquisitionGap(history), + (history) => !reacquisitionLimit(history), ), )( `ends a follower's scoped recovery only after every reacquisition succeeds: order $order, notice between $noticeBetween, fails $fails, new demand $newDemand`, assertRotatedReacquisition, 10_000, ) - it.fails.each(rotatedReacquisitionHistories.filter(reacquisitionGap))( + it.fails.each(rotatedReacquisitionHistories.filter(reacquisitionLimit))( `ends a follower's scoped recovery only after every reacquisition succeeds: order $order, notice between $noticeBetween, fails $fails, new demand $newDemand`, assertRotatedReacquisition, 10_000, @@ -4579,7 +4579,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }), 10_000, ) - // Known gap: see #2139. Recovery never ends, so the rows stay hidden. + // Known limit: see #2139. Recovery never ends, so the rows stay hidden. it.fails( `holds a rotated follower's notices when two reads find the claim lost: fails 'none'`, () => @@ -4593,7 +4593,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ) const followerRemoteDemandHistory = fc.record({ - // Known gap: see #2139. The rotated placement is pinned above. + // Known limit: see #2139. The rotated placement is a fixed history above. placement: fc.constantFrom( `direct`, `queued`, @@ -6787,7 +6787,7 @@ function expectedDurableHydrationTransactions( // replay from this shared file. These bounded cases exclude every explicit replay. // Terminal local persistence failures crash the Collection after readiness, // following the IndexedDB closure precedent. Upstream (wrapped sync adapter) -// failures after readiness stay health metadata; this lane holds both. +// failures after readiness stay health metadata; this block holds both. describeUnlessAnyOracleReplay(`hydration source durability routing`, () => { const first = { id: `shared`, title: `first source value` } const second = { id: `shared`, title: `last source value` } diff --git a/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts index 9279d5c4a2..82f212f684 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts @@ -1799,7 +1799,7 @@ describe(`persistedCollectionOptions`, () => { }, ) - // Hostile output controls calibrate the same comparison used above and by + // Mutant output controls calibrate the same comparison used above and by // the captured-read source history. A missing key, an obsolete defined // field, and an obsolete own-undefined field must all be rejected. These // controls do not substitute for the production RED witnesses above. diff --git a/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts index e30c0fb77d..a04fe24347 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts @@ -2184,7 +2184,7 @@ describe(`hydration routing probe calibration`, () => { }) // The adapter contract requires call-local forwarding through wrappers. This - // positive/hostile pair reaches the real coordinator but lets the recorder + // positive/mutant pair reaches the real coordinator but lets the recorder // finish bad calls, so losing the loan is a routing assertion, not a timeout. it.each([true, false])( `detects a wrapper that forwards its loan=%s`, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts index 9a4e7a6435..6da1a1cab8 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts @@ -1210,7 +1210,7 @@ describe(`persistedCollectionOptions`, () => { peerStatus: `ready`, }) - // This hostile control bypasses the sync-run-owned callback. It proves + // This mutant control bypasses the sync-run-owned callback. It proves // the status observation above would reject an unfenced late ready. expect(() => collection._lifecycle.markReady()).toThrowError( expect.objectContaining({ name: `CollectionStateError` }), diff --git a/packages/db-sqlite-persistence-core/tests/persisted-support.ts b/packages/db-sqlite-persistence-core/tests/persisted-support.ts index 72a4afd7db..7e92a2d877 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-support.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-support.ts @@ -1345,11 +1345,11 @@ export const assertGenerationNoticeHistory = async ({ * loss: the load rejects as after a failed ensure, and the demand stays * active and retries instead of releasing the owner's acquisition. * - * Known gap (#2139): a rotated follower never ends scoped recovery, so the + * Known limit (#2139): a rotated follower never ends scoped recovery, so the * owner's row never becomes visible in the rotated placement. Its cells are - * pinned as failing, and the generated histories omit that placement. A + * fixed histories that fail, and the generated histories omit that placement. A * rotation that starts while a load waits for the owner's commits is also - * outside this lane and tracked there. + * outside this history grammar and tracked there. */ export type FollowerRemoteDemandHistory = { placement: `direct` | `queued` | `rotated` | `expired` diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts index 50fb1b66ed..200f529337 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts @@ -527,7 +527,7 @@ export function runSQLiteResetResumeOracleTests( * * Observed result and known omissions: exact settled rows, exact metadata * keys, and whether the durable Electric state is absent/reset/resume. The - * external-loss lane proves the generic adapter leaves metadata reachable; + * external-loss grammar proves the generic adapter leaves metadata reachable; * only the persisted+Electric suite judges whether that cursor is safe to * consume. The injected loss is not a claim that arbitrary SQL is a * supported public API. Partial resumed updates and SDK framing retain @@ -583,7 +583,7 @@ export function runSQLiteResetResumeOracleTests( }), ) - // A seed-only shared run reseeds this campaign. Ordinary runs keep the + // A shared run with only a seed is a reseeded campaign of this property. Ordinary runs keep the // fixed seed 1659, and a path replay runs once from its recorded path. const parameters = oraclePropertyOptions( 24, @@ -808,7 +808,7 @@ export function runSQLiteResetResumeOracleTests( * * Refinement: require the exact rows, reached predicate SELECTs, no * SQLite error, and no statement above its declared binding cap. The original 900-item - * OR-chunk lowering is the hostile design: it reaches the intended SELECT and + * OR-chunk lowering is the mutant design: it reaches the intended SELECT and * fails the public result/binding comparison. The legacy CLI test interpolates * literals, so it cannot exercise this boundary. * @@ -1027,7 +1027,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { }) }, ) - // Runs in ordinary and seed-only runs, and as the replay of its property. + // Runs in ordinary and reseeded campaigns, and as the replay of its property. oracleReplayTest(it, BINDING_PROPERTY)( `preserves exact rows across random-seed binding histories`, async () => { diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts index 098a8edada..9ec40795c3 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts @@ -248,9 +248,9 @@ export async function observeCachedSchemaState( * compares the entire projected schema state; the held boundary and reset epoch * are reach witnesses, while compatible reopen and recertifying truncate cases * prevent an oracle that merely rejects every resume. - * A separate generated startup lane holds the runtime between its metadata and + * A separate generated startup grammar holds the runtime between its metadata and * hydration snapshots, then crosses no write, a managed public insert, and a - * hostile raw deletion over one-to-three baseline rows. Its independent model + * mutant raw deletion over one-to-three baseline rows. Its independent model * preserves every managed row but fails closed for unsupported raw loss. * The focused work law first executes one controlled expected-key table read to * prove its SQL observer can detect the forbidden membership work, then resets diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts index f9c2e0c287..0a2d3f07cd 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts @@ -3,7 +3,7 @@ * Scenario checks for native Temporal values in the SQLite core adapter: IN * membership against lookalike string keys, and the native literal encoding * budget. `sqlite-temporal-value-oracle.test.ts` owns the typed-value law and its - * grammar; these tests pin single cases with hand-written expectations. + * grammar; these tests check single cases with hand-written expectations. */ import { IR } from '@tanstack/db' import { Temporal } from 'temporal-polyfill' diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts index f172d5432d..e2dffa65c5 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts @@ -3,8 +3,8 @@ import { fc } from '@fast-check/vitest' import { expect, it } from 'vitest' import { missingRowMetadataHistoriesArbitrary, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, queuedMetadataRepairHistoriesArbitrary, runSessionHistory, seedReinsertReloadReplay, @@ -52,7 +52,7 @@ it(`rejects a recovery schedule whose boundary the driver cannot hold`, async () bare: false, value: 0, }, - () => createSQLiteDriver(persistedPeerOnDemandLane), + () => createSQLiteDriver(persistedPeerOnDemandGrammar), ), ).rejects.toThrow(`commit completed without reaching after-application`) }) @@ -88,13 +88,15 @@ it(`rejects a wrong durable write even when a later queued write repairs it`, as numRuns: 1, })[0]! for (const { history, bare, held } of cases) { - const baseline = await runSessionHistory(persistedPeerLane, history, () => - createPersistedDriver(true, true, false, true), + const baseline = await runSessionHistory( + persistedPeerGrammar, + history, + () => createPersistedDriver(true, true, false, true), ) expect(baseline.mismatch).toBeUndefined() let injected = false - const hostile = await runSessionHistory( - persistedPeerLane, + const mutant = await runSessionHistory( + persistedPeerGrammar, history, async () => { const driver = await createPersistedDriver(true, true, false, true) @@ -121,8 +123,8 @@ it(`rejects a wrong durable write even when a later queued write repairs it`, as }, ) expect(injected, `${bare}/${held}`).toBe(true) - expect(hostile.mismatch?.law).toContain(`keeping metadata does not write`) - expect(hostile.mismatch?.action).not.toBe(`settle`) + expect(mutant.mismatch?.law).toContain(`keeping metadata does not write`) + expect(mutant.mismatch?.action).not.toBe(`settle`) } }, 30_000) @@ -133,21 +135,26 @@ const hosts: Array<{ name: string; create: () => Promise }> = [ }, { name: `SQLite`, - create: () => createSQLiteDriver(persistedPeerOnDemandLane), + create: () => createSQLiteDriver(persistedPeerOnDemandGrammar), }, ] it.each(hosts)( `rejects wrong source metadata that appears only after reload ($name)`, async ({ create, name }) => { - const lane = - name === `SQLite` ? persistedPeerOnDemandLane : persistedPeerLane + const productionPath = + name === `SQLite` ? persistedPeerOnDemandGrammar : persistedPeerGrammar expect( - (await runSessionHistory(lane, seedReinsertReloadReplay, create)) - .mismatch, + ( + await runSessionHistory( + productionPath, + seedReinsertReloadReplay, + create, + ) + ).mismatch, ).toBeUndefined() let reached = false const result = await runSessionHistory( - lane, + productionPath, seedReinsertReloadReplay, async () => { const driver = await create() @@ -191,7 +198,7 @@ it.each(recoveryPhases)( runRecoverySchedule( { phase, interruption: `seed`, bare: false, value: 7 }, async () => { - const driver = await createSQLiteDriver(persistedPeerOnDemandLane) + const driver = await createSQLiteDriver(persistedPeerOnDemandGrammar) const hold = driver.holdRecovery let holding = false driver.holdRecovery = (selected) => { @@ -235,7 +242,7 @@ it.each(recoveryPhases)( runRecoverySchedule( { phase, interruption: `cleanup`, bare: true, value: 7 }, async () => { - const driver = await createSQLiteDriver(persistedPeerOnDemandLane) + const driver = await createSQLiteDriver(persistedPeerOnDemandGrammar) const stop = driver.stopSource const observe = driver.observe let ended = false diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts index ef09771c22..67dbe616b8 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts @@ -5,9 +5,9 @@ import { describeMismatch, missingRowMetadataCases, missingRowMetadataHistoriesArbitrary, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, runSessionHistory, } from '../../db/tests/sync-metadata-session-oracle' import { @@ -29,10 +29,10 @@ import { createSQLiteDriver } from './sync-metadata-session-sqlite-driver' * named peer notice. Core holds vary application without delaying durability. * This is one sync run; restart and intermediate SQL statements are outside it. */ -const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, +const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] const requested = readOracleRunConfig().replayProperty for (const [host, property] of [ @@ -46,11 +46,14 @@ for (const [host, property] of [ ) continue it(`keeps missing-row metadata local unless explicitly written (${host}, ${campaign})`, async () => { - const state = (lane: string, name: string) => `${lane}: ${name}` + const state = (productionPathName: string, name: string) => + `${productionPathName}: ${name}` const reach = createOracleReach( property, - lanes.flatMap((lane) => - missingRowMetadataCases.map(({ name }) => state(lane.name, name)), + productionPaths.flatMap((productionPath) => + missingRowMetadataCases.map(({ name }) => + state(productionPath.name, name), + ), ), ) const result = await fc.check( @@ -58,33 +61,33 @@ for (const [host, property] of [ missingRowMetadataHistoriesArbitrary, async (batch) => { const failures: Array = [] - for (const lane of lanes) { + for (const productionPath of productionPaths) { for (const { name, history } of batch) { reach.beginHistory() const historyResult = await runSessionHistory( - lane, + productionPath, history, () => host === `sqlite` - ? createSQLiteDriver(lane) + ? createSQLiteDriver(productionPath) : createPersistedDriver( true, true, false, true, - lane.fullRows, - lane.onDemand, + productionPath.fullRows, + productionPath.onDemand, ), ) if (historyResult.mismatch) { failures.push( - `${state(lane.name, name)}\n${describeMismatch(history, historyResult.mismatch)}`, + `${state(productionPath.name, name)}\n${describeMismatch(history, historyResult.mismatch)}`, ) } else if (historyResult.steps !== history.length) throw new Error( `constructive history skipped an authored action`, ) - else reach.reach(state(lane.name, name)) + else reach.reach(state(productionPath.name, name)) } } if (failures.length) throw new Error(failures.join(`\n\n`)) diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts index 792c40653b..68549dd04e 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts @@ -7,7 +7,7 @@ import { recoverySchedulesArbitrary, runRecoverySchedule, } from '../../db/tests/sync-metadata-recovery-oracle' -import { persistedPeerOnDemandLane } from '../../db/tests/sync-metadata-session-oracle' +import { persistedPeerOnDemandGrammar } from '../../db/tests/sync-metadata-session-oracle' import { oraclePropertyOptions, oracleRuns, @@ -38,7 +38,7 @@ for (const campaign of [`fixed`, `random`] as const) { for (const schedule of batch) { reach.beginHistory() const observation = await runRecoverySchedule(schedule, () => - createSQLiteDriver(persistedPeerOnDemandLane), + createSQLiteDriver(persistedPeerOnDemandGrammar), ) if ( !observation.events.includes(schedule.phase) || diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts index 99509169b9..cacaa1017a 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts @@ -3,9 +3,9 @@ import { completedSeedReinsertReloadRegime, describeMismatch, keptMetadataDurabilityHistoriesArbitrary, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, seedReinsertReloadHistoryArbitrary, } from '../../db/tests/sync-metadata-session-oracle' import { @@ -17,7 +17,7 @@ import { createOracleReach } from '../../db/tests/oracle-reach' import { requireExecutedHistory } from './sync-metadata-campaign-result' import type { SessionAction, - SessionLane, + SessionGrammar, runSessionHistory, } from '../../db/tests/sync-metadata-session-oracle' @@ -36,32 +36,32 @@ export async function runSeedReinsertReloadCampaign({ campaign: `fixed` | `random` runHistory: ( history: ReadonlyArray, - lane: SessionLane, + productionPath: SessionGrammar, ) => ReturnType }): Promise { - const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, + const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] - const states = lanes.flatMap((lane) => [ - `${lane.name}: inner commit`, - `${lane.name}: row`, - `${lane.name}: reload`, + const states = productionPaths.flatMap((productionPath) => [ + `${productionPath.name}: inner commit`, + `${productionPath.name}: row`, + `${productionPath.name}: reload`, ]) - const completedStates = lanes.flatMap((lane) => [ - `${lane.name}: completed kept bare seed reload`, - `${lane.name}: completed kept non-bare seed reload`, + const completedStates = productionPaths.flatMap((productionPath) => [ + `${productionPath.name}: completed kept bare seed reload`, + `${productionPath.name}: completed kept non-bare seed reload`, ]) const reach = createOracleReach(property, states) const completedReach = createOracleReach(property, completedStates) const result = await fc.check( fc.asyncProperty(seedReinsertReloadHistoryArbitrary, async (history) => { const failures: Array = [] - for (const lane of lanes) { + for (const productionPath of productionPaths) { reach.beginHistory() completedReach.beginHistory() - const result = await runHistory(history, lane) + const result = await runHistory(history, productionPath) if (!result.mismatch && result.steps !== history.length) throw new Error(`constructive history skipped an authored action`) const last = history.at(-1)! @@ -69,15 +69,15 @@ export async function runSeedReinsertReloadCampaign({ (action) => action.type === `commit`, ) if (result.steps > innerCommit) - reach.reach(`${lane.name}: inner commit`) + reach.reach(`${productionPath.name}: inner commit`) if (last.type === `peer` && result.steps === history.length) - reach.reach(`${lane.name}: ${last.notice}`) + reach.reach(`${productionPath.name}: ${last.notice}`) const completed = completedSeedReinsertReloadRegime(history, result) if (completed !== undefined) - completedReach.reach(`${lane.name}: ${completed}`) + completedReach.reach(`${productionPath.name}: ${completed}`) if (result.mismatch) failures.push( - `${lane.name}\n${describeMismatch(history, result.mismatch)}`, + `${productionPath.name}\n${describeMismatch(history, result.mismatch)}`, ) } if (failures.length) throw new Error(failures.join(`\n\n`)) @@ -119,33 +119,33 @@ export async function runKeptMetadataDurabilityCampaign({ campaign: `fixed` | `random` runHistory: ( history: ReadonlyArray, - lane: SessionLane, + productionPath: SessionGrammar, ) => ReturnType extraStates?: ReadonlyArray extraReached?: ( - lane: SessionLane, + productionPath: SessionGrammar, result: Awaited>, ) => ReadonlyArray }): Promise { - const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, + const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] const name = ( - lane: SessionLane, + productionPath: SessionGrammar, write: string, bare: boolean, metadata: boolean, notice: string, ) => - `${lane.name}: ${write}: ${bare ? `bare` : `metadata`} seed: ${metadata ? `explicit` : `implicit`}: ${notice}` - const states = lanes.flatMap((lane) => + `${productionPath.name}: ${write}: ${bare ? `bare` : `metadata`} seed: ${metadata ? `explicit` : `implicit`}: ${notice}` + const states = productionPaths.flatMap((productionPath) => [`update`, `reinsert`, `inner-reinsert`].flatMap((write) => [false, true].flatMap((bare) => [false, true].flatMap((metadata) => [`none`, `row`, `reload`].map((notice) => - name(lane, write, bare, metadata, notice), + name(productionPath, write, bare, metadata, notice), ), ), ), @@ -157,21 +157,22 @@ export async function runKeptMetadataDurabilityCampaign({ keptMetadataDurabilityHistoriesArbitrary, async (batch) => { const failures: Array = [] - for (const lane of lanes) { + for (const productionPath of productionPaths) { for (const { write, bare, metadata, notice, history } of batch) { reach.beginHistory() - const result = await runHistory(history, lane) + const result = await runHistory(history, productionPath) if (result.mismatch) failures.push( - `${lane.name}\n${describeMismatch(history, result.mismatch)}`, + `${productionPath.name}\n${describeMismatch(history, result.mismatch)}`, ) else { if (result.steps !== history.length) throw new Error( `constructive history skipped an authored action`, ) - reach.reach(name(lane, write, bare, metadata, notice)) - for (const state of extraReached(lane, result)) reach.reach(state) + reach.reach(name(productionPath, write, bare, metadata, notice)) + for (const state of extraReached(productionPath, result)) + reach.reach(state) } } } diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts index fff5c1ec87..ac8a00e2d1 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts @@ -37,10 +37,10 @@ import type { PersistedTx, PersistenceAdapter, ProtocolEnvelope } from '../src' * Checkpoints: source reads after every action; `collection.base` only at the * final checkpoint, after every hold ends and the wrapper's continuations * drain. The wrapper hands rows to core after its durable step, so consumers - * see them a turn or more later even with no hold. In the peer lanes the + * see them a turn or more later even with no hold. In the peer production paths the * holding adapter's stored rows are compared at the final checkpoint too. * - * Lanes: `persisted-flat`, `persisted-nested`, `persisted-seeds`, + * Production paths: `persisted-flat`, `persisted-nested`, `persisted-seeds`, * `persisted-cached` (row 1 is in storage when the run starts), * `persisted-conflicts` (inner writes that land ahead of enclosing ones), and * `persisted-nested-seeds`, which crosses seeds with nesting and the cached @@ -52,7 +52,7 @@ import type { PersistedTx, PersistenceAdapter, ProtocolEnvelope } from '../src' * `persisted-peers-on-demand`, which loads on demand with a demand for every * row active from the start. Seeds * use a `DbClient` that owns the persisted Collection. Restart and resume - * reads are outside these lanes. + * reads are outside these production paths. * * A peer is a second writer the driver plays. It writes the stored row and * metadata directly, takes the next stream position after this tab's @@ -215,8 +215,8 @@ const drainTurns = async () => { let run = 0 /** - * Drives one persisted Collection through the shared session contract. Lane - * options select hydration seeds, cached rows, storage holds and peer notices; + * Drives one persisted Collection through the shared session contract. Production + * path options select hydration seeds, cached rows, storage holds and peer notices; * source reads and durable observations remain separate comparison inputs. */ export async function createPersistedDriver( diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts index 9d175ec5ec..181b917dd9 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts @@ -3,17 +3,17 @@ import { describe, expect, it } from 'vitest' import { describeMismatch, historyRegimes, - persistedCachedLane, - persistedConflictLane, - persistedFlatLane, - persistedNestedLane, - persistedNestedSeedLane, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, - persistedReadLane, - persistedReadSeedLane, - persistedSeedLane, + persistedCachedGrammar, + persistedConflictGrammar, + persistedFlatGrammar, + persistedNestedGrammar, + persistedNestedSeedGrammar, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, + persistedReadGrammar, + persistedReadSeedGrammar, + persistedSeedGrammar, queuedMetadataRepairHistoriesArbitrary, runSessionHistory, seedReinsertReloadReplay, @@ -35,7 +35,7 @@ import { } from './sync-metadata-session-campaign' import type { SessionAction, - SessionLane, + SessionGrammar, } from '../../db/tests/sync-metadata-session-oracle' /** @@ -56,10 +56,10 @@ import type { * Checkpoints: source reads after every action; `collection.base` only at the * final checkpoint, after every hold ends and the wrapper's continuations * drain. The wrapper hands rows to core after its durable step, so consumers - * see them a turn or more later even with no hold. In the peer lanes the + * see them a turn or more later even with no hold. In the peer production paths the * holding adapter's stored rows are compared at the final checkpoint too. * - * Lanes: `persisted-flat`, `persisted-nested`, `persisted-seeds`, + * Production paths: `persisted-flat`, `persisted-nested`, `persisted-seeds`, * `persisted-cached` (row 1 is in storage when the run starts), * `persisted-conflicts` (inner writes that land ahead of enclosing ones), and * `persisted-nested-seeds`, which crosses seeds with nesting and the cached @@ -71,7 +71,7 @@ import type { * `persisted-peers-on-demand`, which loads on demand with a demand for every * row active from the start. Seeds * use a `DbClient` that owns the persisted Collection. Restart and resume - * reads are outside these lanes. + * reads are outside these production paths. * * A peer is a second writer the driver plays. It writes the stored row and * metadata directly, takes the next stream position after this tab's @@ -87,16 +87,16 @@ import type { async function assertPersistedHistory( history: ReadonlyArray, - lane: SessionLane = persistedFlatLane, + productionPath: SessionGrammar = persistedFlatGrammar, ): Promise { - const { mismatch } = await runSessionHistory(lane, history, () => + const { mismatch } = await runSessionHistory(productionPath, history, () => createPersistedDriver( - lane.seeds, - lane.cached === true, - lane.readHold === true, - lane.peers === true, - lane.fullRows === true, - lane.onDemand === true, + productionPath.seeds, + productionPath.cached === true, + productionPath.readHold === true, + productionPath.peers === true, + productionPath.fullRows === true, + productionPath.onDemand === true, ), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) @@ -152,7 +152,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `begin` }, { type: `reinsert`, key: 1, metadata: false }, ], - persistedSeedLane, + persistedSeedGrammar, ) }) @@ -169,7 +169,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `commit` }, { type: `seed`, key: 1, bare: true }, ], - persistedSeedLane, + persistedSeedGrammar, ) }) @@ -186,7 +186,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `commit` }, { type: `seed`, key: 2, bare: true }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -199,7 +199,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: false }, { type: `seed`, key: 1, bare: true }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -217,7 +217,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: true }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -237,7 +237,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: true }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -255,7 +255,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `seed`, key: 1 }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -273,7 +273,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: false }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -282,7 +282,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { it(`reads an enclosing transaction's write inside a nested transaction`, async () => { await assertPersistedHistory( [{ type: `begin` }, { type: `rowSet`, key: 1 }, { type: `begin` }], - persistedNestedLane, + persistedNestedGrammar, ) }) @@ -301,7 +301,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `reinsert`, key: 1, metadata: false }, { type: `commit` }, ], - persistedConflictLane, + persistedConflictGrammar, ) }) @@ -318,13 +318,13 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { it(`reaches held and open-over-held reads in the fixed campaign`, () => { const counts = new Map() for (const history of fc.sample( - sessionHistoryArbitrary(persistedFlatLane), + sessionHistoryArbitrary(persistedFlatGrammar), { numRuns: oracleRuns(50), seed: fixedSeed, }, )) - for (const regime of historyRegimes(persistedFlatLane, history)) + for (const regime of historyRegimes(persistedFlatGrammar, history)) counts.set(regime, (counts.get(regime) ?? 0) + 1) for (const regime of [ `read with two held transactions`, @@ -336,7 +336,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { }) if (requestedReplayProperty === undefined) { - fcTest.prop([sessionHistoryArbitrary(persistedFlatLane)], { + fcTest.prop([sessionHistoryArbitrary(persistedFlatGrammar)], { numRuns: oracleRuns(50), seed: fixedSeed, })( @@ -345,7 +345,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { 120_000, ) fcTest.prop( - [sessionHistoryArbitrary(persistedFlatLane)], + [sessionHistoryArbitrary(persistedFlatGrammar)], oraclePropertyOptions(50, property), )( `keeps the session law on flat histories (random)`, @@ -354,7 +354,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { ) } else if (requestedReplayProperty === property) { fcTest.prop( - [sessionHistoryArbitrary(persistedFlatLane)], + [sessionHistoryArbitrary(persistedFlatGrammar)], oraclePropertyOptions(50, property), )( `replays persisted flat session histories`, @@ -365,33 +365,33 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { }) /** - * Each lane's fixed campaign and the states it must reach (ORC-015). The - * conflict lane runs more histories: an insert over a different row needs an + * Each production path's fixed campaign and the states it must reach (ORC-015). The + * conflict production path runs more histories: an insert over a different row needs an * outer insert, an inner write to the same row, and the inner commit. */ -const extraLanes: Array<{ - lane: SessionLane +const extraProductionPaths: Array<{ + productionPath: SessionGrammar property: string seed: number runs: number reach: Array }> = [ { - lane: persistedNestedLane, + productionPath: persistedNestedGrammar, property: `sync-metadata-session.persisted-nested`, seed: 2_220_012, runs: 50, reach: [`nested commit`, `nested commit during a hold`, `store hold`], }, { - lane: persistedSeedLane, + productionPath: persistedSeedGrammar, property: `sync-metadata-session.persisted-seeds`, seed: 2_220_013, runs: 50, reach: [`seed`, `store hold`], }, { - lane: persistedCachedLane, + productionPath: persistedCachedGrammar, property: `sync-metadata-session.persisted-cached`, seed: 2_220_015, runs: 80, @@ -402,7 +402,7 @@ const extraLanes: Array<{ ], }, { - lane: persistedConflictLane, + productionPath: persistedConflictGrammar, property: `sync-metadata-session.persisted-conflicts`, seed: 2_220_014, runs: 150, @@ -416,7 +416,7 @@ const extraLanes: Array<{ // Writes while a storage read is in flight follow the same laws: the // wrapper buffers their transactions until the read ends. { - lane: persistedReadLane, + productionPath: persistedReadGrammar, property: `sync-metadata-session.persisted-reads`, seed: 2_220_017, runs: 150, @@ -430,9 +430,9 @@ const extraLanes: Array<{ // A transaction committed during a read and abandoned before the wrapper // hands it to core never wrote its rows, so a seed beneath it still holds // them (decision 21). Abandoning a seeded row's writer is rare here; a - // replay pins it. + // fixed history covers it. { - lane: persistedReadSeedLane, + productionPath: persistedReadSeedGrammar, property: `sync-metadata-session.persisted-reads-seeds`, seed: 2_220_021, runs: 150, @@ -443,7 +443,7 @@ const extraLanes: Array<{ // seeds (decision 11); a full reload keeps a held seed and makes every // changed stored row a source write (decisions 13 and 15). { - lane: persistedPeerLane, + productionPath: persistedPeerGrammar, property: `sync-metadata-session.persisted-peers`, seed: 2_220_018, runs: 150, @@ -458,10 +458,10 @@ const extraLanes: Array<{ `peer write during a hold`, ], }, - // The peer lane under full row updates: an update replaces its row, so + // The peer production path under full row updates: an update replaces its row, so // storage must not keep a seed's fields beneath it (decision 17). { - lane: persistedPeerFullLane, + productionPath: persistedPeerFullGrammar, property: `sync-metadata-session.persisted-peers-full`, seed: 2_220_019, runs: 150, @@ -477,11 +477,11 @@ const extraLanes: Array<{ `accepted update drops a field`, ], }, - // The peer lane on demand: a reset is a source truncate that keeps the + // The peer production path on demand: a reset is a source truncate that keeps the // seeds this tab holds (decisions 19 and 20). Metadata of a missing row is - // rare here; a replay pins a reset over it. + // rare here; a fixed history covers a reset over it. { - lane: persistedPeerOnDemandLane, + productionPath: persistedPeerOnDemandGrammar, property: `sync-metadata-session.persisted-peers-on-demand`, seed: 2_220_020, runs: 150, @@ -500,7 +500,7 @@ const extraLanes: Array<{ // re-insert kept, here also when the re-insert committed ahead of the // enclosing transaction whose row it re-inserted. { - lane: persistedNestedSeedLane, + productionPath: persistedNestedSeedGrammar, property: `sync-metadata-session.persisted-nested-seeds`, seed: 2_220_016, runs: 300, @@ -515,7 +515,7 @@ const extraLanes: Array<{ ] /** - * Shrunk peer-lane counterexamples, replayed through the same model, driver, + * Shrunk counterexamples from the peer production paths, replayed through the same model, driver, * and comparison as the campaigns. Each reached a defect that the fixed and * random campaigns rarely generate: a held seed that a reload must keep, a * row that held transactions wrote, metadata of a missing row, a write over a @@ -772,25 +772,26 @@ const readSeedReplays: Array<[string, Array]> = [ describe(`sync metadata session oracle through the persisted wrapper: peer replays`, () => { it.each(peerReplays)(`replays: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerLane), + assertPersistedHistory(history, persistedPeerGrammar), ) it.each(peerFullReplays)(`replays under full rows: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerFullLane), + assertPersistedHistory(history, persistedPeerFullGrammar), ) - // Pinned from the persisted-peers-full random campaign (seed 654774679): + // Fixed history from the persisted-peers-full random campaign (seed 654774679): // an outer transaction inserts over a bare seed and an inner equal re-insert // commits first. Its implicit metadata keep writes no metadata to storage; // the shared model judges source reads and the later unknown-author reload // separately under D9, D15 and D18. This uses the campaigns' model, driver, // and comparison. it(`replays under full rows: keeps source metadata reads consistent across an inner re-insert and reload`, () => - assertPersistedHistory(seedReinsertReloadReplay, persistedPeerFullLane)) + assertPersistedHistory(seedReinsertReloadReplay, persistedPeerFullGrammar)) it.each(peerOnDemandReplays)(`replays on demand: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerOnDemandLane), + assertPersistedHistory(history, persistedPeerOnDemandGrammar), ) it.each(readSeedReplays)( `replays with reads and seeds: %s`, - (_name, history) => assertPersistedHistory(history, persistedReadSeedLane), + (_name, history) => + assertPersistedHistory(history, persistedReadSeedGrammar), ) }) @@ -808,15 +809,15 @@ for (const campaign of [`fixed`, `random`] as const) { await runSeedReinsertReloadCampaign({ property: seedReloadProperty, campaign, - runHistory: (history, lane) => - runSessionHistory(lane, history, () => + runHistory: (history, productionPath) => + runSessionHistory(productionPath, history, () => createPersistedDriver( true, true, false, true, - lane.fullRows === true, - lane.onDemand === true, + productionPath.fullRows === true, + productionPath.onDemand === true, ), ), }) @@ -825,52 +826,60 @@ for (const campaign of [`fixed`, `random`] as const) { const describeNested = oracleReplayTest( describe, - ...extraLanes.map(({ property }) => property), + ...extraProductionPaths.map(({ property }) => property), ) describeNested( `sync metadata session oracle through the persisted wrapper: nesting and seeds`, () => { for (const { - lane, - property: lanePropertyName, + productionPath, + property: productionPathPropertyName, seed, runs, reach: states, - } of extraLanes) { - const runLane = (history: Array) => - assertPersistedHistory(history, lane) + } of extraProductionPaths) { + const runProductionPath = (history: Array) => + assertPersistedHistory(history, productionPath) if (requestedReplayProperty === undefined) { - const reach = createOracleReach(`${lanePropertyName} (fixed)`, states, { - minimum: 5, - }) - fcTest.prop([sessionHistoryArbitrary(lane)], { + const reach = createOracleReach( + `${productionPathPropertyName} (fixed)`, + states, + { + minimum: 5, + }, + ) + fcTest.prop([sessionHistoryArbitrary(productionPath)], { numRuns: oracleRuns(runs), seed, })( - `keeps the session law on ${lane.name} histories (fixed)`, + `keeps the session law on ${productionPath.name} histories (fixed)`, (history) => { reach.beginHistory() - for (const regime of historyRegimes(lane, history)) + for (const regime of historyRegimes(productionPath, history)) if (states.includes(regime)) reach.reach(regime) - return runLane(history) + return runProductionPath(history) }, 240_000, ) - it(`reaches the declared states in the ${lane.name} fixed campaign`, () => + it(`reaches the declared states in the ${productionPath.name} fixed campaign`, () => reach.assertReached()) fcTest.prop( - [sessionHistoryArbitrary(lane)], - oraclePropertyOptions(50, lanePropertyName), + [sessionHistoryArbitrary(productionPath)], + oraclePropertyOptions(50, productionPathPropertyName), )( - `keeps the session law on ${lane.name} histories (random)`, - runLane, + `keeps the session law on ${productionPath.name} histories (random)`, + runProductionPath, 120_000, ) - } else if (requestedReplayProperty === lanePropertyName) { + } else if (requestedReplayProperty === productionPathPropertyName) { fcTest.prop( - [sessionHistoryArbitrary(lane)], - oraclePropertyOptions(50, lanePropertyName), - )(`replays ${lane.name} session histories`, runLane, 120_000) + [sessionHistoryArbitrary(productionPath)], + oraclePropertyOptions(50, productionPathPropertyName), + )( + `replays ${productionPath.name} session histories`, + runProductionPath, + 120_000, + ) } } }, @@ -891,15 +900,15 @@ for (const campaign of [`fixed`, `random`] as const) { await runKeptMetadataDurabilityCampaign({ property: durabilityProperty, campaign, - runHistory: (history, lane) => - runSessionHistory(lane, history, () => + runHistory: (history, productionPath) => + runSessionHistory(productionPath, history, () => createPersistedDriver( true, true, false, true, - lane.fullRows === true, - lane.onDemand === true, + productionPath.fullRows === true, + productionPath.onDemand === true, ), ), }) @@ -929,7 +938,7 @@ for (const campaign of [`fixed`, `random`] as const) { for (const { bare, held, history } of batch) { reach.beginHistory() const result = await runSessionHistory( - persistedPeerLane, + persistedPeerGrammar, history, () => createPersistedDriver(true, true, false, true), ) diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts index d01def4bfa..2bb9a2d948 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts @@ -44,7 +44,7 @@ import type { import type { SyncConfig } from '@tanstack/db' import type { ReceiptState, - SessionLane, + SessionGrammar, } from '../../db/tests/sync-metadata-session-oracle' type Row = { id: number; v: unknown } @@ -57,7 +57,7 @@ let run = 0 * before comparison; returned recovery inputs contain no expected metadata. */ export async function createSQLiteDriver( - lane: SessionLane, + productionPath: SessionGrammar, ): Promise { const database = new DatabaseSync(`:memory:`) const { driver, drained } = createMetadataSQLiteDriver(database) @@ -69,7 +69,7 @@ export async function createSQLiteDriver( new SQLiteCorePersistenceAdapter({ driver }), ] const id = `metadata-items` - const seedClaim = lane.onDemand + const seedClaim = productionPath.onDemand ? await adapters[0]!.claimCacheGeneration(id) : undefined const target = seedClaim @@ -258,15 +258,19 @@ export async function createSQLiteDriver( persistedCollectionOptions({ id, getKey: (row) => row.id, - ...(lane.onDemand ? { syncMode: `on-demand` as const } : {}), + ...(productionPath.onDemand + ? { syncMode: `on-demand` as const } + : {}), sync: { - rowUpdateMode: lane.fullRows ? `full` : `partial`, + rowUpdateMode: productionPath.fullRows ? `full` : `partial`, sync: (sync) => { actions[index] = sync sync.begin() sync.commit() sync.markReady() - return lane.onDemand ? { loadSubset: () => true } : undefined + return productionPath.onDemand + ? { loadSubset: () => true } + : undefined }, }, onInsert: () => @@ -284,10 +288,10 @@ export async function createSQLiteDriver( // These direct source histories have no live query to start on-demand sync. // Start both sources before a follower can route its first demand to its peer. for (const current of collections) current.startSyncImmediate() - if (!lane.onDemand) + if (!productionPath.onDemand) await Promise.all(collections.map((collection) => collection.preload())) const demands = collections.map(() => ({})) - if (lane.onDemand) + if (productionPath.onDemand) await Promise.all( collections.map((collection, index) => collection._sync.loadSubset(demands[index]!), @@ -487,7 +491,7 @@ export async function createSQLiteDriver( `the real coordinator did not deliver the ${notice} notice`, ) console.info( - `SQLITE_METADATA_NOTICE ${JSON.stringify({ lane: lane.name, notice, received: notices.length - before })}`, + `SQLITE_METADATA_NOTICE ${JSON.stringify({ lane: productionPath.name, notice, received: notices.length - before })}`, ) }, observe: () => ({ @@ -539,7 +543,7 @@ export async function createSQLiteDriver( await release() while (depth) abort() await Promise.all(receipts) - if (lane.onDemand) + if (productionPath.onDemand) for (const [index, current] of collections.entries()) current._sync.unloadSubset(demands[index]!) await quiesce() diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts index 9cc53b0c75..42e8e570f9 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts @@ -2,9 +2,9 @@ import { it } from 'vitest' import { describeMismatch, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, runSessionHistory, seedReinsertReloadReplay, } from '../../db/tests/sync-metadata-session-oracle' @@ -19,21 +19,26 @@ import { import { createSQLiteDriver } from './sync-metadata-session-sqlite-driver' import type { SessionAction, - SessionLane, + SessionGrammar, } from '../../db/tests/sync-metadata-session-oracle' -const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, +const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] -async function check(history: ReadonlyArray, lane: SessionLane) { - return runSessionHistory(lane, history, () => createSQLiteDriver(lane)) +async function check( + history: ReadonlyArray, + productionPath: SessionGrammar, +) { + return runSessionHistory(productionPath, history, () => + createSQLiteDriver(productionPath), + ) } -oracleReplayTest(it).each(lanes)( +oracleReplayTest(it).each(productionPaths)( `receives the seed/re-insert/reload replay through SQLite ($name)`, - async (lane) => { - const result = await check(seedReinsertReloadReplay, lane) + async (productionPath) => { + const result = await check(seedReinsertReloadReplay, productionPath) if (result.mismatch) throw new Error( describeMismatch(seedReinsertReloadReplay, result.mismatch), @@ -78,8 +83,8 @@ for (const campaign of [`fixed`, `random`] as const) { campaign, runHistory: check, extraStates: [recoveryState], - extraReached: (lane, result) => - lane.onDemand && + extraReached: (productionPath, result) => + productionPath.onDemand && result.recoveryInputs.some( (input) => input.fromRowVersion === 0 && input.latestRowVersion >= 2, ) diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts index 55b6cef0d6..ff937f170c 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts @@ -4,9 +4,9 @@ import { fc } from '@fast-check/vitest' import { keptMetadataDurabilityHistoriesArbitrary, modelCheckpoints, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, queuedMetadataRepairHistoriesArbitrary, } from '../../db/tests/sync-metadata-session-oracle' import { persistedCollectionOptions } from '../src' @@ -46,9 +46,9 @@ it(`constructs every declared metadata premise as a legal history`, async () => history.filter((action) => action.type === `commit`), ).toHaveLength(2) expect(history.some((action) => action.type === `rowSet`)).toBe(true) - expect((await modelCheckpoints(persistedPeerLane, history)).size).toBe( - history.length, - ) + expect( + (await modelCheckpoints(persistedPeerGrammar, history)).size, + ).toBe(history.length) } } for (const batch of fc.sample(keptMetadataDurabilityHistoriesArbitrary, { @@ -87,12 +87,12 @@ it(`constructs every declared metadata premise as a legal history`, async () => ) const depth = prefix.filter((action) => action.type === `begin`).length expect(depth).toBe(write === `inner-reinsert` ? 2 : 1) - for (const lane of [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, + for (const productionPath of [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ]) - expect((await modelCheckpoints(lane, history)).size).toBe( + expect((await modelCheckpoints(productionPath, history)).size).toBe( history.length, ) } diff --git a/packages/db/package-tests/indexed-db/packed-consumer.test.ts b/packages/db/package-tests/indexed-db/packed-consumer.test.ts index d6377eaae8..c4bc000277 100644 --- a/packages/db/package-tests/indexed-db/packed-consumer.test.ts +++ b/packages/db/package-tests/indexed-db/packed-consumer.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node /** Published runtime smoke tests supplement portable-declarations.test.ts. * Actual tarballs and Node's ESM/CJS loaders judge export paths; a persisted - * row survives cleanup and a new descriptor. This fixed integration lane does + * row survives cleanup and a new descriptor. This fixed integration path does * not model Collection histories or claim every package-manager configuration. * e2e/indexed-db/packed-consumer.spec.ts runs the same consumer through a browser bundler. */ diff --git a/packages/db/package-tests/indexed-db/packed-consumer.ts b/packages/db/package-tests/indexed-db/packed-consumer.ts index c5da2955fd..6a427cd25b 100644 --- a/packages/db/package-tests/indexed-db/packed-consumer.ts +++ b/packages/db/package-tests/indexed-db/packed-consumer.ts @@ -1,7 +1,7 @@ /** Packaging mechanics only. Pack the candidate and its installed dependency * closure, then install those tarballs in a disposable consumer. File overrides - * keep this lane offline and deterministic; they never point at workspace source - * or dist directories. No build runs here: the package/CI lane builds first. + * keep this path offline and deterministic; they never point at workspace source + * or dist directories. No build runs here: the package/CI job builds first. */ import { execFileSync } from 'node:child_process' import { diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index 7d8f976339..0c7e6d01b9 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -409,7 +409,7 @@ export function processGroupBy( // D2 consolidates contributions that hash equal, and the hash treats -0 as 0 // and equal Dates as one value. Today it keeps the instance it was last given; - // db-ivm's groupBy tests pin that as current behavior, not as a promise. A min + // db-ivm's groupBy tests assert that as current behavior, not as a promise. A min // or max returns one of its inputs, so each contribution carries the exact key // of the value it compares, as a group value does. A primitive's key is its // exact value, so equal primitives still consolidate. An object's key holds its diff --git a/packages/db/tests/SortedMap.test.ts b/packages/db/tests/SortedMap.test.ts index 0f434c0ab1..be6b9041ff 100644 --- a/packages/db/tests/SortedMap.test.ts +++ b/packages/db/tests/SortedMap.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for SortedMap: ordering edge cases, bulk-sync movement bounds, and - * key iteration details that pin hand-written results. The law that every + * key iteration details that fix hand-written results. The law that every * ordering observation must refine a full recomputation is owned by * SortedMap-oracle.test.ts. */ @@ -14,7 +14,7 @@ import type { SyncConfig } from '../src/types.js' import type { ModelKey, Row } from './sorted-map-support' // Supplemental examples for SortedMap. The generated law lives in -// SortedMap-oracle.test.ts; these pin scale, scan reuse, and public +// SortedMap-oracle.test.ts; these check scale, scan reuse, and public // Collection sync paths that its small histories do not reach. async function countDisplacedSlots( diff --git a/packages/db/tests/btree-map-oracle.test.ts b/packages/db/tests/btree-map-oracle.test.ts index ad38d52b61..94d92c1ac9 100644 --- a/packages/db/tests/btree-map-oracle.test.ts +++ b/packages/db/tests/btree-map-oracle.test.ts @@ -285,7 +285,7 @@ declareOracle({ check: [expectRefinement], model: [stepModel] }) describe(`BTree Map oracle`, () => { // The 120,000-operation corpus takes about nine seconds with CI coverage. - // This fixed lane is a stable depth campaign, not random discovery. Keep + // This fixed-seed campaign is a stable depth campaign, not random discovery. Keep // every operation and assertion. This is not a five-second performance budget. it( `matches a Map oracle under random insert/delete/overwrite with small nodes`, diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index 61fe073a5f..7ca84da311 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -49,7 +49,7 @@ import type { ChangeMessage, SyncConfig } from '../src/types' * through a local-only Collection, either in one same-turn batch or * sequentially. After all batched writes settle, or after each sequential * write settles, public rows and a change-message mirror must equal the - * reference rows. An eager-index lane also builds an index before the history + * reference rows. An eager-index grammar also builds an index before the history * and compares both equality buckets after each settled prefix in sequential * histories. In batched histories it compares them after each synchronous * operation before settlement, then once after all writes settle. At the diff --git a/packages/db/tests/cleanup-queue-oracle.property.test.ts b/packages/db/tests/cleanup-queue-oracle.property.test.ts index 834280bf1d..4b383257cf 100644 --- a/packages/db/tests/cleanup-queue-oracle.property.test.ts +++ b/packages/db/tests/cleanup-queue-oracle.property.test.ts @@ -247,7 +247,7 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( // grammar; a negative elapsed advance is invalid for its monotonic elapsed // clock. The fixed seed and seedless campaigns use the same property and run // budget. The shared oracle config -// accepts a seed and shrink path for direct replay of the seedless lane. +// accepts a seed and shrink path for direct replay of the seedless grammar. it.each([20260913, undefined])( `obeys appointment histories (seed %s)`, async (seed) => { diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index 9dfd145f60..13a9cba3e9 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -54,7 +54,7 @@ import type { Row } from './collection-cleanup-restart-support.js' * Errored eager and on-demand source histories clean up the dependent first, * then restart both Collections through live-query preload. They check the * same live-query object and subscriber after a later source update. These - * pinned paths sit outside the three-cut model; they do not establish + * fixed histories sit outside the three-cut model; they do not establish * automatic recovery after source-only restart. * If adapter cleanup and local teardown both fail, the aggregate keeps the * adapter error primary and every local error as an ordered secondary @@ -92,7 +92,7 @@ import type { Row } from './collection-cleanup-restart-support.js' * points. Refinement checks run before releasing the controlled adapter gate * and after the cleanup promise settles. Counts, exact errors, status, rows, * subscription and observer ownership, and settlement are observed. The alias - * and late-registration cases are pinned refinements outside the three-cut + * and late-registration cases are fixed-history refinements outside the three-cut * model. This suite does not establish full demand/replay histories, transport * shutdown, persistence-wrapper behavior, or general row-publication laws. */ diff --git a/packages/db/tests/collection-gc-mutation-oracle.property.test.ts b/packages/db/tests/collection-gc-mutation-oracle.property.test.ts index 6e36107659..5b83309038 100644 --- a/packages/db/tests/collection-gc-mutation-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-mutation-oracle.property.test.ts @@ -354,7 +354,7 @@ const historyArbitrary = fc.record({ sourceValue: fc.integer({ min: -3, max: 3 }), }) it.each( - runConfig.replaySeed === undefined ? [20261026, undefined] : [undefined], + runConfig.campaignSeed === undefined ? [20261026, undefined] : [undefined], )( `retains every touched Collection across mutation shapes (seed %s)`, async (seed) => { diff --git a/packages/db/tests/collection-gc-retention-oracle.property.test.ts b/packages/db/tests/collection-gc-retention-oracle.property.test.ts index d227564d55..6713fc29d7 100644 --- a/packages/db/tests/collection-gc-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-retention-oracle.property.test.ts @@ -496,7 +496,7 @@ const boundaryParameters = fc.record({ ), siblingPending: fc.boolean(), }) -const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( +const boundaryGrammarHistoryArbitrary: fc.Arbitrary = fc.oneof( ...[ `startup`, `unsubscribe`, @@ -513,7 +513,7 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( `async-cleanup-held`, `async-cleanup-retired`, `async-cleanup-single`, - ].map((lane) => + ].map((grammar) => boundaryParameters.map((parameters): BoundaryHistory => { const { gcTime, @@ -531,15 +531,15 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( siblingPending, } = parameters const actions: Array = [] - const asyncCleanup = lane.startsWith(`async-cleanup`) - // The two cleanup timing lanes always preserve a sibling. A separate - // single-transaction lane keeps the nearby no-survivor boundary. + const asyncCleanup = grammar.startsWith(`async-cleanup`) + // The two cleanup timing grammars always preserve a sibling. A separate + // single-transaction grammar keeps the nearby no-survivor boundary. const hasSibling = asyncCleanup - ? lane !== `async-cleanup-single` + ? grammar !== `async-cleanup-single` : sibling - const settleAfterCleanup = lane === `async-cleanup-retired` - const failedPreload = lane.startsWith(`failed-preload`) - const preloadLast = lane === `failed-preload-last` + const settleAfterCleanup = grammar === `async-cleanup-retired` + const failedPreload = grammar.startsWith(`failed-preload`) + const preloadLast = grammar === `failed-preload-last` const settleTransaction = (slot: number, result = outcome): GcAction => result === `rollback` ? { kind: `rollback`, slot } @@ -554,22 +554,22 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( actions.push({ kind: `preload` }) } } - if (lane === `warm-preload`) + if (grammar === `warm-preload`) actions.push( { kind: `advance`, elapsedMs: Math.max(gcTime, 50) }, { kind: `preload` }, ) - if (lane === `unsubscribe`) actions.push({ kind: `subscribe` }) - if (lane === `idle`) { + if (grammar === `unsubscribe`) actions.push({ kind: `subscribe` }) + if (grammar === `idle`) { actions.push({ kind: `advance`, elapsedMs: Math.max(gcTime, 50) + 1 }) for (let i = 0; i < noTimePeriods; i++) actions.push({ kind: `idle`, hasTime: false, didTimeout: false }) } actions.push({ kind: `insert`, - commit: !lane.startsWith(`pending`) && lane !== `delayed-commit`, + commit: !grammar.startsWith(`pending`) && grammar !== `delayed-commit`, }) - if (lane === `pending-subscribed`) { + if (grammar === `pending-subscribed`) { actions.push( { kind: `subscribe` }, { kind: `advance`, elapsedMs: Math.max(gcTime, 50) + age + 1 }, @@ -580,23 +580,23 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( { kind: `advance`, elapsedMs: 1 }, { kind: `idle`, hasTime: true, didTimeout: false }, ) - } else if (lane === `pending` || lane === `pending-alone`) { + } else if (grammar === `pending` || grammar === `pending-alone`) { actions.push({ kind: `advance`, elapsedMs: Math.max(gcTime, 50) - 1 }) - if (lane === `pending`) actions.push({ kind: `rollback`, slot: 0 }) + if (grammar === `pending`) actions.push({ kind: `rollback`, slot: 0 }) actions.push({ kind: `advance`, elapsedMs: 2 }) } else { - if (lane === `delayed-commit`) { + if (grammar === `delayed-commit`) { actions.push( { kind: `advance`, elapsedMs: age }, { kind: `commit`, slot: 0 }, ) } - if (lane === `preload` || failedPreload) preload() + if (grammar === `preload` || failedPreload) preload() if (hasSibling) actions.push({ kind: `insert`, commit: !pendingSibling }) - if (lane === `unsubscribe`) actions.push({ kind: `unsubscribe` }) + if (grammar === `unsubscribe`) actions.push({ kind: `unsubscribe` }) actions.push({ kind: `advance`, elapsedMs: age }) - if (lane === `warm-preload`) preload() + if (grammar === `warm-preload`) preload() if (asyncCleanup) actions.push({ kind: `cleanup-start` }) if (asyncCleanup && settleAfterCleanup) actions.push({ kind: `cleanup-settle` }) @@ -611,7 +611,7 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( ? { kind: `rollback`, slot: lastSlot } : settleTransaction(lastSlot, siblingOutcome), ) - if (lane === `preload`) actions.push({ kind: `ready` }) + if (grammar === `preload`) actions.push({ kind: `ready` }) if (failedPreload && preloadLast) actions.push({ kind: `preload-failure` }) if (asyncCleanup) @@ -625,15 +625,15 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( } actions.push({ kind: `idle`, hasTime: true, didTimeout: timeout }) // Test just below the startup floor and after every finite deadline. - if (lane === `startup` && age < 49) + if (grammar === `startup` && age < 49) actions.push( { kind: `advance`, elapsedMs: 49 - age }, { kind: `idle`, hasTime: true, didTimeout: false }, ) // Derive boundary cuts from the independent law, not production timers. const reference = createGcModel( - lane === `disabled` ? disabledTime : gcTime, - lane !== `preload` && !failedPreload, + grammar === `disabled` ? disabledTime : gcTime, + grammar !== `preload` && !failedPreload, ) for (const action of actions) if (gcActionLegal(reference, action)) stepGcModel(reference, action) @@ -652,9 +652,9 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( { kind: `cleanup` }, ) return { - gcTime: lane === `disabled` ? disabledTime : gcTime, - direct: direct && lane === `startup`, - ready: lane !== `preload` && !failedPreload, + gcTime: grammar === `disabled` ? disabledTime : gcTime, + direct: direct && grammar === `startup`, + ready: grammar !== `preload` && !failedPreload, asyncCleanup, actions, } @@ -666,7 +666,7 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( // The prefix establishes subscriber multiplicity, three persisting siblings, // a delayed commit, non-FIFO settlement and a warm preload between releases. // Generated suffixes then recombine those owners instead of only varying a -// predefined lane's parameters. A maximum of seven transaction slots and three +// predefined grammar's parameters. A maximum of seven transaction slots and three // subscribers keeps shrinking and exact-key observations small. Every emitted // action is legal; the production driver rejects a skipped interaction action. // Removing this arbitrary loses those crossings. Removing its suffix preserves @@ -846,13 +846,13 @@ const automaticCleanupHistoryArbitrary: fc.Arbitrary = fc }) const boundaryHistoryArbitrary: fc.Arbitrary = fc.oneof( - { arbitrary: boundaryLaneHistoryArbitrary, weight: 5 }, + { arbitrary: boundaryGrammarHistoryArbitrary, weight: 5 }, { arbitrary: interactionHistoryArbitrary, weight: 1 }, { arbitrary: automaticCleanupHistoryArbitrary, weight: 1 }, ) it.each( - runConfig.replaySeed === undefined ? [20261026, undefined] : [undefined], + runConfig.campaignSeed === undefined ? [20261026, undefined] : [undefined], )( `preserves retention across transaction phases and idle delivery (seed %s)`, async (seed) => { diff --git a/packages/db/tests/collection-gc-transaction-oracle.property.test.ts b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts index 3d5d61a43e..80cf5bb16a 100644 --- a/packages/db/tests/collection-gc-transaction-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts @@ -401,7 +401,7 @@ const property = fc.asyncProperty( // Replay one history with TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, // and TANSTACK_DB_ORACLE_PROPERTY=collection-lifecycle.gc-persisting-transaction. it.each( - runConfig.replaySeed === undefined ? [20261024, undefined] : [undefined], + runConfig.campaignSeed === undefined ? [20261024, undefined] : [undefined], )( `retires a Collection only after gcTime with no subscriber and no persisting transaction (seed %s)`, async (seed) => { diff --git a/packages/db/tests/collection-row-metadata-composition-oracle.test.ts b/packages/db/tests/collection-row-metadata-composition-oracle.test.ts index 80356d763c..b42b33077e 100644 --- a/packages/db/tests/collection-row-metadata-composition-oracle.test.ts +++ b/packages/db/tests/collection-row-metadata-composition-oracle.test.ts @@ -11,7 +11,7 @@ import type { SyncConfig } from '../src/types.js' * through `metadata.row.set` and `metadata.row.delete`, through the `metadata` * field of a row message, and through `truncate`. Writes that carry metadata * replace earlier values, so the last such write wins. `collection.test.ts` - * pins that rule. A maintainer decision on 2026-10-05 (recorded in + * covers that rule with fixed examples. A maintainer decision on 2026-10-05 (recorded in * `docs/contributing/oracle-reviews/2026-10-05-state-mutation-round-3.md`) * fixes the writes that carry none: * @@ -26,7 +26,7 @@ import type { SyncConfig } from '../src/types.js' * absent row. * * This oracle covers one key, one transaction of up to three writes, and four - * lanes: immediate, held, rebuilt, and nested. The nested lane applies a + * variants: immediate, held, rebuilt, and nested. The nested variant applies a * nested transaction or a hydration seed before the open transaction. When a * rebuild changes an insert into an equal re-insert, or the reverse, the rule * follows the write that applies. The model judges the reclassified sequence. @@ -47,11 +47,11 @@ import type { SyncConfig } from '../src/types.js' * `collection.base` is a different observation: it is the applied view and * excludes queued writes. * - * The held-window lane checks this rule for `metadata.row.get`, + * The held-window variant checks this rule for `metadata.row.get`, * `metadata.collection.get`, and `metadata.collection.list`. The history's * explicit `set` and `unset` writes also write one collection key. A later * accepted transaction writes another key. A read sees both before and after - * the hold ends. The lane does not judge a read between writes of one open + * the hold ends. The variant does not judge a read between writes of one open * transaction, nested transactions, or wrapper-deferred transactions. */ @@ -66,7 +66,7 @@ type Write = metadata: boolean } type Start = `absent` | `present-with-metadata` | `present-without-metadata` -type Lane = `immediate` | `held` | `rebuilt` +type ProductionPath = `immediate` | `held` | `rebuilt` const writes: ReadonlyArray = [ { kind: `set` }, @@ -152,11 +152,11 @@ const histories = starts.flatMap((start) => { }) /** - * The rebuilt lane also varies the earlier held transaction. It either + * The rebuilt variant also varies the earlier held transaction. It either * updates key 2, or writes key 1 without metadata: an update or a delete when * key 1 is present, an insert when it is absent. A key-1 write changes the * projection that the open transaction rebuilds against, so its sequences are - * legal after that write. Sequences stay at two writes to bound the lane. + * legal after that write. Sequences stay at two writes to bound the variant. */ type EarlierWrite = { key: 1 | 2; kind: `insert` | `update` | `delete` } const shortSequences = writes.flatMap((first) => [ @@ -190,7 +190,7 @@ const rebuiltHistories = starts.flatMap((start) => { }) /** - * The nested lane opens a transaction, T1, and writes up to three writes to + * The nested variant opens a transaction, T1, and writes up to three writes to * key 1. Then a nested write applies before T1 commits: * * - `delete`: a transaction begun inside T1 deletes the present row. @@ -208,7 +208,7 @@ const rebuiltHistories = starts.flatMap((start) => { * T1 applies after the nested write, so the Collection reclassifies each of * T1's inserts against the rows that the nested write leaves. An insert of a * present key is an equal re-insert, and an insert of an absent key is an - * insert. Every row write in this lane writes one value, so a reclassified + * insert. Every row write in this variant writes one value, so a reclassified * insert is never a duplicate. The `held` variant holds both transactions * behind a persisting optimistic update until T1 commits; a hold delays * application but cannot change commit order, so it expects the same value. @@ -326,7 +326,8 @@ const describeWrite = (write: Write) => /** * The driver writes the history through a real Collection's sync API and reads * the result through `metadata.row.get` after the transaction applies. Each - * lane reaches a different production path for the same rule: + * production path takes a different route through the Collection for the same + * rule: * * - `immediate`: the transaction applies at commit. * - `held`: a persisting optimistic request holds the transaction until it @@ -335,10 +336,10 @@ const describeWrite = (write: Write) => * open, so the Collection rebuilds this transaction's automatic metadata * writes before it commits. */ -// The model predicts a lane's row metadata. A write before the history (the +// The model predicts a production path's row metadata. A write before the history (the // earlier write) applies first when it names the same key, and the history // then folds over that result. -function expectedLaneMetadata( +function expectedProductionPathMetadata( start: Start, sequence: ReadonlyArray, earlier: EarlierWrite | undefined, @@ -367,14 +368,14 @@ declareOracle({ model: [ expectedMetadata, foldMetadata, - expectedLaneMetadata, + expectedProductionPathMetadata, expectedNestedMetadata, ], grammar: [histories, rebuiltHistories, nestedHistories], }) async function observeMetadata( - lane: Lane, + productionPath: ProductionPath, start: Start, sequence: ReadonlyArray, id: string, @@ -409,13 +410,13 @@ async function observeMetadata( try { await collection.stateWhenReady() const blocker = - lane === `immediate` + productionPath === `immediate` ? undefined : collection.update(2, (draft) => { draft.v = 9 }) let earlierReceipt: true | Promise = true - if (lane === `rebuilt`) { + if (productionPath === `rebuilt`) { sync.begin() if (earlier.kind === `delete`) sync.write({ type: `delete`, key: 1 }) else @@ -427,7 +428,7 @@ async function observeMetadata( } // A re-insert writes the row exactly as the source holds it. let held: number | undefined = start === `absent` ? undefined : 0 - if (lane === `rebuilt` && earlier.key === 1) + if (productionPath === `rebuilt` && earlier.key === 1) held = earlier.kind === `delete` ? undefined : 100 sync.begin() sequence.forEach((write, step) => { @@ -457,15 +458,15 @@ async function observeMetadata( ...(write.metadata ? { metadata } : {}), }) }) - if (lane === `rebuilt`) { + if (productionPath === `rebuilt`) { expect(earlierReceipt).not.toBe(true) release() await blocker!.when(`settled`) await earlierReceipt } const receipt = sync.commit() - if (lane === `immediate`) expect(receipt).toBe(true) - if (lane === `held`) { + if (productionPath === `immediate`) expect(receipt).toBe(true) + if (productionPath === `held`) { // A truncate applies at once; the request holds every other transaction. if (sequence.some((write) => write.kind === `truncate`)) expect(receipt).toBe(true) @@ -957,9 +958,9 @@ describe(`row metadata composition oracle`, () => { it.each([`immediate`, `held`, `rebuilt`] as const)( `matches last-write-wins metadata on the %s path`, { timeout: 120_000 }, - async (lane) => { + async (productionPath) => { const cases = - lane === `rebuilt` + productionPath === `rebuilt` ? rebuiltHistories : histories.map((history) => ({ ...history, earlier: undefined })) const mismatches: Array = [] @@ -968,10 +969,10 @@ describe(`row metadata composition oracle`, () => { let actual: unknown try { actual = await observeMetadata( - lane, + productionPath, start, sequence, - `row-metadata-composition-${lane}-${index}`, + `row-metadata-composition-${productionPath}-${index}`, earlier, ) } catch (error) { @@ -982,7 +983,7 @@ describe(`row metadata composition oracle`, () => { mismatches, label, actual, - expectedLaneMetadata(start, sequence, earlier), + expectedProductionPathMetadata(start, sequence, earlier), ) } expect(mismatches).toEqual([]) diff --git a/packages/db/tests/collection-state-retention-oracle.property.test.ts b/packages/db/tests/collection-state-retention-oracle.property.test.ts index 4130e98fb6..e3e052ca19 100644 --- a/packages/db/tests/collection-state-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-state-retention-oracle.property.test.ts @@ -455,7 +455,7 @@ fcTest.prop( }, ) -// The partial-update lane reuses those histories and marks source batches +// The partial-update production path reuses those histories and marks source batches // partial from a separate stream, so the full-mode campaigns keep their // seeded histories. It runs with the default `partial` row update mode. const markPartial = (step: OptimisticStep, partial: boolean): OptimisticStep => @@ -474,7 +474,7 @@ const partialHistory = fc steps: history.steps.map((step, index) => markPartial(step, marks[index]!)), })) -// These are replay programs for the same model and driver as randomized runs, +// These are fixed histories for the same model and driver as randomized runs, // not separate assertions that only know the reported final state. it.each( [false, true].flatMap((acceptBeforeTruncate) => @@ -530,7 +530,7 @@ it.each( }, ) -// Replays of review probes. A sync transaction still open when the mutation +// Fixed histories from review probes. A sync transaction still open when the mutation // settles is not accepted, so it holds nothing and attributes nothing: after // it commits or aborts, a later remote write is remote. A completed key that // no queued sync touches stays remote while another key's sync is held. @@ -594,7 +594,7 @@ it(`keeps an unrelated completed key remote while another key's sync is held`, a ], ) }) -// Replays of random-campaign counterexamples. A rollback while a sync +// Fixed regressions of random-campaign counterexamples. A rollback while a sync // transaction on its key is still open must publish the drop. When two // completed transactions are held on one key, the newer one's row shows. it(`publishes a rollback while a sync transaction on its key is open`, async () => { @@ -672,7 +672,7 @@ it(`shows the newer of two held completed rows on one key`, async () => { ) }) -// Replay of a random-campaign counterexample. A confirmed delete must clear the +// Fixed regression of a random-campaign counterexample. A confirmed delete must clear the // key's local attribution, so a later source reinsert is remote. it(`attributes a source reinsert after a confirmed delete to the source`, async () => { const row = { id: 2, a: 0, b: 0, c: 0 } @@ -742,12 +742,12 @@ it(`writes distinguishing partial updates in the fixed partial campaign`, async expect(distinguishing).toBeGreaterThan(0) }) -// Pinned replays for a source delete of a key the source never held. The +// Fixed histories for a source delete of a key the source never held. The // backend accepted an optimistic insert, then deleted the row before the // source streamed it. The model's drain applies the delete to the applied // synced rows, where it changes nothing, so the visible result follows from // the settlement-drop law alone. The driver compares every step with the -// model; each count below proves the replay reached its premise. +// model; each count below proves the fixed history reached its premise. function absentKeyDelete(truncate = false) { return { type: `sync` as const, diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts index 9fe42773fa..98d43ce7b6 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts @@ -30,7 +30,7 @@ import { declareOracle } from './oracle-declaration.js' * would hide the cross-boundary histories this oracle exists to test. Row * contents remain outside this grammar and have their own publication model. * - * Pinned histories name important paths. Generated histories explore adjacent + * Fixed histories name important paths. Generated histories explore adjacent * legal commands with fixed and random seeds. The drivers compare production * loads, unloads, results, status, errors, and publication against this trace. * diff --git a/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts b/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts index 1221fce236..6eb3cfaff9 100644 --- a/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts @@ -2784,10 +2784,10 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { const { multiplier, ...replay } = readOracleRunConfig() const restartProperty = `subscription-lifecycle.restart-publication` - // Each generated input runs every structural lane. Reach never depends on + // Each generated input runs every structural grammar. Reach never depends on // the random seed choosing equality, a live owner, or ordinary source delivery. // Both campaigns use the same budget, grammar, model, driver and comparisons. - for (const seed of replay.replaySeed === undefined + for (const seed of replay.campaignSeed === undefined ? [2310, undefined] : [undefined]) { for (const delivery of restartDeliveries) { @@ -2868,7 +2868,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { }, 120_000) } } - for (const seed of replay.replaySeed === undefined + for (const seed of replay.campaignSeed === undefined ? [2310, undefined] : [undefined]) { for (const path of restartReceiverPaths) { diff --git a/packages/db/tests/collection-subscription-lifecycle.test.ts b/packages/db/tests/collection-subscription-lifecycle.test.ts index 2cc999f505..0b3244e0f2 100644 --- a/packages/db/tests/collection-subscription-lifecycle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle.test.ts @@ -11,7 +11,7 @@ import { startOutcomes } from './collection-subscription-lifecycle-cell-support. import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' /** - * Pinned unit tests for CollectionSubscription demand lifecycle: concrete + * Fixed unit tests for CollectionSubscription demand lifecycle: concrete * acquisition, release, restart, and replay examples whose expectations are * written by hand. The law, its model, and the generated scenario checks are * owned by collection-subscription-lifecycle-oracle.test.ts. diff --git a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts index 3960a5f7eb..ed0df98936 100644 --- a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts @@ -24,7 +24,7 @@ * outcomes. It compares them after the outer commit returns and after the * receipts settle. * - * Limits: the pinned tests cover layout marks, truncates, listener errors, and + * Limits: the fixed examples cover layout marks, truncates, listener errors, and * subset release. The generated grammar covers only listener commits, aborts, * and one open transaction. Optimistic settlement belongs to * `optimistic-history-oracle.ts`. diff --git a/packages/db/tests/comparison-oracle.property.test.ts b/packages/db/tests/comparison-oracle.property.test.ts index 70e5bdca9f..851f714f4c 100644 --- a/packages/db/tests/comparison-oracle.property.test.ts +++ b/packages/db/tests/comparison-oracle.property.test.ts @@ -1036,7 +1036,7 @@ describe(`areValuesEqual property-based tests`, () => { * Equal-value branches construct separate objects, and the zoned branch forces * different zones at the same instant. The production path is the exported * `ascComparator` under default options; each return is the observation - * checkpoint. This lane makes no indexed or public-query claim. + * checkpoint. This production path makes no indexed or public-query claim. */ type IsoDateFields = { year: number; month: number; day: number } type PlainDateOrderInput = { left: IsoDateFields; right: IsoDateFields } diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 766c8c21e5..6efcb2bc6d 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -393,7 +393,7 @@ export interface LiveQueryDriver { */ comparesQueryIdentity: boolean /** No active waivers. Kept empty in drivers to make this boundary explicit. */ - knownGaps?: ReadonlyArray + limits?: ReadonlyArray /** * How the adapter surfaces a query error (see the `error-status` scenario): * - `flag` (default): a readable `isLoadingError`/`status === 'error'` on the result. diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index c56dde64a7..a7ccd178c3 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -180,6 +180,6 @@ export interface InfiniteQueryDriver { build: QueryBuild, config?: InfiniteQueryConfig, ) => InfiniteQueryInputHandle - /** Whole-test waivers are not supported; future gaps need exact signatures. */ - knownGaps?: ReadonlyArray + /** Whole-test waivers are not supported; future limits need exact signatures. */ + limits?: ReadonlyArray } diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 3bab3babbd..146532cc9b 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -183,7 +183,7 @@ export function runInfiniteQuerySuite( rawDriver: InfiniteQueryDriver, registerUnitScenarios: (context: InfiniteQueryContext) => void, ): void { - const registry = scenarioRegistry(rawDriver.knownGaps) + const registry = scenarioRegistry(rawDriver.limits) const state = createScenarioState() const driver = wrapDriver(rawDriver, state) const lifetime = scenarioLifetime(state) diff --git a/packages/db/tests/conformance/registration.ts b/packages/db/tests/conformance/registration.ts index 8f32cd6a4a..0e92606c48 100644 --- a/packages/db/tests/conformance/registration.ts +++ b/packages/db/tests/conformance/registration.ts @@ -1,8 +1,8 @@ /** Whole-test expected failure cannot distinguish a known bug from a new one. */ -export function scenarioRegistry(knownGaps: ReadonlyArray = []) { - if (knownGaps.length) { +export function scenarioRegistry(limits: ReadonlyArray = []) { + if (limits.length) { throw new Error( - `Conformance gaps require an approved exact failure signature, not a whole-test waiver`, + `Conformance limits require an approved exact failure signature, not a whole-test waiver`, ) } const keys = new Set() diff --git a/packages/db/tests/conformance/result-shape-oracle.ts b/packages/db/tests/conformance/result-shape-oracle.ts index 2a436de3e2..2b959ad448 100644 --- a/packages/db/tests/conformance/result-shape-oracle.ts +++ b/packages/db/tests/conformance/result-shape-oracle.ts @@ -402,13 +402,14 @@ function checkResultShape( // Definition and registration // --------------------------------------------------------------------------- -// Two segments enter every state the laws need, on every lane. +// Two constructed histories enter every state the laws need, in every campaign +// and production path. const resultShapeHistories = { arbitrary: shapeHistory, states, runs: 6, fixedSeeds: [2316], - segments: [ + constructed: [ { name: `every switch over a three-row window`, enters: states.filter( @@ -435,7 +436,7 @@ const resultShapeHistories = { }), }, ], - pins: regressions.map((history) => ({ + fixed: regressions.map((history) => ({ name: `${history.initial}, ${history.order}, ${history.count} rows${history.windowed ? `, window at ${history.offset}` : ``}`, history, })), diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 7f728bef7a..1f8df4aa29 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -168,7 +168,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { registerWindowHistoryOracle(rawDriver) registerResultShapeOracle(rawDriver) const { ops } = rawDriver - const registry = scenarioRegistry(rawDriver.knownGaps) + const registry = scenarioRegistry(rawDriver.limits) // Track every handle mounted during the current scenario so it is always torn // down, even when a scenario throws before its own diff --git a/packages/db/tests/conformance/window-history-oracle.ts b/packages/db/tests/conformance/window-history-oracle.ts index 1ccd022d88..846bda229f 100644 --- a/packages/db/tests/conformance/window-history-oracle.ts +++ b/packages/db/tests/conformance/window-history-oracle.ts @@ -727,7 +727,7 @@ declareOracle({ /** * Pending window moves keep the last settled page. A later commit supersedes * the requested window; an older acquisition cannot publish its own page. This - * receiving lane uses each real adapter over an on-demand source that already + * receiving path uses each real adapter over an on-demand source that already * holds the rows but has not settled its demand. Available rows alone prove no * coverage (ARCHITECTURE.md, atomic window publication). Settlement is explicit; * no timer stands in for provider completion. The core ordered-window owner @@ -961,7 +961,7 @@ function registerPendingWindowOracle(driver: LiveQueryDriver) { * The application never calls imperative setWindow, whose reentrancy guard is * outside this input language (contract.ts; maintainer decision 2026-10-30). * - * This receiving lane uses all six real framework drivers. A direct Collection + * This receiving path uses all six real framework drivers. A direct Collection * change callback changes the binding's parameter while source publication is * on the stack. The callback entry is measured; no delayed trigger simulates it. * A one-shot guard permits the moved window to publish without another input diff --git a/packages/db/tests/cursor-oracle.property.test.ts b/packages/db/tests/cursor-oracle.property.test.ts index 9b0fa3da73..4b0bff8c4b 100644 --- a/packages/db/tests/cursor-oracle.property.test.ts +++ b/packages/db/tests/cursor-oracle.property.test.ts @@ -233,7 +233,7 @@ declareOracle({ // trailing terms determine local order. Null and undefined share a comparator // position but remain distinct driver inputs. Fixed matrices reconstruct every // option cell and small scalar pair; random campaigns extend the integer range. -// Width zero is pinned below as the no-cursor result, outside this generator. +// Width zero is fixed below as the no-cursor result, outside this generator. // A boundary with no order is the nearby invalid state. describe(`buildCursor properties`, () => { diff --git a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts index 7631d235bc..b26690532a 100644 --- a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts +++ b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts @@ -134,7 +134,7 @@ const reconciliationHistoryArbitrary = fc.array(reconciliationStepArbitrary, { maxLength: 30, }) -// Grammar controls: the pinned numeric/string key, unknown update/delete, +// Grammar controls: the fixed numeric/string key, unknown update/delete, // stale previous-value, and changed-source restart histories below are all // reconstructible here. Upsert establishes or replaces authoritative source // rows; rawUpdate exercises an update without a prior sent row; replay checks diff --git a/packages/db/tests/db-client-hydration-authority-oracle.test.ts b/packages/db/tests/db-client-hydration-authority-oracle.test.ts index a494dbaaa2..a0d14b8722 100644 --- a/packages/db/tests/db-client-hydration-authority-oracle.test.ts +++ b/packages/db/tests/db-client-hydration-authority-oracle.test.ts @@ -63,7 +63,7 @@ import type { ChangeMessage } from '../src/types' * mirror/public agreement. The change-message mirror carries no metadata, so * metadata is observed only through dehydration. * - * Pinned reference cases (not run against production) cover the equal + * Fixed reference cases (not run against production) cover the equal * re-insert, clarification (b) in both arrival orders, a metadata-less latest * seed, and a metadata-only write held against a later seed. The reference * throws on an insert that differs from a row the source wrote; the grammar @@ -72,7 +72,7 @@ import type { ChangeMessage } from '../src/types' * Limits: this owner covers one or two immediately applied adapter * transactions, all of them on the target key, three keys, and at most two * hydration chunks. A source insert over a source-written row appears only in - * the pinned reference cases. `initialData` carries no metadata. The + * the fixed reference cases. `initialData` carries no metadata. The * state-retention oracle and focused DbClient tests own queued transactions and * cancellation. This test does not establish provider transport order or a * later authoritative hydration epoch. @@ -529,7 +529,7 @@ describe(`DbClient hydration authority oracle`, () => { ]) }) - it(`reference folds the D5 rules over pinned histories`, () => { + it(`reference folds the D5 rules over fixed histories`, () => { const before = hydrate(hydrationRows(`before`)) const after = hydrate(hydrationRows(`after`)) diff --git a/packages/db/tests/declared-reach-oracle.property.test.ts b/packages/db/tests/declared-reach-oracle.property.test.ts index 6017b41a31..3c9c25e65a 100644 --- a/packages/db/tests/declared-reach-oracle.property.test.ts +++ b/packages/db/tests/declared-reach-oracle.property.test.ts @@ -28,11 +28,11 @@ * fewer than n fixed histories ran, a failure stopped the run inside the * examples, and the check reports only that. * - The examples come on top of the base run budget, so they never displace - * a random history. A replay keeps its seed, and a path replay of the + * a random history. A reseeded campaign keeps its seed, and a path replay of the * property keeps its path and still runs one history. * * `runOracleCampaign` runs a fixed-seed and a random campaign, each with its - * own reach and a minimum of one. A seed-only run of its replay prefix runs one + * own reach and a minimum of one. A seed-only run of its campaign prefix is a reseeded campaign and runs one * campaign with that seed and checks its reach; a seed and path run one * recorded history and check none. Every started campaign reports its seed and * counts on one `ORACLE_REACH` line, including a failing campaign. Fresh seed @@ -352,9 +352,9 @@ async function checkLedger(ledger: Ledger) { return expected } -// The campaign runner's replay prefix, and the environment each run kind or +// The campaign runner's prefix, and the environment each run kind or // invalid environment sets for it. The runner reads only its own prefix, so a -// replay of another property is an ordinary run for it. +// path replay of another property is an ordinary run for it. const campaignPrefix = `TANSTACK_DB_REACH_LEDGER` function campaignRun(run: RunKind): RunKind { return run === `other-path` ? `ordinary` : run @@ -401,8 +401,8 @@ function campaignLedger(ledger: Ledger): Ledger { } } -// The campaign model: two campaigns without a prefix seed, one replay -// campaign with one. Each campaign has a minimum of one and no fixed +// The campaign model: two campaigns without a prefix seed, one campaign named +// `replay` with one. Each campaign has a minimum of one and no fixed // histories, checks reach unless a path replays it, and reports its seed and counts // even when reach fails. The first failing campaign ends the run. function expectedCampaign(ledger: Ledger): CampaignObservation { @@ -519,7 +519,7 @@ async function checkCampaign(ledger: Ledger) { } // The run parameters: the examples come on top of the base runs, and the -// replay selection stays. A seed replay keeps its seed, as does a path replay +// replay selection stays. A reseeded campaign keeps its seed, as does a path replay // of another property. A path replay of this property keeps its seed and path, // and runs one history. A replay witness channel adds reporter keys; no run // kind here opens one, so the check leaves them out. @@ -599,11 +599,11 @@ function checkRunParameters( ? undefined : run === `seed` ? { - replaySeed: 1, + campaignSeed: 1, replayPath: undefined, replayProperty: undefined, } - : { replaySeed: 1, replayPath: `0`, replayProperty: replayed }, + : { campaignSeed: 1, replayPath: `0`, replayProperty: replayed }, property, examples, ), diff --git a/packages/db/tests/documented-mutations-oracle.ts b/packages/db/tests/documented-mutations-oracle.ts index b62724403f..954f2e7f8c 100644 --- a/packages/db/tests/documented-mutations-oracle.ts +++ b/packages/db/tests/documented-mutations-oracle.ts @@ -25,7 +25,7 @@ import type { * * Scope: exact handler ASTs, one mutation, one edited key and an optional peer. * Complete ordinary fields, including Dates and own undefined, are observed. - * Transport rejection and resolved HTTP 503 responses are separate lanes for + * Transport rejection and resolved HTTP 503 responses are separate grammars for * the fetch handler, with server-write fact varied independently. The HTTP * branch owes an Error with its declared message and exact error propagation. * Full schema/factory/query/component execution, awaitMatch, timeout guidance, diff --git a/packages/db/tests/documented-mutations-support.test.ts b/packages/db/tests/documented-mutations-support.test.ts index a8b9e680bc..b9eb0eea96 100644 --- a/packages/db/tests/documented-mutations-support.test.ts +++ b/packages/db/tests/documented-mutations-support.test.ts @@ -87,7 +87,7 @@ test(`finite replay selects the exact authored history and rejects an unknown id }) test.each([undefined, `42`])( - `an irrelevant shared seed %s changes neither complete nor missing-lane reach`, + `an irrelevant shared seed %s changes neither complete nor missing-case reach`, (seed) => { vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, seed) vi.spyOn(console, `info`).mockImplementation(() => {}) diff --git a/packages/db/tests/fixtures/census/src/cases.ts b/packages/db/tests/fixtures/census/src/cases.ts index 06bed87d1d..1a320b8c38 100644 --- a/packages/db/tests/fixtures/census/src/cases.ts +++ b/packages/db/tests/fixtures/census/src/cases.ts @@ -1,5 +1,5 @@ // Classifier cases for tests/error-sites-oracle.ts. The production error -// message oracle pins how the census classifies each function below. +// message oracle checks how the census classifies each function below. function devBuild(): boolean { return true diff --git a/packages/db/tests/flat-change-tracking-oracle.property.test.ts b/packages/db/tests/flat-change-tracking-oracle.property.test.ts index 85f693db6a..e49c05ac6f 100644 --- a/packages/db/tests/flat-change-tracking-oracle.property.test.ts +++ b/packages/db/tests/flat-change-tracking-oracle.property.test.ts @@ -60,10 +60,10 @@ * `Object.is` alone reports `-0` written over `0`, one that skips deletions * loses removed fields, one that ignores whether the row owns a field drops * added `undefined` fields, and one that edits rows in place breaks the throw - * and stored-draft histories; each fails a pinned history and the fixed + * and stored-draft histories; each fails a fixed history and the fixed * campaign. The proxy used to treat a field added with `undefined` as * reverted when another field went back to its original value, and dropped - * the added field; the pinned history and both campaigns fail without the + * the added field; the fixed history and both campaigns fail without the * fix, because the grammar weights that run. * * Known omissions: nested objects, arrays, Dates, Maps, Sets, class @@ -371,7 +371,7 @@ const historyArbitrary: fc.Arbitrary = fc }), ) -// Each witness pins a shape the trackers once disagreed on to the change +// Each witness checks a shape the trackers once disagreed on to the change // set both must now report: defining a field acts as assigning it, and a // non-enumerable field is not row data unless the callback writes it. const pinnedHistories: ReadonlyArray<{ name: string; history: History }> = [ diff --git a/packages/db/tests/index-update-oracle.property.test.ts b/packages/db/tests/index-update-oracle.property.test.ts index cced09c866..44b69bdbef 100644 --- a/packages/db/tests/index-update-oracle.property.test.ts +++ b/packages/db/tests/index-update-oracle.property.test.ts @@ -41,11 +41,11 @@ import type { BaseIndex } from '../src/indexes/base-index.js' * concerns returned index results, not bucket identity or maintenance work. * A build is one of those direct actions even when a source value is * `undefined`: default nulls-first order must hold after build just as it - * does after incremental add. The nullish lane below exercises both paths. - * A separate mixed number/string lane checks key conservation only. A full + * does after incremental add. The nullish grammar below exercises both paths. + * A separate mixed number/string grammar checks key conservation only. A full * ordered read and an indexed public snapshot must contain each live source * key once, even when unlike values have no declared relative sort order. - * A separate two-value callback lane checks legacy scalar input kinds for + * A separate two-value callback grammar checks legacy scalar input kinds for * immutable Date, Temporal.Instant, and byte values across both index types. */ @@ -88,7 +88,7 @@ const arbitraryAction: fc.Arbitrary = fc.oneof( * model actions, not names for additional production index methods. * * Reconstruction: the fixed prefix reaches nonempty replacement, update, - * delete, empty replacement, and continuation; the pinned tail reaches a new + * delete, empty replacement, and continuation; the fixed-history tail reaches a new * key after replacement. Generated put supplies duplicate values and same-key * updates, delete supplies retirement, and build supplies arbitrary replacement * sets. Removing any one action loses that transition. Removing the narrow @@ -1689,14 +1689,14 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { // Group grammar: 2..20 generated IDs in 0..4, with a forced duplicate first // group and a distinct group 5. The same bounded input is run in both index - // types. The exact-identity lane gives every row a fresh array, then retires - // and reuses handles. The custom-comparator lane additionally reverses + // types. The exact-identity grammar gives every row a fresh array, then retires + // and reuses handles. The custom-comparator grammar additionally reverses // insertion. Without the duplicate, representative retirement is absent; // without the distinct group, group order is unobservable; without reversed // insertion, insertion order can masquerade as lexical key order. Replacing // one value with a fresh value in the same group checks exact membership // independently of comparator position; a later update crosses groups. - // The pinned [0, 1] case reconstructs these distinctions. Invalid comparator + // The fixed [0, 1] history reconstructs these distinctions. Invalid comparator // results and inconsistent comparator order are outside these two grammars. test.each([20260930, undefined])( `preserves exact equality while ordered traversal retains every row (seed %s)`, @@ -2001,7 +2001,7 @@ test(`retired-identity calibration shrinks and replays the same semantic failure * then deletes one Temporal owner. After each returned action, both full index * traversals must contain exactly the live source keys. * - * In the public lane a ready Collection receives the same rows and deletes + * In the public production path a ready Collection receives the same rows and deletes * the number. A manual index serves a bounded `currentStateAsChanges` read * with room for one more row than the source contains. The returned change * messages must contain each live key once. `optimizedOnly` and the take spy diff --git a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts index e1a6e4764c..29e0764c39 100644 --- a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts @@ -8,7 +8,7 @@ * history of whole effects, with raw event and public status checks. Existing * peers and an ordinary successful suffix precede fresh restore. * - * Fixed/random campaigns share the exact property and bounds. Pinned histories + * Fixed/random campaigns share the exact property and bounds. Fixed histories * reconstruct every action; finite templates enumerate every sender/delivery * choice within their stated two-operation scope. This is not exhaustive native * scheduling, arbitrary optimistic overlap, or a same-key conflict policy. diff --git a/packages/db/tests/indexed-db/pending-history-oracle.test.ts b/packages/db/tests/indexed-db/pending-history-oracle.test.ts index 9f9f429abe..e1d520a014 100644 --- a/packages/db/tests/indexed-db/pending-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/pending-history-oracle.test.ts @@ -15,7 +15,7 @@ * The driver uses independent descriptors, deferred application handlers, public * base/metadata, raw IDB observations and a simple downstream live query. * Checkpoints: handler entry, every peer read/publication, settlement, ordinary - * suffix and restore. Pinned CRUD × remote action × decision cases reconstruct + * suffix and restore. Fixed CRUD × remote action × decision cases reconstruct * every boundary. Fixed/fresh histories use the same generator and checker. */ import { fc } from '@fast-check/vitest' diff --git a/packages/db/tests/indexed-db/persistence.test.ts b/packages/db/tests/indexed-db/persistence.test.ts index 213719b3f3..139ef72508 100644 --- a/packages/db/tests/indexed-db/persistence.test.ts +++ b/packages/db/tests/indexed-db/persistence.test.ts @@ -1,5 +1,5 @@ // Ordinary tests for the settled-history owner in persistence-oracle.test.ts. -// They pin legacy version-metadata semantics and reject wrong snapshots with +// They check legacy version-metadata semantics and reject wrong snapshots with // literal values. The law they support is that version tokens, not wall-clock // timestamps, order changes, and that assertRows distinguishes typed keys and // extra fields. The model and check that the owner declares are in diff --git a/packages/db/tests/live-query-bound-options-oracle.ts b/packages/db/tests/live-query-bound-options-oracle.ts index ac31c74301..956f6c84f8 100644 --- a/packages/db/tests/live-query-bound-options-oracle.ts +++ b/packages/db/tests/live-query-bound-options-oracle.ts @@ -217,7 +217,7 @@ export function checkBoundSchemaMutation( ) } -// Every lane is required separately in each native receiving driver. A held +// Every production path is required separately in each native receiving driver. A held // observation counts only after immediate/public/handler comparisons succeed. export const boundSchemaCuts = [ `initial-source-raw`, @@ -233,11 +233,11 @@ export const boundSchemaCuts = [ `rebound-followup-held`, `rebound-followup-settled`, ] as const -export function boundSchemaLane(history: BoundSchemaHistory) { +export function boundSchemaGrammar(history: BoundSchemaHistory) { return `${history.identity}/${history.firstSchema}->${history.nextSchema}/${history.changeKey ? `new-key` : `same-key`}/${history.input.word}` } export const boundSchemaReachStates = boundSchemaHistories.flatMap((history) => - boundSchemaCuts.map((cut) => `${boundSchemaLane(history)}/${cut}`), + boundSchemaCuts.map((cut) => `${boundSchemaGrammar(history)}/${cut}`), ) declareOracle({ diff --git a/packages/db/tests/live-query-bound-options-support.ts b/packages/db/tests/live-query-bound-options-support.ts index 048aa410af..ea27625b27 100644 --- a/packages/db/tests/live-query-bound-options-support.ts +++ b/packages/db/tests/live-query-bound-options-support.ts @@ -9,7 +9,7 @@ import { gt } from '../src/query/index.js' import { createDeferred } from '../src/deferred.js' import { mockSyncCollectionOptions, withOracleCleanup } from './utils.js' import { - boundSchemaLane, + boundSchemaGrammar, captureBoundOptions, checkBoundSchemaMutation, checkBoundSchemaRows, @@ -154,8 +154,8 @@ export async function runBoundSchemaHistory( reach: ReturnType>, ): Promise { reach.beginHistory() - const lane = boundSchemaLane(history) - const mark = (cut: string) => reach.reach(`${lane}/${cut}`) + const grammar = boundSchemaGrammar(history) + const mark = (cut: string) => reach.reach(`${grammar}/${cut}`) let base: Array = [ { id: `one`, label: ` source alpha `, rank: 1 }, { id: `peer`, label: ` source beta `, rank: 2 }, diff --git a/packages/db/tests/live-query-cache-options-oracle.test.ts b/packages/db/tests/live-query-cache-options-oracle.test.ts index dddebf3447..fdc77cb398 100644 --- a/packages/db/tests/live-query-cache-options-oracle.test.ts +++ b/packages/db/tests/live-query-cache-options-oracle.test.ts @@ -12,7 +12,7 @@ * returned Promise; request start and unresolved preload are checked explicitly. * Public admission, dehydration rows, observer delivery, and subscriber ownership * are the observation cuts. React supplies client/server receiving paths using - * the same judgment. Failure/consumption retirement is a separate lifecycle lane. + * the same judgment. Failure/consumption retirement is a separate lifecycle grammar. */ import { afterAll, expect, test, vi } from 'vitest' import { diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index b549f74352..8dc4df2e34 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -47,7 +47,7 @@ * first subscriber. The separate sibling-resumption grammar below uses * controlled asynchronous loads. The broad grammar does not generate source * failure, asynchronous loads, or several live-query consumers. The - * subscription lifecycle oracle owns those once acquisition has resumed. Pinned blocks cover pooled views, + * subscription lifecycle oracle owns those once acquisition has resumed. Fixed blocks cover pooled views, * `DbClient` stream preloads, a subscriber that outlives cleanup, and reads * that wait for readiness. */ diff --git a/packages/db/tests/live-query-deferred-acquisition.test.ts b/packages/db/tests/live-query-deferred-acquisition.test.ts index 41207ec428..066c9255a0 100644 --- a/packages/db/tests/live-query-deferred-acquisition.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition.test.ts @@ -635,7 +635,7 @@ describe(`preload answered by a DbClient stream`, () => { * A subscriber that survives is still a subscriber in the next sync run, so a * restarted live-query Collection acquires for it at once instead of * deferring until another subscriber or preload arrives. The finite command - * histories above unsubscribe before cleanup. This block pins the history: subscribe, + * histories above unsubscribe before cleanup. This block covers the history: subscribe, * clean up while subscribed, restart, then settle. */ describe(`a subscriber that outlives cleanup`, () => { diff --git a/packages/db/tests/live-query-observer-history.test.ts b/packages/db/tests/live-query-observer-history.test.ts index 7f766a9466..10028b5877 100644 --- a/packages/db/tests/live-query-observer-history.test.ts +++ b/packages/db/tests/live-query-observer-history.test.ts @@ -72,7 +72,7 @@ describe(`LiveQueryObserver generated histories`, () => { /** * Failed subscription setup transfers no listener ownership to the observer: - * the caller received no release handle. These fixed cases pin each failure + * the caller received no release handle. These fixed cases check each failure * point, cleanup rejection and a real sync startup throw, in both modes, with * one or two failed attempts before an explicit retry. They compare with * written lease counts. The generated ownership law that these cases illustrate diff --git a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts index b1130d22ee..c717446a58 100644 --- a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts @@ -70,8 +70,8 @@ import type { | handoff timing, on-demand subsets, streamed errors, consumption | | | | focused `live-query-observer.test.ts` hydration tests | * Keys are `a`..`c`, versions 0..3, histories of 1..10 steps, both modes. - * Segments start a generated history with the steps that enter each declared - * state, so every lane reaches every state. Rows are + * Constructed histories start a generated history with the steps that enter each declared + * state, so every grammar reaches every state. Rows are * compared as sets by key: this oracle makes no order claim. The source is a * plain controlled Collection, not a live-query Collection, so source health * propagation through a query graph belongs to `source-health-oracle.test.ts`. @@ -103,7 +103,7 @@ type View = { status: CollectionStatus | `disabled`; rows: Array } const MAX_EVENTS = 10 const key = fc.constantFrom(`a`, `b`, `c`) const version = fc.integer({ min: 0, max: 3 }) -// A record of constants, not `fc.constant(object)`, so that a pin or segment +// A record of constants, not `fc.constant(object)`, so that a fixed or constructed history // built from a literal is a member of the language (fast-check compares a // constant by identity). const step = (type: Type) => @@ -596,7 +596,7 @@ oracleProperty({ states: reachStates, runs: 100, fixedSeeds: [20260, -88123], - segments: [ + constructed: [ { name: `crash while attached, then cleanup`, enters: [ @@ -644,7 +644,7 @@ oracleProperty({ build: startWith(`attach`, `ready`), }, ], - pins: [`granular`, `wholesale`].flatMap((mode) => + fixed: [`granular`, `wholesale`].flatMap((mode) => replays.map(([name, events]) => ({ name: `${mode} observer ${name}`, history: { diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index c4e27410b2..635f063471 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -18,7 +18,7 @@ import type { Handlers, Orders } from './local-only-direct-write-fixture' * transaction, and runs the user's handler. * * The change-event history oracle drives this path through generated - * histories and checks every publication. These witnesses pin the guard's + * histories and checks every publication. These witnesses fix the guard's * fallback cases, which that oracle does not generate: removing the guard * hides a direct write under a pending overlay and holds it behind a * persisting transaction, for every operation type. diff --git a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts index 297747cd1d..36ccc59650 100644 --- a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts +++ b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts @@ -381,10 +381,10 @@ declareOracle({ describe(`LocalStorage value fidelity oracle`, () => { if (!oracleReplayRequested()) { - // Pinned witnesses. The first is the reported case: a function property in + // Fixed histories. The first is the reported case: a function property in // an inserted row. The others place each lost kind under an array or an // object, and check each documented normalization on every entry path. - const pinned: Array = [ + const fixedPayloads: Array = [ ...lostLeaves.map((make) => make()), lost(`nested function`, { a: { b: () => 1 } }), lost(`function in array`, [1, () => 1]), @@ -422,7 +422,7 @@ describe(`LocalStorage value fidelity oracle`, () => { ] as const for (const entry of entries) { for (const handler of [true, false]) { - for (const payload of pinned) { + for (const payload of fixedPayloads) { if (!isLegal({ entry, handler, payload })) continue it(`${entry} ${handler ? `with` : `without`} a handler of ${payload.label} ${payload.restored === LOST ? `rejects` : `persists its JSON form`}`, () => runHistory({ entry, handler, payload })) diff --git a/packages/db/tests/local-storage-write-order-oracle.test.ts b/packages/db/tests/local-storage-write-order-oracle.test.ts index 86c647be68..d3c3926d30 100644 --- a/packages/db/tests/local-storage-write-order-oracle.test.ts +++ b/packages/db/tests/local-storage-write-order-oracle.test.ts @@ -20,8 +20,8 @@ * by one Collection with automatic handlers. Inserts are excluded: an insert * after a rejected delete of the same key is undecided behaviour. Manual * acceptance and peer writes are outside this property because they need - * different event and ownership boundaries; the order oracle's pinned - * witnesses and `local-storage.peers` cover them. Storage faults and + * different event and ownership boundaries; the order oracle's fixed + * histories and `local-storage.peers` cover them. Storage faults and * histories longer than three writes are also outside it. The prompt release * of a failed later handler's receipt and optimistic row is witnessed only by * the fixed tests in `local-storage-order.test.ts` (coverage map). diff --git a/packages/db/tests/optimistic-history-oracle.property.test.ts b/packages/db/tests/optimistic-history-oracle.property.test.ts index f2a47bce4f..234a31ae03 100644 --- a/packages/db/tests/optimistic-history-oracle.property.test.ts +++ b/packages/db/tests/optimistic-history-oracle.property.test.ts @@ -16,10 +16,10 @@ * each checkpoint, the allowed cuts, rows, outcomes, receipts, and tracked * transactions. The seven observation mutants are calibrations. * - * Limits: the core grammar has no partial-update lane, schema default, + * Limits: the core grammar has no partial-update production path, schema default, * pending manual edit, external completion, rejected handler, reentrant drain, * or ordered source operations. Those dimensions are exercised by the owners - * that still drive `runOptimisticHistory` with pinned histories (coverage map). + * that still drive `runOptimisticHistory` with fixed histories (coverage map). */ import { oracleProperty } from './oracle-property.js' import { @@ -186,7 +186,7 @@ oracleProperty({ law: { statement: `Optimistic and sync histories expose the rows, publications, request outcomes, receipts, and tracked transactions that the optimistic-history model owes at every step.`, authority: `packages/db/tests/optimistic-history-oracle.ts header (the optimistic and sync history contract); the public transaction contract for commit admission and terminal outcomes`, - limits: `The core grammar and production path only: no partial-update lane, schema default, pending manual edit, external completion, rejected handler, reentrant drain, ordered source operations, acknowledged handler write, or wrapping sync.`, + limits: `The core grammar and production path only: no partial-update production path, schema default, pending manual edit, external completion, rejected handler, reentrant drain, ordered source operations, acknowledged handler write, or wrapping sync.`, owner: `Optimistic state`, }, histories: { @@ -195,7 +195,7 @@ oracleProperty({ measured: optimisticHistoryStates, runs: 100, fixedSeeds: [86103], - pins: cases.map(({ name, initial, steps }) => ({ + fixed: cases.map(({ name, initial, steps }) => ({ name, history: { initial, steps } satisfies OptimisticHistory, })), diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index 8ee49553d1..1aeead6562 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -65,7 +65,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * sync transactions the Collection has taken, `deferred` holds committed sync * transactions waiting behind a wrapping sync's held durable step, and `base` * is the applied synced rows. `deferred` is model vocabulary; core reaches it - * only through a wrapping sync, so the core lane never enters it. `held` is + * only through a wrapping sync, so the core production path never enters it. `held` is * a model-only flag for the hold described above: it is set when a * transaction completes while a queued sync transaction touches its key, and * the drain that applies that queue clears it. @@ -93,7 +93,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * A persisting or different-key peer keeps its own settlement. Handler rejection * cascades; an explicit rollback can request a secondary rollback without it. * Ordinary source batches write rows before deletes. The ordered-operations - * lane also permits a delete and reinsert of one key in the same transaction. + * grammar also permits a delete and reinsert of one key in the same transaction. * Its `row` action combines source insert and update; the production driver * resolves that kind against source membership before invoking sync writes. * A reentrant history commits a truncate and its suffix from a subscriber @@ -126,7 +126,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * transaction does not need to stay tracked for. * * `runOptimisticHistory` gives the same edit, delete, settle, and sync history - * to this model and a real Collection. A `WrappingSyncLane` runs the same + * to this model and a real Collection. A `WrappingSyncPath` runs the same * history through a wrapping sync, adds `hold-store` and `release-store` * steps, and lets the wrapping sync settle before each comparison. Right after * each optimistic mutation call, before any await, the driver checks that the @@ -154,7 +154,7 @@ type SourceBatch = { operations?: Array | undefined truncate: boolean copies: number - // In the partial-update lane, the source's updates omit `c`. + // In the partial-update production path, the source's updates omit `c`. partial?: boolean | undefined } // A transaction with no operation and no truncate has no durable write. @@ -682,7 +682,7 @@ const sourceBatch = fc.record({ // The source may also delete keys, including keys it does not hold. deletes: fc.oneof( // A mapped constant, not `fc.constant([])`: fast-check compares a constant - // by identity, so a pinned `[]` would not be a member of the language. + // by identity, so a fixed `[]` would not be a member of the language. { weight: 3, arbitrary: noDeletes }, { weight: 1, @@ -705,7 +705,7 @@ const handlerSourceBatch = fc.oneof( weight: 1, arbitrary: fc.tuple(sourceBatch, fc.boolean()).map( ([batch, awaitReceipt]) => ({ ...batch, awaitReceipt }), - // So a pinned handler batch is a member of the language. + // So a fixed handler batch is a member of the language. (value) => { const { awaitReceipt, ...batch } = value as { awaitReceipt?: unknown @@ -783,7 +783,7 @@ export const optimisticHistory = fc.record({ // The core grammar plus store steps. While a hold is outstanding, an // in-handler batch does not await acceptance. Every mapped piece has an // unmapper, and no step is an object constant (fast-check compares those by -// identity), so a pinned or segment-built history is a member of the language. +// identity), so a fixed or constructed history is a member of the language. // A deferred transaction's signal aborts before its turn. const abortStep: fc.Arbitrary = fc.record({ type: fc.constant(`abort-deferred` as const), @@ -895,7 +895,7 @@ export function wrappingSteps( return [...excludeHeldAcceptance(body), ...wrappingTail] } /** - * The grammar for a wrapping-sync lane: the core grammar plus held durable + * The grammar for a wrapping-sync production path: the core grammar plus held durable * writes and acknowledged edits. */ export const wrappingSyncHistory = fc.record({ @@ -932,7 +932,7 @@ export const wrappingSyncHistory = fc.record({ * comparison. It can also hold a durable step, which is the only way this * grammar reaches the model's `deferred` state. */ -export type WrappingSyncLane = { +export type WrappingSyncPath = { wrap: (config: CollectionConfig) => CollectionConfig // Hold the durable step of the next source transaction that writes. holdNextStore: () => void @@ -962,13 +962,13 @@ export async function runOptimisticHistory( | `previous-value` | `update-as-insert` | `retained-default` - // Writes the partial lane's rows under `rowUpdateMode: 'full'`, so the + // Writes the partial-update production path's rows under `rowUpdateMode: 'full'`, so the // Collection replaces the row instead of merging it. | `partial-as-full`, options: { insertDefault?: number partialUpdates?: boolean - wrapping?: WrappingSyncLane + wrapping?: WrappingSyncPath } = {}, ) { const partialUpdates = options.partialUpdates === true @@ -980,7 +980,7 @@ export async function runOptimisticHistory( whenSyncAccepted, } if (wrapping && options.insertDefault !== undefined) - throw new Error(`The wrapping-sync lane has no schema lane`) + throw new Error(`The wrapping-sync production path has no schema grammar`) const model = new HistoryModel(initial, options.insertDefault, partialUpdates) let sync!: Parameters[`sync`]>[0] let starting: ReturnType> | undefined @@ -1005,7 +1005,7 @@ export async function runOptimisticHistory( onUpdate: handler, onDelete: handler, sync: { - // The partial-update lane keeps the default row update mode. + // The partial-update production path keeps the default row update mode. ...(partialUpdates && mutant !== `partial-as-full` ? {} : { rowUpdateMode: `full` as const }), @@ -1159,7 +1159,7 @@ export async function runOptimisticHistory( beginSourceBatch(step) return commitSourceBatch(step) } - // In a wrapping-sync lane each source transaction carries its own signal, + // In a wrapping-sync production path each source transaction carries its own signal, // so a history can abort it while it is deferred. const controllers = new Map() function commitSourceBatch(step: SourceBatch) { @@ -1656,7 +1656,9 @@ export async function runOptimisticHistory( counts.abortedDeferred++ } else if (step.type === `reentrant`) { if (wrapping) - throw new Error(`The wrapping-sync lane has no reentrant drain`) + throw new Error( + `The wrapping-sync production path has no reentrant drain`, + ) if (open) continue if (!step.trigger.truncate) throw new Error(`Reentrant trigger must publish a truncate`) @@ -1796,7 +1798,7 @@ declareOracle({ check: [runOptimisticHistory], model: [HistoryModel] }) // The same laws, model, and production paths as `runOptimisticHistory`, with // the jobs separated (rubric, ORC-003): // -// - The history is the initial rows, the steps, and the lane's options. +// - The history is the initial rows, the steps, and the production path's options. // - `resolveOptimisticHistory` turns it into a plan of concrete actions. A // step can name its target indirectly, such as "settle active transaction // `slot`", and an edit can be a no-op; resolution replays the model's @@ -1806,12 +1808,12 @@ declareOracle({ check: [runOptimisticHistory], model: [HistoryModel] }) // at each checkpoint, the allowed publication cuts, the visible rows, each // request's outcome and state, the receipts that must still wait, and the // unsettled transactions. -// - `optimisticHistoryDriver(lane)` runs the plan through a real Collection +// - `optimisticHistoryDriver(productionPath)` runs the plan through a real Collection // and records, at each checkpoint, what production published and showed. // It decides nothing. // - `checkOptimisticHistory` compares them, law by law. -/** One history: rows, steps, and the lane's row options. */ +/** One history: rows, steps, and the production path's row options. */ export type OptimisticHistory = { initial: Array steps: ReadonlyArray @@ -2387,19 +2389,19 @@ export type OptimisticCleanup = { } /** - * The production driver for one lane: core, or a wrapping sync such as - * persistence. `fullRows` sets up the partial-update lane in `full` row update - * mode, a hostile configuration that a calibration uses. + * The production driver for one production path: core, or a wrapping sync such as + * persistence. `fullRows` sets up the partial-update production path in `full` row update + * mode, a mutant configuration that a calibration uses. */ export function optimisticHistoryDriver( - lane: { wrapping?: WrappingSyncLane; fullRows?: boolean } = {}, + productionPath: { wrapping?: WrappingSyncPath; fullRows?: boolean } = {}, ) { return async ( plan: OptimisticPlan, observe: (checkpoint: string, value: unknown) => void, ): Promise => { const { history } = plan - const { wrapping } = lane + const { wrapping } = productionPath const partialUpdates = history.partialUpdates === true const ids = batchIds(plan) const db: HistoryDb = wrapping?.db ?? { @@ -2409,7 +2411,7 @@ export function optimisticHistoryDriver( whenSyncAccepted, } if (wrapping && history.insertDefault !== undefined) - throw new Error(`The wrapping-sync lane has no schema lane`) + throw new Error(`The wrapping-sync production path has no schema grammar`) if ( !wrapping && plan.actions.some( @@ -2421,7 +2423,9 @@ export function optimisticHistoryDriver( ) throw new Error(`Store steps need a wrapping sync`) if (wrapping && plan.actions.some((action) => action.type === `reentrant`)) - throw new Error(`The wrapping-sync lane has no reentrant drain`) + throw new Error( + `The wrapping-sync production path has no reentrant drain`, + ) let sync!: Parameters[`sync`]>[0] let starting: ReturnType> | undefined // The next handler call writes this batch before it returns. @@ -2448,8 +2452,8 @@ export function optimisticHistoryDriver( onUpdate: handler, onDelete: handler, sync: { - // The partial-update lane keeps the default row update mode. - ...(partialUpdates && !lane.fullRows + // The partial-update production path keeps the default row update mode. + ...(partialUpdates && !productionPath.fullRows ? {} : { rowUpdateMode: `full` as const }), sync: (actions) => { @@ -2526,7 +2530,7 @@ export function optimisticHistoryDriver( } } } - // In a wrapping-sync lane each source transaction carries its own signal, + // In a wrapping-sync production path each source transaction carries its own signal, // so a history can abort it while it is deferred. const controllers = new Map() function commitSourceBatch({ batch }: PlannedBatch) { diff --git a/packages/db/tests/optimistic-history-publication-oracle.test.ts b/packages/db/tests/optimistic-history-publication-oracle.test.ts index 96e46ea3e8..2ee669a01f 100644 --- a/packages/db/tests/optimistic-history-publication-oracle.test.ts +++ b/packages/db/tests/optimistic-history-publication-oracle.test.ts @@ -351,8 +351,8 @@ it.each([false, true])( // A truncate consumes attribution for keys it writes, while an active edit on // another key still owns its first later source transaction in the same drain. -// The nonoptimistic lane exposes source origin at the drain cut; the optimistic -// lane exposes it when the failed edit drops its overlay. +// The nonoptimistic production path exposes source origin at the drain cut; the optimistic +// production path exposes it when the failed edit drops its overlay. it.each([false, true])( `retains active attribution for another key after truncate, optimistic=%s`, async (optimistic) => { diff --git a/packages/db/tests/optimistic-transaction-oracle.property.test.ts b/packages/db/tests/optimistic-transaction-oracle.property.test.ts index b3536770b1..be592d3d68 100644 --- a/packages/db/tests/optimistic-transaction-oracle.property.test.ts +++ b/packages/db/tests/optimistic-transaction-oracle.property.test.ts @@ -74,7 +74,7 @@ const orders: Array> = [ // value (1..100) and note (0..10 chars). Removing order loses payload-order // checks; removing visibility loses the draft cut; removing outcome loses a // settlement branch; removing either field variation hides truncated payloads. -// Repeated operations are checked in the same-key lane below. Deleting an +// Repeated operations are checked in the same-key grammar below. Deleting an // absent key is excluded from this mixed-key grammar. const sameKeySequences = [ @@ -1047,7 +1047,7 @@ function expectedTypedKeyPayload( // expected payload is the authored key list, including multiplicity and order; // no encoded production globalKey participates in the comparison. Cross each // same-text pair with inserts, updates, deletes, repeated updates, and rollback. -// Existing same-key lanes still require genuine repeated keys to collapse. +// Existing same-key grammars still require genuine repeated keys to collapse. for (const { numberKey, reversed } of [0, -1, 1, 1.5].flatMap((key) => [false, true].map((reverseOrder) => ({ numberKey: key, diff --git a/packages/db/tests/oracle-campaign.ts b/packages/db/tests/oracle-campaign.ts index c5bfafa96e..225b5ba487 100644 --- a/packages/db/tests/oracle-campaign.ts +++ b/packages/db/tests/oracle-campaign.ts @@ -7,7 +7,7 @@ import { createOracleReach } from './oracle-reach' * selection changes between the two calls. Every started campaign reports its * seed and counts even when the callback or reach assertion fails. Counts are histories, not events: * call beginHistory once and reach only after observing the named checkpoint. - * A prefix SEED replay runs one campaign at the callback's full budget, and + * A prefix SEED reseeds one campaign at the callback's full budget, and * checks its reach. A SEED/PATH replay runs one recorded history and checks * no reach. `declared-reach-oracle.property.test.ts` owns this rule. * This is runner support, not an independent product judgment. diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index b4d4f6f930..41097149f6 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -561,7 +561,7 @@ export function assertRegisteredOracleProperty(property: string): void { } export type OracleReplayConfig = { - replaySeed: number | undefined + campaignSeed: number | undefined replayPath: string | undefined replayProperty: string | undefined } @@ -709,14 +709,14 @@ export function readOracleRunConfig( } return { multiplier, - replaySeed: undefined, + campaignSeed: undefined, replayPath: undefined, replayProperty: undefined, } } - const replaySeed = Number(seedValue) - if (seedValue.trim() === `` || !Number.isSafeInteger(replaySeed)) { + const campaignSeed = Number(seedValue) + if (seedValue.trim() === `` || !Number.isSafeInteger(campaignSeed)) { throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) } if (replayPath === undefined) { @@ -727,7 +727,7 @@ export function readOracleRunConfig( } return { multiplier, - replaySeed, + campaignSeed, replayPath: undefined, replayProperty: undefined, } @@ -746,7 +746,7 @@ export function readOracleRunConfig( ) } assertRegisteredOracleProperty(replayProperty) - return { multiplier, replaySeed, replayPath, replayProperty } + return { multiplier, campaignSeed, replayPath, replayProperty } } /** @@ -802,21 +802,21 @@ function randomParameters( examples: ReadonlyArray | undefined, ): OracleRunParameters & { examples?: Array } { if (property !== undefined) assertRegisteredOracleProperty(property) - const { replaySeed, replayPath, replayProperty } = + const { campaignSeed, replayPath, replayProperty } = typeof replay === `object` ? replay : { - replaySeed: replay, + campaignSeed: replay, replayPath: undefined, replayProperty: undefined, } - const selectedSeed = oracleCampaignSeed(replaySeed, property) + const selectedSeed = oracleCampaignSeed(campaignSeed, property) const parameters: OracleRunParameters = - replaySeed === undefined + campaignSeed === undefined ? { numRuns, seed: selectedSeed } : { numRuns, - seed: replaySeed, + seed: campaignSeed, // A path replays one recorded counterexample, so the replay runs // once. From a path with one segment, fast-check would otherwise // run `numRuns` histories that start at that index. @@ -826,7 +826,7 @@ function randomParameters( ? { numRuns: 1, path: replayPath, - ...oracleReplayReporter(property, replaySeed, replayPath), + ...oracleReplayReporter(property, campaignSeed, replayPath), } : {}), } @@ -912,7 +912,7 @@ export function oracleReplayTest( /** * The registration for a calibration witness. During a path replay of - * `property`, TANSTACK_DB_ORACLE_CALIBRATION selects a hostile mutant that the + * `property`, TANSTACK_DB_ORACLE_CALIBRATION selects a mutant that the * main property runs against, and the witness for that `fault` checks the * failure. The witness runs in ordinary runs and in that calibration replay. * It stands aside during every other path replay, where it has nothing to @@ -938,7 +938,7 @@ export function oracleRuns(baseRuns: number): number { } /** - * Replays broad randomized properties when a campaign seed is supplied. + * Reseeds broad randomized properties when a campaign seed is supplied. * `examples` run first in every run, on top of the base runs. A site that * spreads these options and sets fast-check's `examples` itself runs them * inside the base runs instead, so they displace random histories. diff --git a/packages/db/tests/oracle-property-scheduler-oracle.property.test.ts b/packages/db/tests/oracle-property-scheduler-oracle.property.test.ts index c6edb248ce..a174b3ad51 100644 --- a/packages/db/tests/oracle-property-scheduler-oracle.property.test.ts +++ b/packages/db/tests/oracle-property-scheduler-oracle.property.test.ts @@ -132,7 +132,7 @@ oracleProperty({ arbitrary: history, runs: 100, fixedSeeds: [20261103], - pins: [ + fixed: [ { name: `a queue keeps its order while another advances`, history: { diff --git a/packages/db/tests/oracle-property-self-oracle-fixture.ts b/packages/db/tests/oracle-property-self-oracle-fixture.ts index 1be5346cde..305a45878e 100644 --- a/packages/db/tests/oracle-property-self-oracle-fixture.ts +++ b/packages/db/tests/oracle-property-self-oracle-fixture.ts @@ -11,10 +11,10 @@ * first of them in the history. * - `6` makes the driver throw right after it records that item. * - * A segment builds its history as its prefix followed by a generated history, + * A constructed history builds its history as its prefix followed by a generated history, * cut to three items. The driver appends every history it runs, with the running test's name, to * the file named by SELF_ORACLE_LOG, so the self-oracle knows which - * histories each lane ran. A `nondeterministic` spec also records, last, a + * histories each campaign ran. A `nondeterministic` spec also records, last, a * counter that changes on every run. A spec reports reach through the * property's `reached` part or, as the framework's default, through the * driver's `observe("reach", state)`. A `slow` spec's driver takes about @@ -42,14 +42,14 @@ export type SyntheticSpec = { source: | { kind: `generated`; runs: number; seed: number } | { kind: `enumerated`; histories: ReadonlyArray } - /** Required states; each must be entered by a segment. */ + /** Required states; each must be entered by a constructed history. */ states: ReadonlyArray - segments: ReadonlyArray<{ + constructed: ReadonlyArray<{ name: string prefix: SyntheticHistory enters: ReadonlyArray }> - pins: ReadonlyArray<{ + fixed: ReadonlyArray<{ name: string history: SyntheticHistory enters?: ReadonlyArray @@ -84,10 +84,10 @@ const reachedStates = (history: SyntheticHistory) => allStates.filter((state) => history.includes(Number(state.slice(4)))) /** - * The synthetic driver. `hostile` records a 3 in place of the first item, the + * The synthetic driver. `mutant` records a 3 in place of the first item, the * wrong answer a driver calibration supplies. */ -function syntheticDriver(spec: SyntheticSpec, hostile = false) { +function syntheticDriver(spec: SyntheticSpec, mutant = false) { return async ( history: SyntheticHistory, observe: (checkpoint: string, value: unknown) => void, @@ -102,7 +102,7 @@ function syntheticDriver(spec: SyntheticSpec, hostile = false) { if (spec.reachVia === `observe`) for (const state of reachedStates(history)) observe(`reach`, state) for (const [index, item] of history.entries()) { - observe(`item ${index}`, hostile && index === 0 ? 3 : item) + observe(`item ${index}`, mutant && index === 0 ? 3 : item) if (item === 6) throw new Error(`synthetic driver failed at item ${index}`) } @@ -136,7 +136,7 @@ function syntheticCalibrations( ): Array> { return spec.calibrations.map( ({ name, kind }): Calibration => { - // A hostile driver records a 3 in place of the first item. + // A mutant driver records a 3 in place of the first item. if (kind === `driver`) return { name, law: `three`, driver: syntheticDriver(spec, true) } return { @@ -194,15 +194,15 @@ export function syntheticProperty(spec: SyntheticSpec): void { measured: allStates.filter((state) => !spec.states.includes(state)), runs: spec.source.runs, fixedSeeds: [spec.source.seed], - segments: spec.segments.map(({ name, prefix, enters }) => ({ + constructed: spec.constructed.map(({ name, prefix, enters }) => ({ name, enters, - // A segment extends a generated history, so its histories depend on - // the lane's seed, as real segments do. + // A constructed history extends a generated history, so its histories + // depend on the campaign's seed, as real constructed histories do. build: (generated: SyntheticHistory) => [...prefix, ...generated].slice(0, 3), })), - pins: spec.pins, + fixed: spec.fixed, }, ...parts, check: syntheticCheck, diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts index 8d7d3183ca..e54581a282 100644 --- a/packages/db/tests/oracle-property-self-oracle.test.ts +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -7,31 +7,31 @@ * process, and compares what Vitest reports with what an independent model * predicts. The laws, each judged per test or per probe: * - * - `registration`: a property registers its lanes, pins, calibrations, and + * - `registration`: a property registers its campaigns, fixed histories, calibrations, and * determinism test, or fails to load when its id is not a registered - * oracle property, a required state has no segment, or a segment names an + * oracle property, a required state has no constructed history, or a constructed history names an * undeclared state. * - `verdict`: a test fails with a violation of law L exactly when its first * failing history violates L. Within one history a driver error decides - * first, then a law violation, then a missed segment state; a driver error + * first, then a law violation, then a constructed history that misses its state; a driver error * after a recorded violation becomes that violation's cause. - * - `shrink`: a lane's reported failure keeps the kind and law of its first + * - `shrink`: a campaign's reported failure keeps the kind and law of its first * failure, and its counterexample still has them. - * - `membership`: a pin, or a segment's history, outside the language fails. - * - `reach`: a pin or segment that does not enter a state it names fails, and - * a passing lane reports how many of its histories entered each declared + * - `membership`: a fixed history, or the history a constructed history builds, outside the language fails. + * - `reach`: a fixed or constructed history that does not enter a state it names fails, and + * a passing campaign reports how many of its histories entered each declared * state, whether the property names reach itself or the driver records it. * - `calibration`: a calibration uses the first candidate whose clean trace * passes, and passes only when its wrong answer (a corrupted trace or a - * hostile driver) fails with the named law. - * - `determinism`: the determinism test runs every pin and segment history + * mutant driver) fails with the named law. + * - `determinism`: the determinism test runs every fixed and constructed history * and three generated ones, and fails for a driver whose trace changes. * - `timeout`: a property's `timeout` limits every test it registers. - * - `replay`: replaying a failing lane's seed and path runs only that - * property's random lane, starts from the counterexample itself, and + * - `replay`: replaying a failing campaign's seed and path runs only that + * property's random campaign, starts from the counterexample itself, and * reproduces it. * - `rerun`: a violation's printed rerun command runs exactly that test. - * - `seed-log`: a random lane logs exactly one `ORACLE_SEED` line for its + * - `seed-log`: a random campaign logs exactly one `ORACLE_SEED` line for its * property, the seed it runs, and no other test logs one; a replay tool * reads that line. * @@ -42,9 +42,9 @@ * * Limits: synthetic properties with histories of one to three integers, one * or two per run. The model learns which histories a test ran from the - * synthetic driver's log, and relies on fast-check running a lane's examples - * (its segment histories) first, in order. A replay or rerun probe covers - * every failing lane but only the first violating pin or history of each + * synthetic driver's log, and relies on fast-check running a campaign's examples + * (its constructed histories) first, in order. A replay or rerun probe covers + * every failing campaign but only the first violating fixed or enumerated history of each * property. The gap report's observation hook, `forwardCut`, `withinTurns`, * and `differs` have no law here. */ @@ -75,7 +75,7 @@ import type { // --------------------------------------------------------------------------- // // A draft chooses a generated or enumerated source, how its declared states -// relate to its segments, segments and pins (inside or outside the language), +// relate to its constructed histories and fixed histories (inside or outside the language), // whether its driver varies between runs, how it reports reach, whether it is // slow under a small timeout, and its calibrations. @@ -85,10 +85,10 @@ type Draft = { source: | { kind: `generated`; runs: number; seed: number } | { kind: `enumerated`; histories: ReadonlyArray } - /** `exact` declares the segments' states; the others break registration. */ + /** `exact` declares the constructed histories' states; the others break registration. */ statesMode: `exact` | `extra` | `missing` - segments: Array<{ prefix: SyntheticHistory; enters: Array }> - pins: Array<{ history: SyntheticHistory; enters: Array | null }> + constructed: Array<{ prefix: SyntheticHistory; enters: Array }> + fixed: Array<{ history: SyntheticHistory; enters: Array | null }> nondeterministic: boolean reachVia: `reached` | `observe` /** `slow`: 300 ms per history under a 100 ms timeout. */ @@ -138,11 +138,11 @@ const draft: fc.Arbitrary = fc.record({ weight: 1, }, ), - segments: fc.array( + constructed: fc.array( fc.record({ prefix: insideOrOutside(6), enters: fc.subarray(allStates) }), { maxLength: 2 }, ), - pins: fc.array( + fixed: fc.array( fc.record({ history: insideOrOutside(4), enters: fc.option(fc.subarray(allStates), { freq: 2 }), @@ -178,11 +178,11 @@ const suite: fc.Arbitrary = fc.record({ // Resolution: ids, names, and declared states // --------------------------------------------------------------------------- // -// Each draft gets a pooled id, and its segments, pins, and calibrations get +// Each draft gets a pooled id, and its constructed histories, fixed histories, and calibrations get // names. `statesMode` decides the declared states: exactly the states the -// segments enter, one more (a state without a segment), or one fewer (a -// segment that names an undeclared state). An enumerated draft has no -// segments or pins. +// constructed histories enter, one more (a state without a constructed history), or one fewer (a +// constructed history that names an undeclared state). An enumerated draft has no +// constructed or fixed histories. type Plan = { specs: Array } @@ -190,11 +190,14 @@ function resolveSuite({ drafts }: Suite): Plan { return { specs: drafts.map((d, index) => { const generated = d.source.kind === `generated` - const segments = generated - ? d.segments.map((segment, at) => ({ name: `seg-${at}`, ...segment })) + const constructed = generated + ? d.constructed.map((construction, at) => ({ + name: `constructed-${at}`, + ...construction, + })) : [] const entered = [ - ...new Set(segments.flatMap((segment) => segment.enters)), + ...new Set(constructed.flatMap((construction) => construction.enters)), ].sort() const unused = allStates.filter((state) => !entered.includes(state)) const states = @@ -207,12 +210,12 @@ function resolveSuite({ drafts }: Suite): Plan { id: syntheticIds[index]!, source: d.source, states, - segments, - pins: generated - ? d.pins.map((pin, at) => ({ - name: `pin-${at}`, - history: pin.history, - ...(pin.enters ? { enters: pin.enters } : {}), + constructed, + fixed: generated + ? d.fixed.map((fixedHistory, at) => ({ + name: `fixed-${at}`, + history: fixedHistory.history, + ...(fixedHistory.enters ? { enters: fixedHistory.enters } : {}), })) : [], nondeterministic: d.nondeterministic, @@ -237,17 +240,17 @@ function resolveSuite({ drafts }: Suite): Plan { // a history reaches `saw-k` when it holds k. The model reads the synthetic // driver's log only to learn which histories a test ran; it never reads what // Vitest reported. From the predicted failures it also owes the probes: a -// replay of every lane it predicts to fail, and a rerun of each property's -// first predicted violation among its pins or enumerated histories. +// replay of every campaign it predicts to fail, and a rerun of each property's +// first predicted violation among its fixed or enumerated histories. type Kind = | `pass` | `violation` | `setup` - | `pin-membership` - | `segment-membership` - | `pin-reach` - | `segment-reach` + | `fixed-membership` + | `constructed-membership` + | `fixed-reach` + | `constructed-reach` | `determinism` | `calibration-passed` | `calibration-wrong-law` @@ -264,9 +267,9 @@ type LogEntry = { id: string; test: string; history: SyntheticHistory } const allowed = (item: number) => [0, 1, 2, 3, 4, 6].includes(item) const member = (history: SyntheticHistory) => history.length >= 1 && history.length <= 3 && history.every(allowed) -// A segment's history is its prefix followed by a generated history, cut to +// A constructed history builds its history from its prefix followed by a generated history, cut to // three items; generated items are always allowed. -const segmentMember = (prefix: SyntheticHistory) => +const constructedMember = (prefix: SyntheticHistory) => prefix.length >= 1 && prefix.slice(0, 3).every(allowed) function verdictOf(history: SyntheticHistory): Outcome { for (const [at, item] of history.entries()) { @@ -285,7 +288,7 @@ const statesOf = (history: SyntheticHistory) => const reaches = (history: SyntheticHistory, states: ReadonlyArray) => states.every((state) => statesOf(history).includes(state)) const label = (history: SyntheticHistory) => `items ${history.join(` | `)}` -const isLane = (name: string) => +const isCampaign = (name: string) => name.startsWith(`fixed seed`) || name === `random seed` type SpecExpectation = @@ -295,9 +298,9 @@ type SpecExpectation = tests: Map /** Histories a passing determinism test must run, each twice. */ determinismRuns?: number - /** For each passing lane, the histories entering each state. */ + /** For each passing campaign, the histories entering each state. */ reach: Map> - /** Lanes owed a replay probe, and the test owed a rerun probe. */ + /** Campaigns owed a replay probe, and the test owed a rerun probe. */ replays: Array rerun?: string } @@ -319,7 +322,9 @@ function predictSpec( if (spec.unregistered) return { loads: false } if (generated) { const declared = new Set(spec.states) - const entered = new Set(spec.segments.flatMap((segment) => segment.enters)) + const entered = new Set( + spec.constructed.flatMap((construction) => construction.enters), + ) if ( spec.states.some((state) => !entered.has(state)) || [...entered].some((state) => !declared.has(state)) @@ -332,7 +337,9 @@ function predictSpec( : [ `fixed seed ${spec.source.seed}`, `random seed`, - ...spec.pins.map((pin) => `pin: ${pin.name}`), + ...spec.fixed.map( + (fixedHistory) => `fixed history: ${fixedHistory.name}`, + ), ] const all = [ ...names, @@ -361,7 +368,7 @@ function predictSpec( } // A generated property's determinism test also runs three generated // histories (maintainer decision, 2026-11-03), so a nondeterministic driver - // fails it even without pins or segments. + // fails it even without fixed or constructed histories. const determinism = (): Outcome => { const histories = ran(`determinism`).filter((_, at) => at % 2 === 0) for (const history of histories) { @@ -372,17 +379,21 @@ function predictSpec( return { kind: `pass` } } // A driver error decides a history first, then a law violation, then a - // missed segment state (maintainer decision, 2026-11-03). A lane runs its - // segment histories first, in order. - const lane = (test: string): Outcome => { - if (spec.segments.some((segment) => !segmentMember(segment.prefix))) - return { kind: `segment-membership` } + // constructed history that misses a state it names (maintainer decision, + // 2026-11-03). A campaign runs its constructed histories first, in order. + const campaign = (test: string): Outcome => { + if ( + spec.constructed.some( + (construction) => !constructedMember(construction.prefix), + ) + ) + return { kind: `constructed-membership` } for (const [at, history] of ran(test).entries()) { const { cause: _shrinkable, ...verdict } = verdictOf(history) if (verdict.kind !== `pass`) return verdict - const segment = spec.segments[at] - if (segment && !reaches(history, segment.enters)) - return { kind: `segment-reach` } + const construction = spec.constructed[at] + if (construction && !reaches(history, construction.enters)) + return { kind: `constructed-reach` } } return { kind: `pass` } } @@ -390,18 +401,18 @@ function predictSpec( for (const history of spec.source.histories) tests.set(label(history), verdictOf(history)) else { - tests.set(names[0]!, lane(names[0]!)) - tests.set(`random seed`, lane(`random seed`)) - for (const pin of spec.pins) { - const verdict = verdictOf(pin.history) + tests.set(names[0]!, campaign(names[0]!)) + tests.set(`random seed`, campaign(`random seed`)) + for (const fixedHistory of spec.fixed) { + const verdict = verdictOf(fixedHistory.history) tests.set( - `pin: ${pin.name}`, - !member(pin.history) - ? { kind: `pin-membership` } + `fixed history: ${fixedHistory.name}`, + !member(fixedHistory.history) + ? { kind: `fixed-membership` } : verdict.kind !== `pass` ? verdict - : !reaches(pin.history, pin.enters ?? []) - ? { kind: `pin-reach` } + : !reaches(fixedHistory.history, fixedHistory.enters ?? []) + ? { kind: `fixed-reach` } : { kind: `pass` }, ) } @@ -409,17 +420,21 @@ function predictSpec( for (const { name, kind } of spec.calibrations) tests.set(`calibration: ${name}`, calibrate(name, kind)) tests.set(`determinism`, determinism()) - // The determinism and calibration tests build segment histories too, so a - // segment outside the language fails them the same way it fails a lane. - if (spec.segments.some((segment) => !segmentMember(segment.prefix))) + // The determinism and calibration tests build constructed histories too, so a + // constructed history outside the language fails them the same way it fails a campaign. + if ( + spec.constructed.some( + (construction) => !constructedMember(construction.prefix), + ) + ) for (const name of all.filter( (name) => name === `determinism` || name.startsWith(`calibration:`), )) - tests.set(name, { kind: `segment-membership` }) - // A passing lane counts, for every declared and measured state, the + tests.set(name, { kind: `constructed-membership` }) + // A passing campaign counts, for every declared and measured state, the // histories it ran that entered it. const reach = new Map>() - for (const name of names.filter(isLane)) + for (const name of names.filter(isCampaign)) if (tests.get(name)!.kind === `pass`) reach.set( name, @@ -431,15 +446,17 @@ function predictSpec( ]), ), ) - // A lane that fails inside fast-check reports a seed and path to replay; a - // segment outside the language fails before fast-check runs. + // A campaign that fails inside fast-check reports a seed and path to replay; a + // constructed history outside the language fails before fast-check runs. const replays = names.filter( (name) => - isLane(name) && - [`violation`, `setup`, `segment-reach`].includes(tests.get(name)!.kind), + isCampaign(name) && + [`violation`, `setup`, `constructed-reach`].includes( + tests.get(name)!.kind, + ), ) const rerun = names.find( - (name) => !isLane(name) && tests.get(name)!.kind === `violation`, + (name) => !isCampaign(name) && tests.get(name)!.kind === `violation`, ) return { loads: true, @@ -448,7 +465,7 @@ function predictSpec( replays, ...(rerun ? { rerun } : {}), ...(generated - ? { determinismRuns: spec.pins.length + spec.segments.length + 3 } + ? { determinismRuns: spec.fixed.length + spec.constructed.length + 3 } : {}), } } @@ -464,26 +481,26 @@ function suiteModel( } // The self-oracle's own reach: states of the framework that its laws are -// about. Each pin names the state it witnesses, so every one is reached on -// every run; lanes count them. +// about. Each fixed history names the state it witnesses, so every one is reached on +// every run; campaigns count them. const frameworkStates = [ `registration-error`, - `lane-violation`, - `lane-setup-failure`, + `campaign-violation`, + `campaign-setup-failure`, `replay-probe`, `rerun-probe`, `cause-attached`, - `segment-reach-failure`, - `pin-reach-failure`, - `pin-membership-failure`, - `segment-membership-failure`, + `constructed-reach-failure`, + `fixed-reach-failure`, + `fixed-membership-failure`, + `constructed-membership-failure`, `calibration-applied`, `calibration-rejected`, `calibration-skipped-candidate`, `determinism-caught`, `observed-reach`, `timeout`, - `random-lane`, + `random-campaign`, ] as const function frameworkReach( @@ -501,19 +518,19 @@ function frameworkReach( continue } for (const [name, outcome] of owed.tests) { - if (isLane(name) && outcome.kind === `violation`) - reached.add(`lane-violation`) - if (isLane(name) && outcome.kind === `setup`) - reached.add(`lane-setup-failure`) + if (isCampaign(name) && outcome.kind === `violation`) + reached.add(`campaign-violation`) + if (isCampaign(name) && outcome.kind === `setup`) + reached.add(`campaign-setup-failure`) if (outcome.cause) reached.add(`cause-attached`) if (outcome.kind === `timeout`) reached.add(`timeout`) - if (name === `random seed`) reached.add(`random-lane`) + if (name === `random seed`) reached.add(`random-campaign`) if (outcome.kind === `determinism`) reached.add(`determinism-caught`) const failure = { - 'segment-reach': `segment-reach-failure`, - 'pin-reach': `pin-reach-failure`, - 'pin-membership': `pin-membership-failure`, - 'segment-membership': `segment-membership-failure`, + 'constructed-reach': `constructed-reach-failure`, + 'fixed-reach': `fixed-reach-failure`, + 'fixed-membership': `fixed-membership-failure`, + 'constructed-membership': `constructed-membership-failure`, 'calibration-passed': `calibration-rejected`, 'calibration-wrong-law': `calibration-rejected`, }[outcome.kind as string] @@ -545,15 +562,15 @@ function frameworkReach( // The driver writes each spec to its own test file in a fresh directory // under tests/, runs Vitest once over them with the self-oracle reporter, and // records each file's load errors, each test's state and failure (with the -// cause fast-check attaches), each passing lane's printed reach counts, and +// cause fast-check attaches), each passing campaign's printed reach counts, and // the synthetic driver's log. Then it probes: -// - replay: for every failing lane with a reported seed and path, Vitest runs +// - replay: for every failing campaign with a reported seed and path, Vitest runs // all the files again with that replay selected and its own log, which // shows the history the replay started from; // - rerun: for each property's first failure that printed a rerun command, // Vitest runs that file with the command's `-t` pattern. -// Child runs fix the random lane's seed (a seed without a path selects no -// replay), so a child run records the same facts every time. +// Child runs fix the random campaign's seed (a seed without a path reseeds the +// campaign and selects no replay), so a child run records the same facts every time. type ReportedError = { name?: string; message?: string; cause?: ReportedError } type ReportedTest = { @@ -596,14 +613,14 @@ type SuiteRun = { } type ProbeRun = { id: string - /** The probed test: a lane for replay, a pin or history for rerun. */ + /** The probed test: a campaign for replay, a fixed or enumerated history for rerun. */ target: string original: Failure /** Each test's state in the probe run, by property id and name. */ states: Array<[string, string, string]> /** The probed test's failure in the probe run. */ failure?: Failure - /** Histories the replayed random lane ran, in order. */ + /** Histories the replayed random campaign ran, in order. */ ran?: Array } @@ -639,13 +656,15 @@ function classify(error: ReportedError | undefined): Failure { : /timed out in \d+ms/.test(text) || /timed out in \d+ms/.test(outer) ? `timeout` : text.includes(`is not a history the arbitrary can generate`) - ? `pin-membership` + ? `fixed-membership` : text.includes(`built a history the arbitrary cannot generate`) - ? `segment-membership` - : text.includes(`did not enter`) && text.includes(`: pin "`) - ? `pin-reach` - : text.includes(`did not enter`) && text.includes(`: segment "`) - ? `segment-reach` + ? `constructed-membership` + : text.includes(`did not enter`) && + text.includes(`: fixed history "`) + ? `fixed-reach` + : text.includes(`did not enter`) && + text.includes(`: constructed history "`) + ? `constructed-reach` : text.includes(`two runs of`) ? `determinism` : text.includes(`but the check passed it`) @@ -759,7 +778,7 @@ async function driveSuite( const run: SuiteRun = { files: {}, log: readLog(log) } for (const [index, spec] of plan.specs.entries()) { const reported = report.files.find((file) => file.file === files[index]) - // A passing lane prints ` reach {...}`. + // A passing campaign prints ` reach {...}`. const reach: Record> = {} for (const line of report.stdout.split(`\n`)) { const match = @@ -796,12 +815,13 @@ async function driveSuite( } observe(`run`, run) for (const spec of plan.specs) { - for (const lane of run.files[spec.id]!.tests) { - if (!isLane(lane.name) || lane.failure?.path === undefined) continue + for (const campaign of run.files[spec.id]!.tests) { + if (!isCampaign(campaign.name) || campaign.failure?.path === undefined) + continue const replayLog = join(directory, `replay-${runCount}.jsonl`) const replay = runVitest(directory, files, { - TANSTACK_DB_ORACLE_SEED: String(lane.failure.seed), - TANSTACK_DB_ORACLE_PATH: lane.failure.path, + TANSTACK_DB_ORACLE_SEED: String(campaign.failure.seed), + TANSTACK_DB_ORACLE_PATH: campaign.failure.path, TANSTACK_DB_ORACLE_PROPERTY: spec.id, SELF_ORACLE_LOG: replayLog, }) @@ -811,8 +831,8 @@ async function driveSuite( ?.tests.find((test) => test.name.endsWith(`random seed`)) observe(`replay`, { id: spec.id, - target: lane.name, - original: lane.failure, + target: campaign.name, + original: campaign.failure, states: statesIn(replay, plan, files), ...(random?.state === `failed` ? { failure: classify(random.errors[0]) } @@ -851,8 +871,8 @@ async function driveSuite( // // For each property: it loads exactly when the model says so; it registers // exactly the owed tests; each test passes, or fails with the owed kind, law, -// and cause; a lane's counterexample still violates the owed law; a passing -// determinism test ran every history it owes; and each passing lane printed +// and cause; a campaign's counterexample still violates the owed law; a passing +// determinism test ran every history it owes; and each passing campaign printed // the owed reach counts. Then the probes: the replays and reruns the model // owes all happened; in each, every other test stood aside; the probed test // failed again the same way; and a replay started from the counterexample @@ -863,7 +883,7 @@ const lawOfTest = (name: string) => ? `calibration` : name === `determinism` ? `determinism` - : isLane(name) + : isCampaign(name) ? `shrink` : `verdict` const lawOfKind = (kind: string, name: string) => @@ -896,7 +916,7 @@ function checkSuite( if (seen.loaded) return violation( `run`, - `${spec.id} loaded, but its id is unregistered, a required state has no segment, or a segment names an undeclared state`, + `${spec.id} loaded, but its id is unregistered, a required state has no constructed history, or a constructed history names an undeclared state`, `registration`, ) continue @@ -920,7 +940,7 @@ function checkSuite( if ( observedKind !== outcome.kind || (outcome.law !== undefined && failure?.law !== outcome.law) || - (!isLane(test.name) && failure?.cause !== outcome.cause) + (!isCampaign(test.name) && failure?.cause !== outcome.cause) ) return violation( `run`, @@ -931,8 +951,8 @@ function checkSuite( ), { actual: failure ?? { kind: `pass` }, expected: outcome }, ) - // A random lane selects its seed once and logs that selection once; - // a failing lane's reported seed is the logged one. + // A random campaign selects its seed once and logs that selection once; + // a failing campaign's reported seed is the logged one. const owedSeedLogs = test.name === `random seed` ? 1 : 0 if (test.seedLogs.length !== owedSeedLogs) return violation( @@ -978,26 +998,26 @@ function checkSuite( ) } } - for (const [lane, counts] of owed.reach) - if (JSON.stringify(seen.reach[lane]) !== JSON.stringify(counts)) + for (const [campaign, counts] of owed.reach) + if (JSON.stringify(seen.reach[campaign]) !== JSON.stringify(counts)) return violation( `run`, - `${spec.id} "${lane}" printed reach ${JSON.stringify(seen.reach[lane] ?? null)}, owed ${JSON.stringify(counts)}`, + `${spec.id} "${campaign}" printed reach ${JSON.stringify(seen.reach[campaign] ?? null)}, owed ${JSON.stringify(counts)}`, `reach`, - { actual: seen.reach[lane], expected: counts }, + { actual: seen.reach[campaign], expected: counts }, ) - for (const lane of owed.replays) + for (const campaign of owed.replays) if ( !probes.some( ({ checkpoint, probe }) => checkpoint === `replay` && probe.id === spec.id && - probe.target === lane, + probe.target === campaign, ) ) return violation( `replay`, - `${spec.id} "${lane}" failed but was never replayed`, + `${spec.id} "${campaign}" failed but was never replayed`, `replay`, ) if ( @@ -1065,14 +1085,14 @@ function checkSuite( // Property // --------------------------------------------------------------------------- // -// Pins replay the regressions the framework has had and witness every +// Fixed histories replay the regressions the framework has had and witness every // framework state the laws are about; each names the state it witnesses. const generatedDraft = (overrides: Partial): Draft => ({ source: { kind: `generated`, runs: 2, seed: 7 }, statesMode: `exact`, - segments: [], - pins: [], + constructed: [], + fixed: [], nondeterministic: false, reachVia: `reached`, timing: `normal`, @@ -1080,7 +1100,7 @@ const generatedDraft = (overrides: Partial): Draft => ({ unregistered: false, ...overrides, }) -const pin = ( +const fixedHistory = ( name: string, enters: Array<(typeof frameworkStates)[number]>, ...drafts: Array @@ -1101,8 +1121,8 @@ oracleProperty({ measured: frameworkStates, runs: 3, fixedSeeds: [1], - pins: [ - pin(`a rerun command escapes a label with |`, [`rerun-probe`], { + fixed: [ + fixedHistory(`a rerun command escapes a label with |`, [`rerun-probe`], { ...generatedDraft({}), source: { kind: `enumerated`, @@ -1112,47 +1132,49 @@ oracleProperty({ ], }, }), - pin( + fixedHistory( `a driver error after a violation is the violation's cause`, [`cause-attached`], - generatedDraft({ pins: [at([3, 6])] }), + generatedDraft({ fixed: [at([3, 6])] }), ), - pin( + fixedHistory( `shrinking keeps the first failure's law`, - [`lane-violation`, `replay-probe`], - generatedDraft({ segments: [{ prefix: [4, 3], enters: [] }] }), + [`campaign-violation`, `replay-probe`], + generatedDraft({ constructed: [{ prefix: [4, 3], enters: [] }] }), ), - pin( - `a replayed lane failure reproduces its path`, + fixedHistory( + `a replayed campaign failure reproduces its path`, [`replay-probe`], - generatedDraft({ segments: [{ prefix: [1, 3], enters: [`saw-1`] }] }), + generatedDraft({ + constructed: [{ prefix: [1, 3], enters: [`saw-1`] }], + }), ), - pin( + fixedHistory( `shrinking keeps a setup error a setup error`, - [`lane-setup-failure`], - generatedDraft({ segments: [{ prefix: [6], enters: [] }] }), + [`campaign-setup-failure`], + generatedDraft({ constructed: [{ prefix: [6], enters: [] }] }), ), - pin( + fixedHistory( `the determinism test rejects a nondeterministic driver`, [`determinism-caught`], - generatedDraft({ nondeterministic: true, pins: [at([0])] }), + generatedDraft({ nondeterministic: true, fixed: [at([0])] }), ), - pin( + fixedHistory( `the determinism test also samples generated histories`, [`determinism-caught`], generatedDraft({ nondeterministic: true }), ), - pin( - `a required state needs a segment`, + fixedHistory( + `a required state needs a constructed history`, [`registration-error`], generatedDraft({ statesMode: `extra`, - segments: [{ prefix: [0], enters: [`saw-0`] }], + constructed: [{ prefix: [0], enters: [`saw-0`] }], }), ), - // An enumerated source has no random lane, which is where an id was + // An enumerated source has no random campaign, which is where an id was // checked before; registration itself must check it. - pin( + fixedHistory( `an unregistered enumerated property fails to load`, [`registration-error`], { @@ -1161,65 +1183,65 @@ oracleProperty({ unregistered: true, }, ), - pin( + fixedHistory( `a calibration skips a candidate whose clean trace fails`, [`calibration-skipped-candidate`, `calibration-applied`], - generatedDraft({ pins: [at([3]), at([0])], calibrations: [`right`] }), + generatedDraft({ fixed: [at([3]), at([0])], calibrations: [`right`] }), ), - pin( + fixedHistory( `calibrations need the named law`, [`calibration-applied`, `calibration-rejected`], generatedDraft({ - pins: [at([0])], + fixed: [at([0])], calibrations: [`right`, `wrong-law`, `no-op`], }), ), - pin( - `a hostile driver calibration`, + fixedHistory( + `a mutant driver calibration`, [`calibration-applied`], - generatedDraft({ pins: [at([0])], calibrations: [`driver`] }), + generatedDraft({ fixed: [at([0])], calibrations: [`driver`] }), ), - pin( - `a law violation outranks a missed segment state`, - [`lane-violation`], + fixedHistory( + `a law violation outranks a constructed history that misses its state`, + [`campaign-violation`], generatedDraft({ - segments: [{ prefix: [3, 0, 0], enters: [`saw-1`] }], + constructed: [{ prefix: [3, 0, 0], enters: [`saw-1`] }], }), ), - pin( - `a segment must enter the states it names`, - [`segment-reach-failure`], + fixedHistory( + `a constructed history must enter the states it names`, + [`constructed-reach-failure`], generatedDraft({ - segments: [{ prefix: [0, 0, 0], enters: [`saw-1`] }], + constructed: [{ prefix: [0, 0, 0], enters: [`saw-1`] }], }), ), - pin( - `a pin must enter the states it names`, - [`pin-reach-failure`], - generatedDraft({ pins: [{ history: [0], enters: [`saw-1`] }] }), + fixedHistory( + `a fixed history must enter the states it names`, + [`fixed-reach-failure`], + generatedDraft({ fixed: [{ history: [0], enters: [`saw-1`] }] }), ), - pin( - `a pin outside the language fails`, - [`pin-membership-failure`], - generatedDraft({ pins: [at([5])] }), + fixedHistory( + `a fixed history outside the language fails`, + [`fixed-membership-failure`], + generatedDraft({ fixed: [at([5])] }), ), - pin( - `a segment outside the language fails`, - [`segment-membership-failure`], - generatedDraft({ segments: [{ prefix: [5], enters: [] }] }), + fixedHistory( + `a constructed history outside the language fails`, + [`constructed-membership-failure`], + generatedDraft({ constructed: [{ prefix: [5], enters: [] }] }), ), - pin( + fixedHistory( `reach the driver records counts like reach the property names`, [`observed-reach`], generatedDraft({ reachVia: `observe`, - segments: [{ prefix: [0, 0, 0], enters: [`saw-0`] }], + constructed: [{ prefix: [0, 0, 0], enters: [`saw-0`] }], }), ), - pin( + fixedHistory( `a property timeout limits every test it registers`, [`timeout`], - generatedDraft({ timing: `slow`, pins: [at([0])] }), + generatedDraft({ timing: `slow`, fixed: [at([0])] }), ), ], }, diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index f6392f4a41..3ab0fa4482 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -15,7 +15,8 @@ * coverage map. A failure names it. * - `histories` is either an enumerated list, which runs in full (bounded * exhaustiveness, glossary), or a generated source: a fast-check arbitrary - * with a run budget, fixed seeds, required segments, and pins (below). + * with a run budget, fixed seeds, constructed histories, and fixed histories + * (below). * - `model` gives the expected result of a history and its recorded trace, * without production. A model that answers from the history alone ignores * the trace; a model of an environment the driver observes reads it, and @@ -50,20 +51,21 @@ * - **Reach** names the states a history entered. By default the driver * reports them with `observe("reach", state)`. A state that only the model * can name, such as "a failure while the seed is active", comes from - * `reached(history, trace, expected)` instead. Each lane prints how many + * `reached(history, trace, expected)` instead. Each campaign prints how many * histories entered each declared state, including none (ORC-015). - * - A **segment** builds a history that reaches states that the laws need, - * from a generated history, and names those states. Every lane runs every - * segment once as an example, and a segment run that does not enter a named - * state fails. Only segments make reach required: a state that random - * histories reach is measured, never gated. So every state in - * `states` must be named by a segment, and a state the property only - * measures is declared in `measured` instead (ORC-015). Half of a lane's - * generated histories also go through a segment, so the histories after - * each required state are explored, not visited once. - * - A **pin** is a fixed history, such as a regression. It may name the - * states it `enters`, which its run must reach. A pin and a segment's - * history must be histories the arbitrary could generate + * - A **constructed history** is built from a generated history so that it + * reaches states that the laws need, and it names those declared states. + * Every campaign runs every constructed history once as an example, and a + * constructed history that does not enter a named state fails. Only + * constructed histories make reach required: a state that random histories + * reach is measured, never gated. So every state in `states` must be named + * by a constructed history, and a state the property only measures is + * declared in `measured` instead (ORC-015). Half of a campaign's generated + * histories are also constructed, so the histories after each required + * state are explored, not visited once. + * - A **fixed history** is a named history, such as a fixed regression. It may + * name the states it `enters`, which its run must reach. Fixed and + * constructed histories must be histories the arbitrary could generate * (`canShrinkWithoutContext`), so they shrink like generated ones and cannot * cover a dimension that the grammar lacks; such a dimension belongs in the * grammar, or in the property's stated limits. @@ -75,24 +77,25 @@ * history still resolves to a legal plan. * - **Calibration.** A `calibration` names a wrong answer and the law that * must reject it: a `corrupt` function that alters a recorded trace, as an - * observation mutant does, or a hostile `driver`, such as production set up + * observation mutant does, or a mutant `driver`, such as production set up * wrongly. The calibration test finds a history where the corruption * applies and the clean trace passes (it skips a candidate whose clean trace * fails), and requires the check to report that law for the corrupted one * (rubric, ORC-006). * - **Determinism.** A `determinism` test runs some histories twice and - * requires the same trace: every pin and segment history of a generated + * requires the same trace: every fixed and constructed history of a generated * source and three generated histories, or up to eight evenly spaced * enumerated histories. A trace that * differs between runs of one history breaks replay, shrinking, and the gap * report's comparison, so a driver records relations (before and after, * same or different id) rather than clock readings or random ids. * - **Shrinking keeps the failure.** A violation can name the law it broke. - * Once a history fails one way, a setup error, a missed segment state, or + * Once a history fails one way, a setup error, a constructed history that + * missed its state, or * one law, a smaller history that fails another way counts as passing, so * the reported counterexample still shows the first failure (ORC-010). * Within one history a driver error decides first, then a law violation, - * then a missed segment state. + * then a constructed history that missed its state. * * `oracle-property-self-oracle.test.ts` checks these promises the way an * oracle checks production: it runs synthetic properties under real Vitest @@ -122,7 +125,7 @@ export interface OracleLaw { authority: string /** What this property does not establish. */ limits: string - /** The coverage-map owner. */ + /** The oracle owner in the coverage map. */ owner: string } @@ -236,7 +239,7 @@ export function withinTurns( * releases the oldest task of queue `choices[step] % queues`, counting the * queues that have a pending task in the order they first did, and the oldest * task overall once the choices run out. A history that holds the choices replays exactly, shrinks - * like any array, can be a pin or a segment, and prints as JSON; a + * like any array, can be a fixed or constructed history, and prints as JSON; a * `fc.scheduler()` value does none of these (it is consumed as it runs, its * clones do not reproduce, and it is never a member of its own language). */ @@ -285,10 +288,14 @@ export function scheduleFrom(choices: ReadonlyArray): DataScheduler { queue = label.split(` `)[0]!, ) => new Promise((resolve, reject) => { + // The schedule owns the promise from here: a rejection waits for its + // release without counting as unhandled, then reaches the caller. + const held = Promise.resolve(promise) + held.catch(() => {}) pending.push({ label, queue, - release: () => void Promise.resolve(promise).then(resolve, reject), + release: () => void held.then(resolve, reject), }) }), waitOne, @@ -315,7 +322,7 @@ export class OracleViolation extends Error { * A history built from a generated one so that it reaches the states the * laws need. The driver reports reached states with `observe("reach", state)`. */ -export interface Segment { +export interface ConstructedHistory { name: string enters: ReadonlyArray build: (generated: History) => History @@ -324,9 +331,9 @@ export interface Segment { export interface GeneratedHistories { arbitrary: fc.Arbitrary /** - * The states the laws depend on (ORC-015). Each must be named by a segment, - * so every lane reaches it. Each lane reports how many histories entered - * each one, including none. + * The declared states the laws depend on (ORC-015). Each must be named by a + * constructed history, so every campaign reaches it. Each campaign reports + * how many histories entered each one, including none. */ states?: ReadonlyArray /** @@ -334,16 +341,16 @@ export interface GeneratedHistories { * `states` and `measured` fails, so a misspelled state cannot pass unseen. */ measured?: ReadonlyArray - /** The base run budget of each lane, scaled by the run multiplier. */ + /** The base run budget of each campaign, scaled by the run multiplier. */ runs: number - /** One fixed lane per seed, beside the random lane. */ + /** One fixed-seed campaign per seed, beside the random campaign. */ fixedSeeds: ReadonlyArray - segments?: ReadonlyArray> - pins?: ReadonlyArray> + constructed?: ReadonlyArray> + fixed?: ReadonlyArray> } -/** A fixed history, such as a regression, and the states it must enter. */ -export interface Pin { +/** A named fixed history, such as a fixed regression, and the states it must enter. */ +export interface FixedHistory { name: string history: History enters?: ReadonlyArray @@ -363,7 +370,7 @@ export type Calibration = { ) => ReadonlyArray | undefined } | { - /** A hostile driver, such as production configured wrongly. */ + /** A mutant driver, such as production configured wrongly. */ driver: OraclePropertyOptions[`driver`] } ) @@ -417,7 +424,7 @@ export function oracleProperty( options: OraclePropertyOptions, ): void { // Every source names a registered property, so its replay and manifest - // entries exist; an enumerated source has no random lane to check it. + // entries exist; an enumerated source has no random campaign to check it. assertRegisteredOracleProperty(options.id) const { id, histories } = options recordRegistration(id, options.law, histories) @@ -690,7 +697,7 @@ function calibrationTests( seen.push(`"${name}" reported law "${lawOf(verdict)}"`) } throw new Error( - `${id}: calibration "${calibration.name}" found no history whose clean trace passes and where the check reports law "${calibration.law}"${seen.length ? `; ${seen.slice(0, 3).join(`, `)}` : ``}. Add a passing pin or segment that reaches the state its wrong answer changes.`, + `${id}: calibration "${calibration.name}" found no history whose clean trace passes and where the check reports law "${calibration.law}"${seen.length ? `; ${seen.slice(0, 3).join(`, `)}` : ``}. Add a passing fixed or constructed history that reaches the state its wrong answer changes.`, ) }, })) @@ -734,46 +741,51 @@ function generatedTests( source: GeneratedHistories, ): Array { const { id } = options - const segments = source.segments ?? [] + const constructions = source.constructed ?? [] const declared = source.states || source.measured ? new Set([...(source.states ?? []), ...(source.measured ?? [])]) : undefined - const named = new Set(segments.flatMap((segment) => segment.enters)) - const unsegmented = (source.states ?? []).filter((state) => !named.has(state)) - if (unsegmented.length > 0) + const named = new Set( + constructions.flatMap((construction) => construction.enters), + ) + const unconstructed = (source.states ?? []).filter( + (state) => !named.has(state), + ) + if (unconstructed.length > 0) throw new Error( - `${id}: no segment enters ${unsegmented.join(`, `)}. A required state needs a segment; a state that is only counted belongs in \`measured\`.`, + `${id}: no constructed history enters ${unconstructed.join(`, `)}. A required state needs a constructed history; a state that is only counted belongs in \`measured\`.`, ) - // Generated histories, half of them built by a segment. A built history + // Generated histories, half of them constructed. A constructed history // shrinks through its generated input, as a mapped arbitrary does. const arbitrary = - segments.length === 0 + constructions.length === 0 ? source.arbitrary : fc.oneof( - { arbitrary: source.arbitrary, weight: segments.length }, - ...segments.map((segment) => ({ - arbitrary: source.arbitrary.map(segment.build), + { arbitrary: source.arbitrary, weight: constructions.length }, + ...constructions.map((construction) => ({ + arbitrary: source.arbitrary.map(construction.build), weight: 1, })), ) - for (const segment of segments) { - const undeclared = segment.enters.filter( + for (const construction of constructions) { + const undeclared = construction.enters.filter( (state) => declared !== undefined && !(source.states ?? []).includes(state), ) if (undeclared.length > 0) throw new Error( - `${id}: segment "${segment.name}" names undeclared states ${undeclared.join(`, `)}.`, + `${id}: constructed history "${construction.name}" names undeclared states ${undeclared.join(`, `)}.`, ) } - // Each lane gets a fresh predicate: its first failure fixes the kind that - // shrinking must keep, a setup error, a missed segment state, or one law. A + // Each campaign gets a fresh predicate: its first failure fixes the kind + // that shrinking must keep, a setup error, a constructed history that missed + // its state, or one law. A // smaller history that fails some other way counts as passing. It counts // the histories that entered each state. const predicateFor = ( - segmentOf: Map>, + constructionOf: Map>, counts: Map, ) => { let firstKind: string | undefined @@ -794,14 +806,14 @@ function generatedTests( ) counts.set(state, (counts.get(state) ?? 0) + 1) } - // A law violation outranks a missed segment state. + // A law violation outranks a constructed history that missed its state. if (verdict.kind === `pass`) { - const segment = segmentOf.get(history) + const construction = constructionOf.get(history) const missing = - segment?.enters.filter((state) => !reached.has(state)) ?? [] - if (missing.length === 0 || !keeps(`segment-reach`)) return + construction?.enters.filter((state) => !reached.has(state)) ?? [] + if (missing.length === 0 || !keeps(`constructed-reach`)) return throw new Error( - `${id}: segment "${segment!.name}" did not enter ${missing.join(`, `)}. A segment must reach every state it names.`, + `${id}: constructed history "${construction!.name}" did not enter ${missing.join(`, `)}. A constructed history must reach every state it names.`, ) } if (!keeps(`law:${lawOf(verdict)}`)) return @@ -813,39 +825,40 @@ function generatedTests( ) } } - // Each lane runs every segment, built from histories its own seed draws. + // Each campaign runs every constructed history, built from histories its own + // seed draws. // fast-check passes example inputs by reference, which identifies them. const examplesFor = (seed: number) => { - const segmentOf = new Map>() - const examples = segments.map((segment, index) => { + const constructionOf = new Map>() + const examples = constructions.map((construction, index) => { const [generated] = fc.sample(source.arbitrary, { seed: seed + index, numRuns: 1, }) - const history = segment.build(generated!) + const history = construction.build(generated!) if (!source.arbitrary.canShrinkWithoutContext(history)) throw new Error( - `${id}: segment "${segment.name}" built a history the arbitrary cannot generate. A segment must stay inside the generated language so that its failures shrink.`, + `${id}: constructed history "${construction.name}" built a history the arbitrary cannot generate. A constructed history must stay inside the generated language so that its failures shrink.`, ) - segmentOf.set(history, segment) + constructionOf.set(history, construction) return [history] as [History] }) - return { examples, segmentOf } + return { examples, constructionOf } } - // A lane reports its reach counts after it passes; a failing lane reports - // its counterexample instead. - const runLane = async ( + // A campaign reports its reach counts after it passes; a failing campaign + // reports its counterexample instead. + const runCampaign = async ( name: string, seed: number, parameters: (examples: Array<[History]>) => Parameters[1], ) => { - const { examples, segmentOf } = examplesFor(seed) + const { examples, constructionOf } = examplesFor(seed) const counts = new Map( [...(declared ?? [])].map((state) => [state, 0]), ) await fc.assert( - fc.asyncProperty(arbitrary, predicateFor(segmentOf, counts)), + fc.asyncProperty(arbitrary, predicateFor(constructionOf, counts)), parameters(examples), ) // Vitest hides console output from a passing test; stdout reaches the log. @@ -853,79 +866,82 @@ function generatedTests( `${id} ${name} reach ${JSON.stringify(Object.fromEntries([...counts].sort(([a], [b]) => (a < b ? -1 : 1))))}\n`, ) } - const fixedLanes = source.fixedSeeds.map((seed): PropertyTest => ({ + const fixedCampaigns = source.fixedSeeds.map((seed): PropertyTest => ({ name: `fixed seed ${seed}`, replays: [], run: () => - runLane(`fixed seed ${seed}`, seed, (examples) => + runCampaign(`fixed seed ${seed}`, seed, (examples) => withOracleExamples( { numRuns: oracleRuns(source.runs), seed }, examples, ), ), })) - // The random lane picks its seed itself, so a replay of that seed rebuilds - // the same segment histories, which the replay path counts. - const randomLane: PropertyTest = { + // The random campaign picks its seed itself, so a replay of that seed + // rebuilds the same constructed histories, which the replay path counts. + const randomCampaign: PropertyTest = { name: `random seed`, replays: [id], run: () => { // One call selects and logs the seed; a replay reads that log line. const parameters = oraclePropertyOptions(source.runs, id) - return runLane(`random seed`, parameters.seed!, (examples) => + return runCampaign(`random seed`, parameters.seed!, (examples) => withOracleExamples(parameters, examples), ) }, } - const pins = (source.pins ?? []).map((pin): PropertyTest => ({ - name: `pin: ${pin.name}`, + const fixedTests = (source.fixed ?? []).map((fixed): PropertyTest => ({ + name: `fixed history: ${fixed.name}`, replays: [], run: async () => { - if (!source.arbitrary.canShrinkWithoutContext(pin.history)) + if (!source.arbitrary.canShrinkWithoutContext(fixed.history)) throw new Error( - `${id}: pin "${pin.name}" is not a history the arbitrary can generate. A pin must stay inside the generated language, or the property must declare the missing dimension as a limit.`, + `${id}: fixed history "${fixed.name}" is not a history the arbitrary can generate. A fixed history must stay inside the generated language, or the property must declare the missing dimension as a limit.`, ) - const { verdict, reached } = await runOne(options, pin.history) + const { verdict, reached } = await runOne(options, fixed.history) if (verdict.kind === `violation`) throw failure( options, verdict, - pin.history, - rerunByName(`${id} pin: ${pin.name}`), + fixed.history, + rerunByName(`${id} fixed history: ${fixed.name}`), ) - const missing = (pin.enters ?? []).filter((state) => !reached.has(state)) + const missing = (fixed.enters ?? []).filter( + (state) => !reached.has(state), + ) if (missing.length > 0) throw new Error( - `${id}: pin "${pin.name}" did not enter ${missing.join(`, `)}; it entered ${[...reached].sort().join(`, `) || `no declared state`}. A pin must reach every state it names.`, + `${id}: fixed history "${fixed.name}" did not enter ${missing.join(`, `)}; it entered ${[...reached].sort().join(`, `) || `no declared state`}. A fixed history must reach every state it names.`, ) }, })) - // Pins and the segment histories of the first fixed seed, so these checks - // are reproducible; they are built when a test runs, like a lane's. - const fixedHistories = () => { - const { examples, segmentOf } = examplesFor(source.fixedSeeds[0] ?? 0) + // Fixed histories and the constructed histories of the first fixed seed, so + // these checks are reproducible; they are built when a test runs, like a + // campaign's. + const namedHistories = () => { + const { examples, constructionOf } = examplesFor(source.fixedSeeds[0] ?? 0) return [ - ...(source.pins ?? []).map(({ name, history }) => ({ - name: `pin: ${name}`, + ...(source.fixed ?? []).map(({ name, history }) => ({ + name: `fixed history: ${name}`, history, })), ...examples.map(([history]) => ({ - name: `segment: ${segmentOf.get(history)!.name}`, + name: `constructed history: ${constructionOf.get(history)!.name}`, history, })), ] } // Determinism also covers three generated histories, so a property whose - // driver varies is caught even without pins or segments. + // driver varies is caught even without fixed or constructed histories. const determinism = determinismTest(options, () => [ - ...fixedHistories(), + ...namedHistories(), ...fc .sample(source.arbitrary, { seed: source.fixedSeeds[0] ?? 0, numRuns: 3 }) .map((history, index) => ({ name: `generated ${index}`, history })), ]) // A calibration also tries a fixed sample of generated histories. const calibrations = calibrationTests(options, () => [ - ...fixedHistories(), + ...namedHistories(), ...fc .sample(source.arbitrary, { seed: source.fixedSeeds[0] ?? 0, @@ -933,7 +949,13 @@ function generatedTests( }) .map((history, index) => ({ name: `sample ${index}`, history })), ]) - return [...fixedLanes, randomLane, ...pins, ...calibrations, determinism] + return [ + ...fixedCampaigns, + randomCampaign, + ...fixedTests, + ...calibrations, + determinism, + ] } // A collect-only run (ORACLE_MANIFEST set) records each registration, so the diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index e66371233b..991b69a970 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -866,7 +866,7 @@ export type OracleReplayManifestEntry = { /** * `browser-replay` names a Playwright owner. The guarded Vitest runner * cannot witness it, so it rejects the target. Replay it with `command`. - * `seed-replay` names a property whose generator has no shrink path. A + * `reseeded-campaign` names a property whose generator has no shrink path. A * seed alone reproduces its history: `command` runs the owner directly * with TANSTACK_DB_ORACLE_SEED, which reseeds its random campaign. The * guarded runner, which witnesses a seed and path, rejects it. @@ -875,7 +875,7 @@ export type OracleReplayManifestEntry = { | `assertion` | `statistics-only` | `browser-replay` - | `seed-replay` + | `reseeded-campaign` | `no-named-owner` file?: string /** The module that names the property, when the owner file imports it. */ @@ -886,7 +886,7 @@ export type OracleReplayManifestEntry = { environment: ReadonlyArray } -// These patterns name the random/replay lane, never its fixed-seed sibling. +// These patterns name the random/replay campaign, never its fixed-seed sibling. // Pagination cells carry their property name in the test title so the guard // can select one cell without running the rest of the matrix. const replayTestNames = new Map([ @@ -1045,7 +1045,7 @@ const replayTestNames = new Map([ // Properties whose own seeded generator replaces fast-check, so they have no // shrink path. -const seedReplayProperties = new Set([`evaluators.like`]) +const reseededProperties = new Set([`evaluators.like`]) function directReplayTestName(property: string): string | undefined { if (property.startsWith(`pagination.limit-admission-`)) { @@ -1108,8 +1108,8 @@ export const oracleReplayManifest: ReadonlyArray = [ ? `no-named-owner` : file.endsWith(`.spec.ts`) ? `browser-replay` - : seedReplayProperties.has(property) - ? `seed-replay` + : reseededProperties.has(property) + ? `reseeded-campaign` : property.endsWith(`-statistics`) || property.endsWith(`.scenario-statistics`) ? `statistics-only` @@ -1135,7 +1135,7 @@ export const oracleReplayManifest: ReadonlyArray = [ ? undefined : status === `browser-replay` ? `pnpm exec playwright test --config playwright.indexed-db.config.ts ${file.split(`/`).slice(2).join(`/`)}` - : status === `seed-replay` + : status === `reseeded-campaign` ? `pnpm exec vitest run ${file.split(`/`).slice(2).join(`/`)}` : outsidePackages ? `node --import tsx ${`../`.repeat(cwd!.split(`/`).length)}packages/db/tests/oracle-replay.ts ${outsideConfig === undefined ? `` : `--config ${outsideConfig} `}${file.split(`/`).pop()}` @@ -1145,7 +1145,7 @@ export const oracleReplayManifest: ReadonlyArray = [ ? [ `TANSTACK_DB_ORACLE_STATISTICS=1 (sampling only; no exact assertion replay)`, ] - : status === `seed-replay` + : status === `reseeded-campaign` ? [`TANSTACK_DB_ORACLE_SEED`] : [ `TANSTACK_DB_ORACLE_SEED`, diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index ee293483c4..f7baddd181 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -98,7 +98,7 @@ describe(`guarded oracle replay`, () => { expect(result.output).not.toContain(`unrelated fixed campaign ran`) }, 40_000) - it(`intersects a caller alternation with the named replay lane`, () => { + it(`intersects a caller alternation with the named replay campaign`, () => { const result = runReplay([ fixture, `-t`, @@ -157,7 +157,7 @@ describe(`guarded oracle replay`, () => { ), ), ] as const)( - `selects only the requested %s replay lane`, + `selects only the requested %s replay campaign`, (property, file, expectedTests) => { const result = runReplay([file], undefined, property) expect(result.status, result.output).toBe(0) @@ -344,7 +344,7 @@ describe(`oracle replay reporting`, () => { it(`runs a path replay once, and only for its selected property`, () => { vi.stubEnv(`TANSTACK_DB_ORACLE_REPLAY_WITNESS`, undefined) const replay = { - replaySeed: 42, + campaignSeed: 42, replayPath: `3`, replayProperty: `oracle-replay.calibration`, } @@ -623,12 +623,12 @@ describe(`named oracle replay manifest`, () => { ).toEqual([]) }) - it(`replays a property without a shrink path from its seed alone`, () => { + it(`reseeds a property from its campaign seed alone`, () => { // The LIKE oracle draws its cases from its own seeded generator, so the // guarded runner, which witnesses a seed and a path, cannot witness it. expect( oracleReplayManifest - .filter(({ status }) => status === `seed-replay`) + .filter(({ status }) => status === `reseeded-campaign`) .map(({ property, command, environment }) => ({ property, command, diff --git a/packages/db/tests/oracle-replay.ts b/packages/db/tests/oracle-replay.ts index 3d0e11b37b..df0ff476ff 100644 --- a/packages/db/tests/oracle-replay.ts +++ b/packages/db/tests/oracle-replay.ts @@ -49,8 +49,8 @@ function selectReplayTest(args: Array, pattern: string): Array { forwarded.push(argument) } } - // The manifest selects the named random/replay lane. Caller filters can - // narrow that lane, but cannot replace it with a fixed or unrelated test. + // The manifest selects the named random or replay campaign. Caller filters can + // narrow that campaign, but cannot replace it with a fixed or unrelated test. const intersection = `^${filters .map((filter) => `(?=[\\s\\S]*(?:${filter}))`) .join(``)}` @@ -111,14 +111,14 @@ try { const reached = witnesses.filter( (witness) => witness.property === property && - witness.seed === config.replaySeed && + witness.seed === config.campaignSeed && witness.path === config.replayPath && witness.numRuns > 0, ) console.log( `Oracle replay: ${JSON.stringify({ property, - seed: config.replaySeed, + seed: config.campaignSeed, path: config.replayPath, owner: entry.file, command: [process.execPath, vitest, `run`, ...args], diff --git a/packages/db/tests/production-error-messages-oracle.test.ts b/packages/db/tests/production-error-messages-oracle.test.ts index f44e6e3965..3fca4fea4b 100644 --- a/packages/db/tests/production-error-messages-oracle.test.ts +++ b/packages/db/tests/production-error-messages-oracle.test.ts @@ -509,7 +509,7 @@ describe(`production error messages`, () => { }) // The census is the classifier these laws rest on, so its rules are - // pinned on hand-written cases: one per rule, in fixtures/census. + // fixed on hand-written cases: one per rule, in fixtures/census. it(`classifies guarded, unguarded, and coded census cases`, () => { const cases = findErrorSites( resolve(testsDirectory, `fixtures/census/src`), diff --git a/packages/db/tests/production-error-messages.test.ts b/packages/db/tests/production-error-messages.test.ts index 3693439939..c932b9edb7 100644 --- a/packages/db/tests/production-error-messages.test.ts +++ b/packages/db/tests/production-error-messages.test.ts @@ -1,5 +1,5 @@ /** - * Ordinary coverage pin for production error messages: every exported error + * Ordinary fixed-example coverage for production error messages: every exported error * class has sample inputs. The production-message law is owned by * production-error-messages-oracle.test.ts. */ diff --git a/packages/db/tests/proxy-detachment-contract.test.ts b/packages/db/tests/proxy-detachment-contract.test.ts index 11429067df..aef020aeeb 100644 --- a/packages/db/tests/proxy-detachment-contract.test.ts +++ b/packages/db/tests/proxy-detachment-contract.test.ts @@ -2,7 +2,7 @@ * Ordinary detachment regressions for draft values stored in a Collection. * The law and its independent model are owned by * proxy-detachment-contract-oracle.test.ts, which also runs the generated - * descriptor-write property. These tests pin literal expectations and do not + * descriptor-write property. These tests fix literal expectations and do not * compare against a model. */ diff --git a/packages/db/tests/proxy-native-methods-oracle.property.test.ts b/packages/db/tests/proxy-native-methods-oracle.property.test.ts index 8bc5546cd6..69606eafd0 100644 --- a/packages/db/tests/proxy-native-methods-oracle.property.test.ts +++ b/packages/db/tests/proxy-native-methods-oracle.property.test.ts @@ -71,7 +71,7 @@ const it = oracleReplayTest(vitestIt) declareOracle({ check: [expectArrayCall, expectTypedCall] }) describe(`array methods behave like native arrays`, () => { - // The row of the original pinned table, for every method. + // The row of the original fixed table, for every method. it.each(arrayMethods)(`%s on a fixed row gives the native result`, (m) => { for (const k of [0, 1, 3]) expectArrayCall([{ x: 1 }, 2, [{ x: 3 }], { x: 0 }], m, k) @@ -94,7 +94,7 @@ describe(`array methods behave like native arrays`, () => { )(`matches native arrays across generated rows (random)`, expectArrayCall) }) describe(`typed-array methods behave like native typed arrays`, () => { - // The row of the original pinned table, for every method. + // The row of the original fixed table, for every method. it.each(typedMethods)(`%s on a fixed row gives the native result`, (m) => { for (const write of [false, true]) expectTypedCall([3, 1, 2], m, 1, write) }) diff --git a/packages/db/tests/proxy-native-methods.test.ts b/packages/db/tests/proxy-native-methods.test.ts index 6db9f653ef..b94d2cb5e8 100644 --- a/packages/db/tests/proxy-native-methods.test.ts +++ b/packages/db/tests/proxy-native-methods.test.ts @@ -1,5 +1,5 @@ /** - * Ordinary coverage pins for native array and typed-array method tests: every + * Ordinary fixed examples for native array and typed-array method tests: every * method has call arguments, and the fixed campaign reaches each generator * shape. The native-method law is owned by proxy-native-methods-oracle.property.test.ts. */ diff --git a/packages/db/tests/proxy-oracle.test.ts b/packages/db/tests/proxy-oracle.test.ts index 27d4f27593..f9a724fc56 100644 --- a/packages/db/tests/proxy-oracle.test.ts +++ b/packages/db/tests/proxy-oracle.test.ts @@ -20,7 +20,7 @@ import type { FrozenRow, StoredRow } from './proxy-native-rows' * descriptors, keys, iteration, cycles, aliases, and revert-to-original rules. * The input object must remain unchanged. * - * Pinned examples of individual language operations live in proxy.test.ts. + * Fixed examples of individual language operations live in proxy.test.ts. * The array callback oracle is differential: it runs the same callback history on a plain native row and * on a draft, then compares callback results, visit order, reconstructed rows, * change records, peers, and the untouched baseline. Generated two-step diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 31f420969e..d34ad2ec24 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -955,7 +955,7 @@ describe(`draft revert oracle`, () => { expect(keylessWrites).toBeGreaterThanOrEqual(10) }) - // Pinned witnesses. + // Fixed histories. it.each<[string, History]>([ [ `reverting one of two changed fields keeps the other change`, diff --git a/packages/db/tests/query/aggregate-select-oracle.property.test.ts b/packages/db/tests/query/aggregate-select-oracle.property.test.ts index cd25d6a81b..1032dc9ba5 100644 --- a/packages/db/tests/query/aggregate-select-oracle.property.test.ts +++ b/packages/db/tests/query/aggregate-select-oracle.property.test.ts @@ -1802,7 +1802,7 @@ describe(`aggregate select shapes`, () => { }, ) - // Pinned replay of a review counterexample: a grouped select value that + // Fixed regression of a review counterexample: a grouped select value that // holds the group key inside a function must read the group's key. oracleReplayTest(it)(`reads the group key inside a function`, async () => { await runHistory({ @@ -1814,7 +1814,7 @@ describe(`aggregate select shapes`, () => { }) }) - // Pinned replay: a published row keeps the select's field order, also + // Fixed regression: a published row keeps the select's field order, also // when a group key comes before a plain aggregate. oracleReplayTest(it)(`keeps the select's field order`, async () => { await runHistory({ @@ -1829,7 +1829,7 @@ describe(`aggregate select shapes`, () => { }) }) - // Pinned replay of a generated counterexample: an aggregate query without + // Fixed regression of a generated counterexample: an aggregate query without // groupBy whose select wraps its only aggregate still accepts HAVING. oracleReplayTest(it)( `accepts HAVING beside a wrapped aggregate without groupBy`, @@ -1856,7 +1856,7 @@ describe(`aggregate select shapes`, () => { }, ) - // Pinned replay: a parent field stays legal when the include's source + // Fixed regression: a parent field stays legal when the include's source // shadows the parent alias. oracleReplayTest(it)( `reads a parent field under a shadowing child alias`, diff --git a/packages/db/tests/query/aggregate-select.test.ts b/packages/db/tests/query/aggregate-select.test.ts index 2ef5555602..cdba5b9925 100644 --- a/packages/db/tests/query/aggregate-select.test.ts +++ b/packages/db/tests/query/aggregate-select.test.ts @@ -15,7 +15,7 @@ import { import { mockSyncCollectionOptions } from '../utils.js' describe(`aggregate select shapes`, () => { - // Pinned witness: a group key that holds a literal object or NaN matches a + // Fixed history: a group key that holds a literal object or NaN matches a // select value that builds the same literal again. it(`matches group keys holding array, Date and NaN literals`, async () => { const source = createCollection( diff --git a/packages/db/tests/query/async-hold-outcomes.test.ts b/packages/db/tests/query/async-hold-outcomes.test.ts index 3590483138..c348082f7c 100644 --- a/packages/db/tests/query/async-hold-outcomes.test.ts +++ b/packages/db/tests/query/async-hold-outcomes.test.ts @@ -12,7 +12,7 @@ import type { LoadSubsetOptions } from '../../src/types.js' * Initial-query readiness and Effect `skipInitial` suppression wait on held * acquisitions: a live query's lazy demand and an Effect's initial loads. * Model D in `includes-temporal-oracle.test.ts` owns the settle, abort - * (rekey), and truncate-replacement outcomes. This file pins the remaining + * (rekey), and truncate-replacement outcomes. This file covers the remaining * outcomes for one hold at a time: * * - a real load failure disposes a `skipInitial` Effect and delivers nothing; diff --git a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts index ed768a469e..e2e5dc5c52 100644 --- a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts +++ b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts @@ -109,8 +109,8 @@ import type { * positive is the only event a failed flush may publish. * The work counter wraps the iteration, `get` and `has` methods of * each facade's stored rows during the flush and records which facades the - * flush read. A pinned case covers nested facades across two edges: the child - * edge commits, then the parent edge throws. Pinned cases also cover a + * flush read. A fixed history covers nested facades across two edges: the child + * edge commits, then the parent edge throws. Fixed histories also cover a * facade subscriber that throws during publication, which must not hold back * another facade's events, and a restore that throws, which must still close * every facade's deferred events. @@ -774,7 +774,7 @@ const step = fc.record({ settleLocal: fc.integer({ min: 0, max: 5 }).map((n) => n === 0), }) const history = fc.array(step, { minLength: 1, maxLength: 8 }) -// The parts this file declares. runHistory runs one generated or pinned +// The parts this file declares. runHistory runs one generated or fixed // history through the real adapter and compares it with the model after each // flush; checkEvents is its exactly-once publication comparison. The model // predicts the rows that each flush owes, which events it may name, and which @@ -800,7 +800,7 @@ describe(`bucket facade rollback`, () => { ) }) - // Pinned: a flush that reorders a shown row throws. The retried flush must + // Fixed history: a flush that reorders a shown row throws. The retried flush must // still publish the layout change, so the failed flush must not leave its // new order behind as the facade's current order. it(`publishes a reorder on retry after a failed reordering flush`, async () => { @@ -827,7 +827,7 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned: a reorder rolled back after `prepare()`, then sent again. The + // Fixed history: a reorder rolled back after `prepare()`, then sent again. The // rollback must restore the facade's current order, or the second flush // sees no order change and owes a layout revision it does not publish. it(`publishes a reorder sent again after a rolled-back reorder`, async () => { @@ -854,7 +854,7 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned: a failed root commit, then a retry that carries only changes to + // Fixed history: a failed root commit, then a retry that carries only changes to // other buckets, as a parent-only write would. The retry must still publish // the facade change that was pending when the root commit failed. it(`publishes a pending facade change after a failed root commit`, async () => { @@ -902,7 +902,7 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned: a failed flush that writes two existing buckets and retires a + // Fixed history: a failed flush that writes two existing buckets and retires a // third restores all three, then the retried flush applies everything. it(`restores two written buckets and a retired bucket, then retries`, async () => { await runHistory([ @@ -935,7 +935,7 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned replay of a generated failure: an update to a row that an earlier + // Fixed regression of a generated failure: an update to a row that an earlier // flush wrote behind a still-persisting transaction keeps the row. it(`updates a row that a persisting transaction still holds`, async () => { await runHistory([ @@ -959,7 +959,7 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned replay of a CI failure: a rollback restores a row that an earlier + // Fixed regression of a CI failure: a rollback restores a row that an earlier // flush wrote behind a still-persisting transaction. The restore must write // it as an update, because the held insert lands first. it(`restores a held row after a rolled-back update`, async () => { @@ -987,7 +987,7 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned replay of a generated failure: an optimistic row shares a graph + // Fixed regression of a generated failure: an optimistic row shares a graph // id, is rolled back, and then a flush fails while writing the facade. The // failed flush publishes nothing. it(`publishes nothing from a failed flush after a shared optimistic row leaves`, async () => { diff --git a/packages/db/tests/query/bucket-facade-rollback.test.ts b/packages/db/tests/query/bucket-facade-rollback.test.ts index ab8b55381d..c4480bca0a 100644 --- a/packages/db/tests/query/bucket-facade-rollback.test.ts +++ b/packages/db/tests/query/bucket-facade-rollback.test.ts @@ -26,7 +26,7 @@ function kidsOf(row: unknown): unknown { } describe(`bucket facade rollback`, () => { - // Pinned: a list of 50 buckets with 3 rows each, where one insert touches + // Fixed history: a list of 50 buckets with 3 rows each, where one insert touches // one bucket. The flush must read that bucket's rows and no others. it(`reads only the written facade in a wide list`, async () => { const driver = new Driver() @@ -59,7 +59,7 @@ describe(`bucket facade rollback`, () => { () => driver.cleanup(), ) }) - // Pinned: nested facades. A parent facade's row refers to a child facade. + // Fixed history: nested facades. A parent facade's row refers to a child facade. // The child edge commits first, then the parent edge's commit throws. The // failed flush must restore the child's rows, keep the parent's reference // to the old child facade, and drop the child facade it created. The retry diff --git a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts index 9a018c1e77..b3b33f8b32 100644 --- a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts @@ -839,7 +839,7 @@ declareOracle({ }) // All atom classes occur in the initial matrix. Partial matches differ only -// in the last term; nulls occur in both tuple positions. The pinned scenario +// in the last term; nulls occur in both tuple positions. The fixed scenario // leaves, restores, deletes and reinserts a match on each side. const compoundBoundaryRows = compoundAtoms().map((_, atom) => ({ id: atom, diff --git a/packages/db/tests/query/compiler/evaluators-oracle.test.ts b/packages/db/tests/query/compiler/evaluators-oracle.test.ts index 88b2312d1a..23cc0287a2 100644 --- a/packages/db/tests/query/compiler/evaluators-oracle.test.ts +++ b/packages/db/tests/query/compiler/evaluators-oracle.test.ts @@ -20,8 +20,8 @@ import { declareOracle } from '../../oracle-declaration.js' * * History grammar: each case contains a value, a pattern, and a case-fold flag. * The small alphabet concentrates on literal/wildcard precedence, `_`, case, - * newlines, and non-ASCII code units. Every generated string is legal. Pinned - * witnesses reconstruct the reported `%` collision; removing `%` removes that + * newlines, and non-ASCII code units. Every generated string is legal. Fixed + * histories reconstruct the reported `%` collision; removing `%` removes that * distinction, while empty and literal-only cases exercise its boundaries. * * Production driver: compile a public `like` or `ilike` expression and invoke diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index ecf981355f..d878668429 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -1110,7 +1110,7 @@ describe(`evaluators`, () => { it(`equivalent forms compare equal but eq returns false`, () => { // PT60M and PT1H represent the same duration. .compare() ranks them // as equal, but .equals() (structural, field-by-field) does not, so - // eq returns false. This pins the asymmetry — and verifies we + // eq returns false. This records the asymmetry — and verifies we // dispatch to .compare() rather than lex-comparing toString output, // which would say "PT1H" < "PT60M". const sixtyMinutes = Temporal.Duration.from(`PT60M`) diff --git a/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts b/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts index 653f5e5dd6..8b1ebe06eb 100644 --- a/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts +++ b/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts @@ -259,7 +259,7 @@ describe(`in evaluator`, () => { false, ], [`an empty list`, `a`, [], false], - ] as const)(`pins %s`, (_name, value, list, expected) => { + ] as const)(`fixed history %s`, (_name, value, list, expected) => { const testCase = { value, list: [...list], listFromRow: false } expect(referenceIn(value, testCase.list)).toBe(expected) check(evaluateWithProduction, testCase) diff --git a/packages/db/tests/query/group-result-key-oracle.property.test.ts b/packages/db/tests/query/group-result-key-oracle.property.test.ts index c3a03e53a7..700dcf6502 100644 --- a/packages/db/tests/query/group-result-key-oracle.property.test.ts +++ b/packages/db/tests/query/group-result-key-oracle.property.test.ts @@ -129,7 +129,7 @@ const historyArbitrary: fc.Arbitrary = fc.record({ }) // Each lookalike pair publishes together, then one member's group changes. -const pinnedHistories: ReadonlyArray = [ +const fixedHistories: ReadonlyArray = [ { rows: [null, `null`, undefined, `undefined`], changes: [{ kind: `move`, index: 0, g: `null` }], @@ -261,8 +261,8 @@ async function runHistory(history: History, reach?: Reach): Promise { declareOracle({ check: [runHistory], model: [expectedGroups] }) describe(`grouped result key oracle`, () => { - for (const [index, history] of pinnedHistories.entries()) { - it(`keeps lookalike group values apart (pinned ${index})`, () => + for (const [index, history] of fixedHistories.entries()) { + it(`keeps lookalike group values apart (fixed history ${index})`, () => runHistory(history)) } diff --git a/packages/db/tests/query/identity-output-shape.test.ts b/packages/db/tests/query/identity-output-shape.test.ts index dc31e81103..7a50e60160 100644 --- a/packages/db/tests/query/identity-output-shape.test.ts +++ b/packages/db/tests/query/identity-output-shape.test.ts @@ -1,4 +1,4 @@ -// Hand-written equality pairs that pin the value relation, and the public group +// Hand-written equality pairs that check the value relation, and the public group // rows they produce. identity-output-shape-oracle.test.ts owns the law and its // generated campaign. import { Temporal } from 'temporal-polyfill' diff --git a/packages/db/tests/query/includes-collection-oracle.property.test.ts b/packages/db/tests/query/includes-collection-oracle.property.test.ts index 7b588b2f4c..7b1d7a8a14 100644 --- a/packages/db/tests/query/includes-collection-oracle.property.test.ts +++ b/packages/db/tests/query/includes-collection-oracle.property.test.ts @@ -80,9 +80,9 @@ import type { * * The history grammar varies parent and child writes, route movement, shared * buckets, dormant buckets, ordering, parent-dependent filters, and joins. It - * also varies optimistic confirmation and rollback. A bounded exhaustive lane covers every - * two-step history in the smallest relationship domain. Random histories add - * longer combinations and shrink failures. + * also varies optimistic confirmation and rollback. A bounded exhaustive + * campaign covers every two-step history in the smallest relationship domain. + * Random histories add longer combinations and shrink failures. * * The Map model stores current source Collection rows. It combines all D2 * relation, route, and bucket state into the public result recomputation; it @@ -91,15 +91,15 @@ import type { * * Grammar controls: the fixed route-retirement, dormant-bucket, shared-bucket, * and child-order witnesses reconstruct the key relationship histories. The - * generated action lane needs both parent and child writes: removing either + * generated action grammar needs both parent and child writes: removing either * loses route transitions or child-only facade changes. Its IDs (parents 0–3, * children 10–14), groups (-3–3), values (-5–5), and 1–16 actions are bounded; - * the exhaustive lane includes empty initial relations and zero actions. A + * the bounded exhaustive campaign includes empty initial relations and zero actions. A * repeated key becomes an update, so the grammar rejects a duplicate insert. - * The public-key lane crosses one-digit and two-digit IDs to expose lexical + * The public-key grammar crosses one-digit and two-digit IDs to expose lexical * sorting; allowing equal IDs would collapse the two-row premise. The order - * lane uses adjacent swaps among 2–8 rows; a one-row case cannot move. The - * optimistic lane uses group/value -10–10 and disjoint IDs 10–100/101–200. + * grammar uses adjacent swaps among 2–8 rows; a one-row case cannot move. The + * optimistic grammar uses group/value -10–10 and disjoint IDs 10–100/101–200. * Its rollback and confirmation steps distinguish temporary from retained * child rows; a delete of an absent child is excluded. * @@ -383,7 +383,7 @@ const optimisticChildArbitrary = fc.record({ value: fc.integer({ min: -10, max: 10 }), }) -// Enumeration proves the small finite domain. The generated lane explores +// Enumeration proves the small finite domain. The generated campaign explores // longer histories over a wider value domain. Neither replaces the other. function enumerateActionSequences( actions: ReadonlyArray, diff --git a/packages/db/tests/query/includes-collection.test.ts b/packages/db/tests/query/includes-collection.test.ts index af71b98182..844366cca3 100644 --- a/packages/db/tests/query/includes-collection.test.ts +++ b/packages/db/tests/query/includes-collection.test.ts @@ -1,7 +1,7 @@ // Fixed-scenario Collection-valued include tests: each runs one hand-written // history, or a fixed structural observation, and checks literal facade rows, // events, and states. These are unit tests. The generated facade laws, the -// Map model, and the exhaustive relationship lane are owned by +// Map model, and the bounded exhaustive relationship campaign are owned by // includes-collection-oracle.property.test.ts. import { test as fcTest } from '@fast-check/vitest' import { describe, expect } from 'vitest' diff --git a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts index 665db3932d..fbaaa7e88a 100644 --- a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts +++ b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts @@ -594,7 +594,7 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( }), ) -// The three pinned examples below reconstruct the named in-range witnesses +// The three fixed histories below reconstruct the named in-range witnesses // in both generated campaigns. Each coordinate has positive probability in // the finite record grammar; examples keep them stable if sampling changes. // Group controls shared vs split/moved/unmatched routes; position controls @@ -1474,7 +1474,7 @@ describe(`includes cross-formulation oracle`, () => { ) expect(String(failure.errorInstance)).toContain(`checkpoint":0`) if (directReplay) { - expect(failure.seed).toBe(replay.replaySeed) + expect(failure.seed).toBe(replay.campaignSeed) expect(replay.replayPath).toBe( `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, ) @@ -1547,7 +1547,7 @@ describe(`includes cross-formulation oracle`, () => { ) expect(String(failure.errorInstance)).toContain(`checkpoint":0`) if (directReplay) { - expect(failure.seed).toBe(replay.replaySeed) + expect(failure.seed).toBe(replay.campaignSeed) expect(replay.replayPath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`) return } diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index efef085603..7ef99ad087 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -1496,7 +1496,7 @@ function transitionHistoryPlacements( }> { // A rekey needs one child level so it changes relationship membership. It // also fails when two descendant levels remain. The single-transition matrix - // pins that failure and will turn red when this continuation can be widened. + // covers that failure and will turn red when this continuation can be widened. const minimumTargetLevel = firstTransition === `rekey` || secondTransition === `rekey` ? Math.max(1, depth - 1) @@ -4727,7 +4727,7 @@ const scopedNoteRows: Array = [ { id: 200, partId: 2, clientId: 1 }, ] -function pinnedScenario( +function fixedScenario( shape: ScopedShape, aliases: ScopedAliases, ): ScopedScenario { @@ -4748,10 +4748,10 @@ function pinnedScenario( * overbroad shadowing check once rejected. Together they reach every optimizer site that once re-minted a * SourceId; the review record maps each site to its killing witness. */ -const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ +const fixedScopeWitnesses: Array<[string, ScopedScenario]> = [ [ `an include reuses the join alias of a joined subquery`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { @@ -4765,7 +4765,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the second join alias`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { ...plainSubquery, joins: `refsThenNotes` }, @@ -4776,7 +4776,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the source alias of an ordered, limited subquery`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { ...plainSubquery, body: `orderedLimit` }, @@ -4787,7 +4787,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the source alias of a DISTINCT subquery`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { ...plainSubquery, body: `distinct`, subSelect: `fields` }, @@ -4798,7 +4798,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the source alias of a joined union branch`, - pinnedScenario( + fixedScenario( { topology: `unionBranch`, subquery: { ...plainSubquery, subSelect: `fields` }, @@ -4816,7 +4816,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include on a joined union reuses a branch alias`, - pinnedScenario( + fixedScenario( { topology: `unionParent`, includeSource: `refs` }, { activeBranch: `x`, inactiveBranch: `y`, anchor: `a`, include: `x` }, ), @@ -4824,7 +4824,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ [ `an include shadows its parent source alias`, { - ...pinnedScenario( + ...fixedScenario( { topology: `fromSubquery`, subquery: plainSubquery, @@ -4844,7 +4844,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `a nested include source shadows its outer include source`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: plainSubquery, @@ -4862,7 +4862,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `a join subquery reuses the alias of a collapsed pure wrapper`, - pinnedScenario( + fixedScenario( { topology: `wrapper`, wrapperJoin: `inner` }, { wrapped: `x`, joinSub: `k`, joinSource: `x` }, ), @@ -4877,7 +4877,7 @@ describe(`includes alpha-renaming across lexical scopes`, () => { () => expectScopedAlphaRenamingHolds( { - ...pinnedScopeWitnesses[0]![1], + ...fixedScopeWitnesses[0]![1], parts: [ { id: 1, active: true }, { id: 2, active: true }, @@ -4892,7 +4892,7 @@ describe(`includes alpha-renaming across lexical scopes`, () => { ), ) - for (const [name, scenario] of pinnedScopeWitnesses) { + for (const [name, scenario] of fixedScopeWitnesses) { for (const mode of [`eager`, `onDemand`] as const) { fcTest(`${name} [${mode}]`, () => expectScopedAlphaRenamingHolds({ ...scenario, mode }), @@ -4914,7 +4914,7 @@ describe(`includes alpha-renaming across lexical scopes`, () => { } }) -// A real active-bucket receiving lane extends this owner's numeric route +// A real active-bucket receiving path extends this owner's numeric route // domain with native route-string collisions. The same full-tree recompute // judges inline arrays and Collection facades after each source transaction. // The fixture proves only the collision premise; it computes no expected rows. diff --git a/packages/db/tests/query/includes-publication-oracle.test.ts b/packages/db/tests/query/includes-publication-oracle.test.ts index a4f8ad5b6b..ac38c82d51 100644 --- a/packages/db/tests/query/includes-publication-oracle.test.ts +++ b/packages/db/tests/query/includes-publication-oracle.test.ts @@ -597,7 +597,7 @@ function publicationCampaigns(baseRuns: number, property: string) { if (replay.replayPath !== undefined && replay.replayProperty !== property) return [] const options = oraclePropertyOptions(baseRuns, property) - if (replay.replaySeed !== undefined) return [{ name: `replay`, options }] + if (replay.campaignSeed !== undefined) return [{ name: `replay`, options }] return [ { name: `fixed seed ${fixedPublicationSeed}`, diff --git a/packages/db/tests/query/includes-query-shape-oracle.test.ts b/packages/db/tests/query/includes-query-shape-oracle.test.ts index d5507b0e8c..a3ec587a17 100644 --- a/packages/db/tests/query/includes-query-shape-oracle.test.ts +++ b/packages/db/tests/query/includes-query-shape-oracle.test.ts @@ -30,7 +30,7 @@ import type { TraceDriver, TraceProjection } from '../trace-runner.js' * law stronger. * * Stable campaigns preserve the histories that exposed these distinctions. - * Fresh random campaigns vary their value domains. Pinned examples cover route + * Fresh random campaigns vary their value domains. Fixed histories cover route * movement and repeated retirement because those laws need ordered histories, * not more random scalar values. * @@ -455,7 +455,7 @@ declareOracle({ }) function campaigns(fixedSeed: number, property: string) { - // Grammar controls: the pinned 1-child case reconstructs final retirement, + // Grammar controls: the fixed 1-child history reconstructs final retirement, // while counts 2–5 keep a surviving contributor. Equal route keys are the // control; distinct order and part IDs expose the wrong-alias result. // Removing null or route updates loses empty-value or reactivation behavior. diff --git a/packages/db/tests/query/includes-route-grammar-oracle.ts b/packages/db/tests/query/includes-route-grammar-oracle.ts index 9142339884..43c3664f6f 100644 --- a/packages/db/tests/query/includes-route-grammar-oracle.ts +++ b/packages/db/tests/query/includes-route-grammar-oracle.ts @@ -37,7 +37,7 @@ import type { ChildRow, RootRow } from './includes-structural-support.js' * the expected result. * * The trace runner checks each intermediate cut that the contract exposes. - * Focused pinned histories preserve small boundary cases. Random or replayed + * Focused fixed histories preserve small boundary cases. Random or replayed * campaigns explore longer action sequences and wider route values. Other * include suites own Collection facades, demand lifetime, functional callbacks, * and publication coherence. diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index cb960e7dff..4498652f67 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -83,7 +83,7 @@ import type { * imply one physical acquisition or one provider transport. The fast/late * phase is a fixture clock around source startup, not an ordered query window. * fast-check schedules current and obsolete completions in new orders, while - * fixed scheduler orders pin both directions. + * fixed scheduler orders cover both directions. * * The production drivers use real Collections, compiled includes, applied * receipts, release callbacks, replay barriers, and source writes. They observe @@ -486,7 +486,7 @@ function expectReleaseReentryObservation( // "the first query loads immediately (on-demand)" (TanStack DB 0.5 release // post). An eager source serves no subset demand, so its Collection readiness // is the only evidence. The architecture's initial-readiness law states this -// rule; this lane is its executable owner. +// rule; this oracle is its oracle owner. // // The law has three kinds of consumer. A live-query Collection becomes ready, // and its preload settles, at initial-query readiness. A one-shot read resolves @@ -529,7 +529,7 @@ function expectReleaseReentryObservation( // The live-query shapes put the lagging source at a direct root, a // single-result root, an ordered root window, a correlated include child, // the lazy side of a join, an eager root that sets `syncMode: 'eager'`, and -// an eager root that leaves `syncMode` at its default. The Effect lane uses +// an eager root that leaves `syncMode` at its default. The Effect production path uses // the direct, lazy-join, and both eager-root shapes; Effects over an ordered // window or an include are not covered here. The eager roots are the nearby // witnesses: a design that ignores source readiness entirely completes them @@ -552,7 +552,7 @@ function expectReleaseReentryObservation( // and controlled source writes. Each step is followed by a timer turn. Each // checkpoint compares the consumer's observation and the number of // acquisitions; the ordered shape records only that it acquired. A rejected acquisition, source error, and later demand changes -// are outside this lane; Models A to C, the Effect disposal oracle, and the +// are outside this grammar; Models A to C, the Effect disposal oracle, and the // subscription lifecycle oracle own them. The Electric live-query tests hold // the receiving witnesses for a real progressive source and for an on-demand // source that is ready before its subset loads. @@ -1276,8 +1276,8 @@ async function expectLaggingSourceTraceMatches( }) } -// Each generated lane uses one grammar and one check in both campaigns. A -// seed-and-path replay selects only its requested random lane. +// Each generated campaign uses one grammar and one check in both campaigns. A +// seed-and-path replay selects only its requested random campaign. function temporalCampaigns(property: string, fixedSeed: number) { return [ { diff --git a/packages/db/tests/query/includes-work-counter-oracle.test.ts b/packages/db/tests/query/includes-work-counter-oracle.test.ts index 0219ff873c..6dd7850f55 100644 --- a/packages/db/tests/query/includes-work-counter-oracle.test.ts +++ b/packages/db/tests/query/includes-work-counter-oracle.test.ts @@ -50,8 +50,8 @@ import type { * The input grammar varies an integer filler count from 1 through 24. Zero is * the ablated baseline; negative and fractional counts are invalid fixture * sizes. Joined queries vary unrelated links and join targets separately. - * The join-free query grows every source together. Counts 1, 2, and 3 pin the - * smallest valid boundary, 24 pins the upper bound, and fixed and random + * The join-free query grows every source together. Counts 1, 2, and 3 cover the + * smallest valid boundary, 24 covers the upper bound, and fixed and random * campaigns sample the range. * * The boundary conditions matter. Collections preload before they receive @@ -159,17 +159,17 @@ describe(`includes deterministic work-counter oracle`, () => { }) it.each([1, 2, 3])( - `pins the work bound at the small filler boundary (%i)`, + `checks the work bound at the small filler boundary (%i)`, expectCorrelatedJoinWorkBound, ) - it(`pins the joined-link filler range endpoint`, () => + it(`checks the joined-link filler range endpoint`, () => expectCorrelatedJoinWorkBound(24)) it.each([1, 24])( - `pins join-target growth at filler count %i`, + `checks join-target growth at filler count %i`, expectJoinTargetWorkBound, ) it.each([1, 24])( - `pins join-free growth at filler count %i`, + `checks join-free growth at filler count %i`, expectJoinFreeWorkBound, ) @@ -276,8 +276,8 @@ describe(`includes deterministic work-counter oracle`, () => { * Collection delivers more change batches to the query than the initial load * at preload, or than the step's own parent write. The generated campaigns * skip only the work bound at refill checkpoints and still check the rows. - * Replays pin every known trace and allow exactly the one extra child row - * each shows, and pinned forward moves allow none. Each generated campaign + * Fixed regressions cover every known trace and allow exactly the one extra child row + * each shows, and fixed forward moves allow none. Each generated campaign * counts the forward moves its own runs check strictly and requires some. * * ## Histories @@ -1061,12 +1061,12 @@ declareOracle({ // reconstruction check below counts them. const WINDOW_RUNS = 60 -/** A pinned case: its strict forward-move tally is not reach evidence. */ +/** A fixed history: its strict forward-move tally is not reach evidence. */ async function expectWindowedIncludeWork(testCase: WindowCase): Promise { await checkWindowedIncludeWork(testCase) } -const pinnedWindowCase: WindowCase = { +const fixedWindowCase: WindowCase = { parents: 8, groups: 8, copies: [], @@ -1087,7 +1087,7 @@ describe(`includes work under an ordered parent window`, () => { `loads children only for a late page's parents (distinct=%s)`, (distinct) => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, parents: 40, groups: 40, childrenPerGroup: 3, @@ -1102,7 +1102,7 @@ describe(`includes work under an ordered parent window`, () => { `moves the parent window, not the %s child's`, (childForm) => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, childForm, history: [{ type: `move`, offset: 3, limit: 2 }], }), @@ -1110,7 +1110,7 @@ describe(`includes work under an ordered parent window`, () => { it(`collapses parents whose selected values match under distinct`, () => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, copies: [ { pick: 1, group: true }, { pick: 2, group: false }, @@ -1122,7 +1122,7 @@ describe(`includes work under an ordered parent window`, () => { it(`creates no static child demand from a static parent filter`, () => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, childSource: `on-demand`, parentFilter: [0, 1, 2, 3, 4], offset: 3, @@ -1133,7 +1133,7 @@ describe(`includes work under an ordered parent window`, () => { // parent past the narrowed window must not load them. it(`stops routing rows that setWindow drops from an orderBy-only parent`, () => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, childrenPerGroup: 3, childSource: `on-demand`, parentForm: `orderOnly`, @@ -1145,7 +1145,7 @@ describe(`includes work under an ordered parent window`, () => { it(`loads one page of children for a findOne parent`, () => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, childForm: `findOne`, parentForm: `findOne`, offset: 5, @@ -1155,7 +1155,7 @@ describe(`includes work under an ordered parent window`, () => { // so static child demand must not load their children. it(`creates no static child demand under a window requested before start`, () => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, childForm: `ordered`, childSource: `on-demand`, parentForm: `orderOnly`, @@ -1167,7 +1167,7 @@ describe(`includes work under an ordered parent window`, () => { // its grandchild demand must wait for the window too. it(`creates no static grandchild demand below a windowed parent`, () => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, parents: 10, groups: 10, childrenPerGroup: 1, @@ -1181,7 +1181,7 @@ describe(`includes work under an ordered parent window`, () => { // offset 0 drops nothing, so its static demand still starts at once. it(`creates no static child demand for a findOne moved past its named value before start`, () => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, parents: 21, groups: 11, childrenPerGroup: 1, @@ -1202,7 +1202,7 @@ describe(`includes work under an ordered parent window`, () => { `moves the window forward strictly (%s children, distinct=%s)`, (childSource, distinct) => expectWindowedIncludeWork({ - ...pinnedWindowCase, + ...fixedWindowCase, parents: 40, groups: 40, childrenPerGroup: 2, @@ -1225,7 +1225,7 @@ describe(`includes work under an ordered parent window`, () => { [ `a desc held row moved to the lowest rank`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 24, groups: 12, childrenPerGroup: 1, @@ -1238,7 +1238,7 @@ describe(`includes work under an ordered parent window`, () => { [ `an asc held row moved to the highest rank`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 36, groups: 18, childrenPerGroup: 1, @@ -1250,7 +1250,7 @@ describe(`includes work under an ordered parent window`, () => { [ `a tie at the window edge during preload (late page)`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 38, groups: 19, copies: [{ pick: 735_689_178, group: false }], @@ -1263,7 +1263,7 @@ describe(`includes work under an ordered parent window`, () => { [ `ties at the window edge during preload (two rows)`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 33, groups: 17, copies: [ @@ -1281,7 +1281,7 @@ describe(`includes work under an ordered parent window`, () => { [ `ties at the window edge during preload (first row)`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 11, groups: 6, copies: [ @@ -1298,7 +1298,7 @@ describe(`includes work under an ordered parent window`, () => { [ `a window move while the loader refills`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 33, groups: 17, childrenPerGroup: 1, diff --git a/packages/db/tests/query/join-result-key-oracle.property.test.ts b/packages/db/tests/query/join-result-key-oracle.property.test.ts index 9a951ef7a8..bbd3349f4c 100644 --- a/packages/db/tests/query/join-result-key-oracle.property.test.ts +++ b/packages/db/tests/query/join-result-key-oracle.property.test.ts @@ -29,11 +29,11 @@ * * Calibration: the fixed pair (`a,b`, `c`) versus (`a`, `b,c`) and the pair * (1, `c`) versus (`1`, `c`) collided under the comma encoding; restoring it - * fails both pinned histories and both campaigns. Plain `JSON.stringify` - * printed `Infinity` and `-Infinity` as `null`; restoring it fails the pinned + * fails both fixed histories and both campaigns. Plain `JSON.stringify` + * printed `Infinity` and `-Infinity` as `null`; restoring it fails the fixed * infinity history and both campaigns. Comparing the join index's source-key * prefixes with `===` let a retracted `NaN`-keyed row survive; restoring it - * fails the pinned `NaN` history. + * fails the fixed `NaN` history. * * Known omissions: joins over subqueries, more than two sources, custom * `getKey`, and optimistic mutations are outside this owner. @@ -155,7 +155,7 @@ const historyArbitrary: fc.Arbitrary = fc.record({ // and (1, `c`) and (`1`, `c`) share `[1,c]`. Under plain JSON, (`a`, Infinity) // and (`a`, -Infinity) share `["a",null]`, and so do the unmatched right rows // once `a` moves away. -const pinnedHistories: ReadonlyArray = [ +const fixedHistories: ReadonlyArray = [ { join: `inner`, left: [ @@ -263,11 +263,11 @@ async function runHistory(history: History): Promise { declareOracle({ check: [runHistory], model: [expectedPairs] }) describe(`joined result key oracle`, () => { - // The pinned histories and the fixed campaign stand aside during any path + // The fixed histories and the fixed-seed campaign stand aside during any path // replay. The random campaign below is the one that a replay of this // property selects. if (!oracleReplayRequested()) { - for (const history of pinnedHistories) { + for (const history of fixedHistories) { it(`keeps every ${history.join} join pair for ${history.left .map((row) => typeof row.id === `number` ? String(row.id) : JSON.stringify(row.id), diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index 00a7aafafc..fd39bc5c5c 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -2622,7 +2622,7 @@ describe(`Ordered windows across truncate replays`, () => { history: Array scenario?: OrderedScenario reaches?: keyof OrderedReach - // A replay whose history the law places in a recovery. The pin also + // A fixed history that the law places in a recovery. The history also // holds where the model cannot tell which subscription issued a load // (see Limits). recovers?: true @@ -3692,7 +3692,7 @@ it.each(orderedFailureCampaigns)( ) // A fresh request that fails inside the marker barrier keeps the // query; a later suffix may legitimately end it, so only the - // fresh history is pinned. + // fresh history is checked exactly. if (fresh) { expect(reach.terminalErrors, `fresh request failure`).toBe(0) expect(reach.requestErrors, `fresh request failure`).toBe(1) diff --git a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts index 1ce880490d..a04c03024b 100644 --- a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts @@ -2504,7 +2504,7 @@ describe(`synchronous initial full-source settlement refinement`, () => { // The refinement checks compare production observations with the models they // name. observeHistory compares each history with its finite reference inside // the driver. compareNullable is the nullable-order model; the palette table is -// its authority, and expectNullableOrderMatchesPalette pins the model to it. +// its authority, and expectNullableOrderMatchesPalette anchors the model to it. declareOracle({ check: [ observeHistory, diff --git a/packages/db/tests/query/ordered-lifecycle.test.ts b/packages/db/tests/query/ordered-lifecycle.test.ts index fbdcd30593..c27bb0866f 100644 --- a/packages/db/tests/query/ordered-lifecycle.test.ts +++ b/packages/db/tests/query/ordered-lifecycle.test.ts @@ -110,7 +110,7 @@ describe(`synchronous initial settlement refinement`, () => { // initial load installs every source row. #2055's hazard needs a bounded read // that left an eligible row out; after a full read the window can always // reach the next eligible row, even when a distant row entered through a live - // update first. This pins that the synchronous initial load keeps that + // update first. This checks that the synchronous initial load keeps that // property through later changes. it(`keeps the first eligible joined rows after a synchronous full-source load`, async () => { type Row = { id: number; rank: number; label: string } diff --git a/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts b/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts index 3a5cea4529..f650847d74 100644 --- a/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts +++ b/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts @@ -2415,7 +2415,7 @@ describe(`Ordered source request authority`, () => { // The model's strand trace: a repair supersedes the in-flight tie and // finishes; a newer invalidation's reaction is deferred; the old tie fails. - it(`recovers a superseded tie that fails after its repair finished (generated-lane replay)`, async () => { + it(`recovers a superseded tie that fails after its repair finished (generated campaign replay)`, async () => { await checkAuthorityCase({ indexed: true, limit: 1, @@ -2435,8 +2435,8 @@ describe(`Ordered source request authority`, () => { }) }) - // Traces the generated lane found, kept as replays. Each ran through the - // same laws and failed before its repair: + // Traces the generated campaign found, kept as fixed regressions. Each ran + // through the same laws and failed before its repair: // - a release callback throws while a repair finishes, a superseded tie // fails, and an explicit window move resumes the repair (a throwing // release unwound the chain mid-step, so nothing resumed it); @@ -2453,7 +2453,7 @@ describe(`Ordered source request authority`, () => { // - a repair tie from before a truncate that fails during the next repair // (the dead failure dropped the new chain's staged step, so nothing // resumed it); - // - the same replay trace, once one request at a time was checked (the + // - the same fixed trace, once one request at a time was checked (the // replaced chain's prefix counted as current and issued a tie beside the // replacement's prefix). // - a repair prefix that settles after full-source coverage completed (a @@ -2474,9 +2474,9 @@ describe(`Ordered source request authority`, () => { // start error replaced the request's own error in its participant); // A superseded page that fails before a covering replay settles reaches // the failed-lease law through a replay, which the campaigns rarely do. - // Two replays keep traces that first failed the lane's own model: a chain - // step it took for a chain start (an optimistic refill, and a shrunk - // window's prefix deferred by joined work). + // Two fixed regressions keep traces that first failed the generated + // campaign's own model: a chain step it took for a chain start (an optimistic + // refill, and a shrunk window's prefix deferred by joined work). it.each([ { name: `a throwing release during a window move that resumes a repair`, @@ -2897,7 +2897,7 @@ describe(`Ordered source request authority`, () => { }, }, ] satisfies Array<{ name: string; testCase: AuthorityCase }>)( - `keeps request authority for $name (generated-lane replay)`, + `keeps request authority for $name (generated campaign replay)`, async ({ testCase }) => { await checkAuthorityCase(testCase) }, @@ -2936,7 +2936,7 @@ describe(`Ordered source request authority`, () => { // there is no replay bookkeeping to distinguish from a graph pass. // `deferred-refill` needs a synchronous answer and a release-triggered // truncate together: 6 of the 96 inputs. An eager source never reaches its - // adapter on release, so production cannot pair them; the lane calibrates + // adapter on release, so production cannot pair them; the campaign calibrates // the driver's continuation provenance rather than a reachable product state. // Random draws run twice the fixed count so that rare state stays above its // minimum (expected 25 of 400). @@ -3227,9 +3227,9 @@ describe(`Ordered source request authority`, () => { }, ) - // Fixed replays force a held answer in each real consumer. They use the + // Fixed histories force a held answer in each real consumer. They use the // same model and comparison as the generated receiving campaign, and keep - // hostile-mutant failure independent for the two publication paths. + // mutant failure independent for the two publication paths. it.each([ [`collection`, `move`], [`collection-window`, `move`], diff --git a/packages/db/tests/query/ordered-work-oracle.property.test.ts b/packages/db/tests/query/ordered-work-oracle.property.test.ts index 6b283bd586..4c1fbc4c77 100644 --- a/packages/db/tests/query/ordered-work-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-work-oracle.property.test.ts @@ -78,7 +78,7 @@ import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' * mutation or repair checkpoint. Adapter requests and work counts are separate * observations; they are not public rows or evidence of remote provider work. * - * Counts are contract bounds, not timing benchmarks. They pin established + * Counts are contract bounds, not timing benchmarks. They check established * request and graph-scheduling behavior only where the test names that promise. * These scenarios exercise local evaluation; they do not model a remote adapter * applying a relation hint. @@ -3609,8 +3609,8 @@ describe(`ordered source work oracle`, () => { * `offset + limit + 1` rows. Its rows law holds beside the first query's at * every checkpoint; the work laws count one consumer, so they do not apply * to those cases. A peer over a refetching provider with a joined filter is - * excluded from the grammar until #2155 is fixed: an indexed trace is fixed - * and pinned, but an unindexed peer still keeps a short joined window after + * excluded from the grammar until #2155 is fixed: an indexed trace is kept as a + * fixed history, but an unindexed peer still keeps a short joined window after * a refetch. A peer over a source filter stays in the grammar. * * `null` and `undefined` ranks are both nullish, so they tie: either may @@ -3829,7 +3829,7 @@ const eagerCaseBase: fc.Arbitrary = fc.record({ }) // A peer over a refetching on-demand provider can keep a short joined window -// after a refetch (#2155). An indexed plan's trace is fixed and pinned below, +// after a refetch (#2155). An indexed plan's trace is kept as a fixed history below, // but an unindexed one still fails (a peer shows [100] where the model shows // [100, 101]). Each campaign excludes only a joined, anti-joined, or full-joined // filter in that pairing, as its last map, until the fix; a source filter @@ -4084,7 +4084,7 @@ const eagerHeldCase: fc.Arbitrary = fc // A forwarding provider sends the delete, and an indexed source filter // or a joined filter refills from the provider, so the refill is a held // request. An unindexed source filter issues no held request. With the - // provider pinned, this campaign needs no peer exclusion (#2155 concerns + // provider fixed, this campaign needs no peer exclusion (#2155 concerns // only a refetching provider). provider: `forward` as const, filter: diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 77b8d468d6..9970262460 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -53,19 +53,19 @@ import type { * acquisition releases, visible rows, readiness, errors, and every * publication cut. * - * Finite products pin boundary cells; fixed and random fast-check histories + * Finite products cover boundary cells; fixed and random fast-check histories * explore adjacent legal actions. Fault probes establish that wrong order, * false coverage, duplicate work, partial publication, and bad delete payloads * are observable. The model does not infer rows beyond provider evidence. * - * The fixed C-01 through C-09 custom-collation lane declares an independent + * The fixed C-01 through C-09 custom-collation histories declare an independent * rank relation and literal public-key sequences. It crosses ascending and * descending order, scan and auto-index execution, inherited and per-order * configuration, bounded and unbounded queries, a later ordered source that * inherits the leading source's default, an output Collection with a different * downstream default, comparator-equal keys, and hostile provider order. It - * also requires exactly one initial filtered full-source acquisition. This - * lane establishes local custom ordering only; it does not authorize custom + * also requires exactly one initial filtered full-source acquisition. These + * histories establish local custom ordering only; it does not authorize custom * cursor pagination, comparator-aware range predicates, or backend collation * parity. */ diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index d5c1916c5e..94656c24f1 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -755,7 +755,7 @@ describe(`pooled live query cleanup admission`, () => { // A view read by a subscribed live query is cleaned up and rejects, then // its unobserved sibling is cleaned up; the reader ends and the view's // cleanup is retried. - const pinned: CleanupHistory = { + const fixedHistory: CleanupHistory = { views: [ { g: `x`, observed: false, reader: true }, { g: `y`, observed: false, reader: false }, @@ -768,7 +768,7 @@ describe(`pooled live query cleanup admission`, () => { ], } it(`keeps a view whose cleanup rejected in its partition`, async () => { - await expectPooledCleanupMatchesOrdinary(pinned) + await expectPooledCleanupMatchesOrdinary(fixedHistory) }) for (const campaign of [`fixed`, `random`] as const) { diff --git a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts index 64e62362f1..9c533dbf1f 100644 --- a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts +++ b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts @@ -63,7 +63,7 @@ * * Calibration: a partition that ignored the previous value, kept group rows * in arrival order, or compared literals without normalization fails the - * pinned histories and campaigns. A view that kept reporting the source's + * fixed histories and campaigns. A view that kept reporting the source's * status, or kept its rows, after cleanup fails the cleanup histories. A * view that empties its groups at cleanup without advancing their revisions * leaves the detached reader on its old rows and fails the cleanup @@ -371,9 +371,9 @@ const historyArbitrary: fc.Arbitrary = fc.record({ ), }) -// Each pinned history moves a row across groups a short example would keep +// Each fixed history moves a row across groups a short example would keep // still. -const pinnedHistories: ReadonlyArray<{ name: string; history: History }> = [ +const fixedHistories: ReadonlyArray<{ name: string; history: History }> = [ { name: `a Date row joins the group of its timestamp`, history: { @@ -1058,7 +1058,7 @@ declareOracle({ }) describe(`pooled live query oracle`, () => { - // The pinned histories and the fixed campaign stand aside during any path + // The fixed histories and the fixed-seed campaign stand aside during any path // replay. The random campaign below is the one that a replay of this // property selects. // Only generated histories count toward reach; a replay asserts nothing. @@ -1069,7 +1069,7 @@ describe(`pooled live query oracle`, () => { return runHistory(history, reach) } if (!oracleReplayRequested()) { - // Generated fields are plain values; this pins a field whose getter + // Generated fields are plain values; this covers a field whose getter // throws, which both paths treat as a row that does not match. it(`matches the live-query Collection when an eq path getter throws`, async () => { const source = createCollection( @@ -1105,7 +1105,7 @@ describe(`pooled live query oracle`, () => { await source.cleanup({ dependents: true }) }) - for (const { name, history } of pinnedHistories) { + for (const { name, history } of fixedHistories) { it(`matches the live-query Collection when ${name}`, () => runHistory(history)) } diff --git a/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts index 6456720898..ce14f5f89c 100644 --- a/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts @@ -364,9 +364,9 @@ function sidesOf(history: History): Array { function historyId(history: History): string { return `${pathOf(history)}/confirm-${history.confirm.join(`+`) || `none`}/reject-${history.reject.join(`+`) || `none`}/settle-${history.rightFirst ? `right` : `left`}-first` } -// Declare each finite lane's owed comparisons independently. Aggregate path -// totals would let one omitted right-source action borrow another lane's reach. -// Outcome and confirmation premises determine which cuts are legal in a lane. +// Declare each finite campaign's owed comparisons independently. Aggregate path +// totals would let one omitted right-source action borrow another campaign's reach. +// Outcome and confirmation premises determine which cuts are legal in a campaign. function requiredEffectCuts(history: History): Array { const sides = sidesOf(history) return [ @@ -398,14 +398,14 @@ const effectMetadataReach = createOracleReach( ), ), // This exhaustive grammar has no seed/path replay. An unrelated replay - // environment must not exempt its required finite lanes from reach. + // environment must not exempt its required finite campaigns from reach. { replaying: false }, ) const effectPassedCuts = new Map() let enteredEffectHistories = 0 afterAll(() => { // A checker-only selector enters no production history. Once any native - // history runs, every finite lane still owes its complete declared reach. + // history runs, every finite campaign still owes its complete declared reach. if (enteredEffectHistories > 0) effectMetadataReach.assertReached() if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) console.info( diff --git a/packages/db/tests/query/virtual-row-fields-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-oracle.test.ts index 1e694938c2..9f5295a6e4 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -439,9 +439,10 @@ test.each( const pendingKeys = new Set() let finishMutation = async () => {} const check = () => { - // The custom-key lane has a completely independent key prediction. The - // default lane retains the old clean-sibling key comparison only as a - // differential check of undocumented compiler-generated key encoding. + // The custom-key histories have a completely independent key prediction. + // The default-key histories retain the old clean-sibling key comparison + // only as a differential check of undocumented compiler-generated key + // encoding. const outputKey = (id: AttributionKey) => customKey ? `projected-${id}` @@ -800,7 +801,7 @@ test.each( * A held write in either branch must not change the other's metadata. Removing * and reinserting that peer checks retractions and subsequent contributions. * Each checkpoint reuses the authored-facts model; no sibling query supplies - * expected keys in this lane. + * expected keys in these histories. */ const unionHistories = [false, true].flatMap((reverse) => [false, true].flatMap((nested) => diff --git a/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts b/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts index 7e5c1ed157..ce5235355f 100644 --- a/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts +++ b/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts @@ -64,8 +64,8 @@ * publication, and a restarted source) compare with written values, so they * are unit tests in where-predicate-publication.test.ts. * - * Calibration: pinned histories separate Kleene from two-valued logic, and a - * checker test requires the two-valued answer to fail. Hostile production + * Calibration: fixed histories separate Kleene from two-valued logic, and a + * checker test requires the two-valued answer to fail. Production * mutants that returned FALSE for `eq(string, null)`, prefiltered on `or` * operands as if they were conjuncts, prefiltered number literals past Date or * NaN rows, skipped an empty @@ -369,7 +369,7 @@ const historyArbitrary: fc.Arbitrary = fc.oneof( // Kleene and two-valued logic disagree on each snapshot history. The first is // the negated nullish comparison that a FALSE-for-UNKNOWN fast path misses. -const pinnedHistories: ReadonlyArray = [ +const fixedHistories: ReadonlyArray = [ { kind: `snapshot`, rows: [ @@ -428,7 +428,7 @@ const pinnedHistories: ReadonlyArray = [ // An unindexed snapshot may reject stored rows by one field before copying // them. A pending optimistic row is visible although it is not yet synced. -const pinnedPrefilteredSnapshot: History = { +const fixedPrefilteredSnapshot: History = { kind: `snapshot`, rows: [ { v: `a`, optimistic: true }, @@ -440,7 +440,7 @@ const pinnedPrefilteredSnapshot: History = { // Each change history moves rows across a predicate whose equality operand a // skip-if-no-match shortcut can misread. -const pinnedChangeHistories: ReadonlyArray = [ +const fixedChangeHistories: ReadonlyArray = [ { // `or` operands are alternatives, not conjuncts. kind: `changes`, @@ -792,7 +792,7 @@ async function runHistory(history: History): Promise { // BasicIndex, and compares every consumer's observed key set with the model's // keys after each checkpoint. The model is `expectedTruth`, the Kleene // evaluator, and `expectedVisible`, which adds the touched-row rule for a -// subscriber without initial state. The pinned calibration and the generated +// subscriber without initial state. The fixed calibration and the generated // properties reach them; the witness tests in where-predicate-publication.test.ts // do not. declareOracle({ @@ -830,13 +830,13 @@ function describePredicate(predicate: Predicate): string { // --------------------------------------------------------------------------- describe(`WHERE predicate publication oracle`, () => { - // The pinned histories and the fixed campaign stand aside during any path + // The fixed histories and the fixed-seed campaign stand aside during any path // replay. The random campaign below is the one that a replay of this // property selects. if (!oracleReplayRequested()) { - it(`pinned snapshot histories distinguish Kleene logic from two-valued logic`, () => { + it(`fixed snapshot histories distinguish Kleene logic from two-valued logic`, () => { // A checker that folds UNKNOWN into FALSE before NOT must disagree with - // the model on each pinned snapshot, or the history does not calibrate. + // the model on each fixed snapshot, or the history does not calibrate. const twoValued = (predicate: Predicate, row: ModelRow): boolean => { switch (predicate.kind) { case `eq`: @@ -854,7 +854,7 @@ describe(`WHERE predicate publication oracle`, () => { return predicate.args.some((arg) => twoValued(arg, row)) } } - for (const history of pinnedHistories) { + for (const history of fixedHistories) { if (history.kind !== `snapshot`) throw new Error(`expected a snapshot`) const rows = new Map( history.rows.map((seed, index): [string, ModelRow] => [ @@ -875,9 +875,9 @@ describe(`WHERE predicate publication oracle`, () => { }) for (const history of [ - ...pinnedHistories, - pinnedPrefilteredSnapshot, - ...pinnedChangeHistories, + ...fixedHistories, + fixedPrefilteredSnapshot, + ...fixedChangeHistories, ]) { it(`publishes TRUE rows for ${history.kind} history ${describePredicate(history.predicate)}`, () => runHistory(history)) diff --git a/packages/db/tests/request-errors-work.test.ts b/packages/db/tests/request-errors-work.test.ts index 8fb69fb4fc..78063c8c6f 100644 --- a/packages/db/tests/request-errors-work.test.ts +++ b/packages/db/tests/request-errors-work.test.ts @@ -3,7 +3,7 @@ import { RequestErrors } from '../src/collection/lifecycle.js' /** * Work witness for request-error bookkeeping. The request error bookkeeping - * lane of the source-health oracle owns what a node shows; this file only + * grammar of the source-health oracle owns what a node shows; this file only * checks what it costs. * * A failed lazy segment reports one request error per needed key, all with diff --git a/packages/db/tests/reverse-index-oracle.property.test.ts b/packages/db/tests/reverse-index-oracle.property.test.ts index e5873bc89a..41dc383a90 100644 --- a/packages/db/tests/reverse-index-oracle.property.test.ts +++ b/packages/db/tests/reverse-index-oracle.property.test.ts @@ -43,7 +43,7 @@ import { declareOracle } from './oracle-declaration.js' * is the reader `@tanstack/db` exported before query-aware placement. It * reads the original index's reversed walk unchanged, so its nullish group * sits wherever plain reversal puts it, for BTree and Basic indexes and for - * either null placement of the original. The pinned keys below are the + * either null placement of the original. The fixed keys below are the * results of the earlier release. * - **Capability:** ordered-query admission requires only range operations * (`supports('gt')`). The reader may use the `equalityLookup` every index diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index f3db44cca9..52345055f4 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -186,24 +186,24 @@ * announced, and that each `status:change` is followed at once by * `health:change`. When the expected tree changed, the last event must show * the current tree. `findProblems` must list, in tree order, exactly the - * expected nodes with a problem of their own. Request lanes check that a lazy load or a root Collection's + * expected nodes with a problem of their own. Request grammars check that a lazy load or a root Collection's * load that fails after initial-query readiness is a request error on the query's - * node, and that it clears. Effect lanes check the Effect's gate, and that a + * node, and that it clears. Effect grammars check the Effect's gate, and that a * failed load after it still ends the Effect: an Effect has no request errors. - * The on-demand reload lane checks that a reload of an on-demand Collection + * The on-demand reload grammar checks that a reload of an on-demand Collection * keeps a query's rows until the new run reloads its subset. * * ## Limits * * The main grammar's Collections are eager, so a live query cannot become - * ready while a Collection is still loading; a focused lane enumerates an + * ready while a Collection is still loading; a focused grammar enumerates an * on-demand Collection that fails its first load after the query is ready. * The main grammar reaches a cleanup during a reload only from `health:change` * and only with `{ dependents: true }`; the lazy grammar's `ending` reaches * every notification and also a plain `cleanup()`, but only as a history's * last step, and it does not judge the query a plain cleanup leaves. * The window grammar settles all pending loads at once, and the lazy grammar - * settles all, the oldest, or the newest (the overlapping-load lane owns + * settles all, the oldest, or the newest (the overlapping-load grammar owns * other orders). A demand parked behind a child whose first load failed makes * no request, so the model predicts it from law 10 (maintainer decision). Per-row child Collections are not * in the tree; an include's node shows its source. Pooled histories do not @@ -216,7 +216,7 @@ * it has no truncate, so a truncate replay that would wait for an aborted * load belongs to the subscription lifecycle oracle (its `manual` * cancellation). The shared demand model lets a held load's success clear - * its keys' errors whenever it started; the overlapping-load lane owns the + * its keys' errors whenever it started; the overlapping-load grammar owns the * rule that a root Collection's request error clears only for a load * started after the failure. */ @@ -372,7 +372,7 @@ type Step = // The main grammar reaches the first two; the lazy grammar reaches all four. // Not reached: the retired run's adapter cleanup function and the new run's // `sync()` are adapter code, and a cleanup from them is a cleanup during sync -// entry or adapter cleanup, which other lanes own. +// entry or adapter cleanup, which other grammars own. type ReloadNotice = `source-health` | `query-health` | `loading-subset` | `subscription-status` @@ -3402,7 +3402,7 @@ describe(`source failure and health oracle`, () => { // retain all six shapes and both direct-subscriber choices above. const requiredProperty = `source-health.required-history` const config = readOracleRunConfig() - for (const seed of config.replaySeed === undefined + for (const seed of config.campaignSeed === undefined ? [20261029, undefined] : [undefined]) { const requiredReach = createOracleReach( @@ -3582,7 +3582,7 @@ describe(`overlapping root loads`, () => { }) // --------------------------------------------------------------------------- -// On-demand reload lane +// On-demand reload grammar // --------------------------------------------------------------------------- /** @@ -4471,7 +4471,7 @@ function expectHeldDemandCovered( * window. The query shows two rows ordered by id. Steps: `window` moves the * window two rows on; `settle-ok` settles every pending load, and any load * the query requests after it, until none is pending; `settle-fail` rejects - * every pending load at once (the overlapping-load lane owns the order of + * every pending load at once (the overlapping-load grammar owns the order of * two loads); `settle-aborted-fail` rejects only the loads the query aborted, * with an `AbortError`, as an adapter that honours the signal does, and * `settle-aborted-ok` serves only those loads late, as an adapter whose @@ -5545,7 +5545,7 @@ type LazyReach = ReturnType< // refuses while an Effect reads the child): the child ends `cleaned-up` with no sync // run live at its adapter and the reload settled; a cascade also ends the // query and the Effect. A plain cleanup under the query is a query failure -// that this lane does not judge. Then a fresh start commits one row, and +// that this grammar does not judge. Then a fresh start commits one row, and // that row is both the child's rows and its diff baseline: nothing of the // reload stayed armed. type LazyEnding = { at: ReloadNotice; dependents: boolean } @@ -5567,7 +5567,7 @@ async function runLazyHistory( // The query orders parents by id and shows the first `window`. window?: number // By step index, the keys of each request the adapter receives in that - // step. A named history pins production's requests where a parked + // step. A fixed history asserts production's requests where a parked // prediction rests on them. requests?: Readonly>>> asyncChildReady?: boolean diff --git a/packages/db/tests/source-health.test.ts b/packages/db/tests/source-health.test.ts index 913d1639c8..09133bbf25 100644 --- a/packages/db/tests/source-health.test.ts +++ b/packages/db/tests/source-health.test.ts @@ -26,7 +26,7 @@ import type { import type { PooledView, Row, SyncParams } from './source-health-support.js' // --------------------------------------------------------------------------- -// Request lane +// Request grammar // --------------------------------------------------------------------------- /** @@ -57,7 +57,7 @@ import type { PooledView, Row, SyncParams } from './source-health-support.js' * reload's promise follow the same rules as for an `Error`, and the node's * problem keeps the value as its `cause`. A value that cannot describe * itself (no prototype, a throwing getter) shows the generic message. The - * lane crosses each call site with each kind of value. + * grammar crosses each call site with each kind of value. */ describe(`problem values`, () => { const values: Record unknown> = { @@ -827,7 +827,7 @@ describe(`crash recovery`, () => { // A query whose first load failed waits for `reload()`: another source // becoming ready does not restart it, and its failed load is not retried. The -// main grammar fails only sources, so this lane supplies the query's own +// main grammar fails only sources, so this grammar supplies the query's own // failure. describe(`a query's own failed first load`, () => { it(`does not restart when an unrelated source becomes ready`, async () => { @@ -1538,7 +1538,7 @@ describe(`lazy join demand grammar`, () => { }) // --------------------------------------------------------------------------- -// Effect lane +// Effect grammar // --------------------------------------------------------------------------- /** @@ -1554,7 +1554,7 @@ describe(`lazy join demand grammar`, () => { * problem on the source's node: it does not dispose the Effect, and * `onHealthChange` receives the health tree, with one node per source alias. * - * The lane crosses `skipInitial`, the source mode, and when the source fails. + * The grammar crosses `skipInitial`, the source mode, and when the source fails. * Before initial-query readiness, an eager source has not called `markReady()`, and an * on-demand source still has its first load pending. After it, the * source calls `fail()`, which fails the first load of an on-demand source @@ -1867,7 +1867,7 @@ describe(`effect healthy gate`, () => { }) }) -// The Effect lane above creates the Effect first. A source can already be in +// The Effect grammar above creates the Effect first. A source can already be in // `error` (its first load failed) or `crashed` when the Effect subscribes. // No status change follows, so the Effect must read the status when it // subscribes. Model: the Effect never reaches its gate, so either status ends @@ -1943,7 +1943,7 @@ describe(`effect over a source that already stopped`, () => { * An Effect has no request errors: a load that fails after its gate, as * before it, ends the Effect through `onSourceError`, and no tree it reported * shows the failure. The parents lead the Effect, and a lazy join loads the - * children each new parent row reaches. The lane crosses `skipInitial` with + * children each new parent row reaches. The grammar crosses `skipInitial` with * the parents' source mode. */ describe(`effect load failures`, () => { diff --git a/packages/db/tests/ssr-snapshot-admission-oracle.ts b/packages/db/tests/ssr-snapshot-admission-oracle.ts index 61f562e91e..25650e515f 100644 --- a/packages/db/tests/ssr-snapshot-admission-oracle.ts +++ b/packages/db/tests/ssr-snapshot-admission-oracle.ts @@ -237,7 +237,7 @@ export function expectedSsrRuntimeErrorViews(history: SsrRuntimeErrorHistory) { * hydrated readers and authored browser rows. A mount follows at least one * authoritative reader. Same and different rows, including ready-empty and * loading-empty server views, distinguish row equality from readiness equality. - * Server/browser writes remain in the adjacent handoff grammar: this lane + * Server/browser writes remain in the adjacent handoff grammar: this grammar * isolates a change of receiving reader after a completed handoff. * * Removing initialization kind loses the mount/hydrate distinction. Removing diff --git a/packages/db/tests/sync-metadata-recovery-oracle.ts b/packages/db/tests/sync-metadata-recovery-oracle.ts index 6995c0feb3..0b5747149c 100644 --- a/packages/db/tests/sync-metadata-recovery-oracle.ts +++ b/packages/db/tests/sync-metadata-recovery-oracle.ts @@ -3,7 +3,7 @@ import { declareOracle } from './oracle-declaration.js' import { compareSource, createSessionModel, - persistedPeerOnDemandLane, + persistedPeerOnDemandGrammar, planAction, } from './sync-metadata-session-oracle.js' import type { @@ -78,13 +78,13 @@ export async function runRecoverySchedule( schedule: RecoverySchedule, createDriver: () => Promise, ) { - const lane = persistedPeerOnDemandLane - const model = createSessionModel(lane) + const grammar = persistedPeerOnDemandGrammar + const model = createSessionModel(grammar) const driver = await createDriver() let checkpoints = 0 const checkSource = (cut: string, ended = false) => { const expected = ended - ? createSessionModel({ ...lane, cached: false }).observe() + ? createSessionModel({ ...grammar, cached: false }).observe() : model.observe() const mismatch = compareSource( checkpoints++, diff --git a/packages/db/tests/sync-metadata-session-oracle.property.test.ts b/packages/db/tests/sync-metadata-session-oracle.property.test.ts index 02fb003863..9212d6d345 100644 --- a/packages/db/tests/sync-metadata-session-oracle.property.test.ts +++ b/packages/db/tests/sync-metadata-session-oracle.property.test.ts @@ -11,18 +11,18 @@ import { import { absentMarker, describeMismatch, - flatLane, + flatGrammar, historyRegimes, modelCheckpoints, - nestedConflictLane, - nestedLane, - nestedSeedLane, + nestedConflictGrammar, + nestedGrammar, + nestedSeedGrammar, runHoldInvariance, runKeyIndependence, runSeedOrderInvariance, runSessionHistory, - seedFullLane, - seedLane, + seedFullGrammar, + seedGrammar, sessionCollectionKeys, sessionHistoryArbitrary, } from './sync-metadata-session-oracle.js' @@ -30,7 +30,7 @@ import type { ReceiptState, SessionAction, SessionDriver, - SessionLane, + SessionGrammar, SessionModel, } from './sync-metadata-session-oracle.js' import type { SyncConfig } from '../src/types.js' @@ -177,7 +177,7 @@ async function createCoreDriver(fullRows = false): Promise { collectionSet: (key, value) => metadata().collection.set(key, value), collectionDelete: (key) => metadata().collection.delete(key), hold: () => { - // Core lanes never generate a store hold. A fresh fixture key each time: no source action or truncate names it. + // Core grammars never generate a store hold. A fresh fixture key each time: no source action or truncate names it. blocker = collection.insert({ id: 1000 + holdCount++, v: 0 }) }, release, @@ -238,11 +238,11 @@ function sortedEntries( } async function assertSessionHistory( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Promise { - const { mismatch } = await runSessionHistory(lane, history, () => - createCoreDriver(lane.fullRows), + const { mismatch } = await runSessionHistory(grammar, history, () => + createCoreDriver(grammar.fullRows), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) } @@ -272,7 +272,7 @@ const witnesses = { // source update that merges over that seed and commits ahead does: the // re-insert then lands on a different row (decision 10). lateSeedUnderMergedUpdate: { - lane: nestedSeedLane, + grammar: nestedSeedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -287,7 +287,7 @@ const witnesses = { // The same under a hold: the inner commit stays accepted but unapplied, so // the seed still marks the row. heldLateSeedUnderMergedUpdate: { - lane: nestedSeedLane, + grammar: nestedSeedGrammar, history: [ a(`hold`), a(`seed`, 1), @@ -302,7 +302,7 @@ const witnesses = { }, // Issue #2220: a held earlier write must stay visible after a later commit. heldCursor: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -315,7 +315,7 @@ const witnesses = { }, // The open transaction overlays every held one, not only the newest. openOverHeld: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -327,7 +327,7 @@ const witnesses = { }, // Independent transactions: an outer abort keeps an inner commit. outerAbortKeepsInnerCommit: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`begin`), a(`begin`), @@ -339,7 +339,7 @@ const witnesses = { // Commit order: the outer transaction commits after the inner one, so its // earlier write wins, and a hold does not change that order. commitOrderDuringHold: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`hold`), a(`begin`), @@ -354,7 +354,7 @@ const witnesses = { // During a hold, the outer transaction can still write and commit after an // inner one commits. outerContinuesAfterInnerDuringHold: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`hold`), a(`begin`), @@ -367,7 +367,7 @@ const witnesses = { }, // An inner truncate applies at its own commit, at once despite a hold. innerTruncateAppliesAtItsCommit: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`begin`), a(`upsert`, 1, true), @@ -384,7 +384,7 @@ const witnesses = { // but never makes the source's equal re-insert a duplicate, and the // re-insert still replaces the row: the late seed's extra field goes. seedUnderOpenReinsert: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), { type: `seed`, key: 1, narrow: true, bare: true }, @@ -396,7 +396,7 @@ const witnesses = { }, // A seed never supersedes a source metadata write on a row the source did not write. seedUnderMetadataOnlyWrite: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), a(`rowSet`, 1), @@ -408,7 +408,7 @@ const witnesses = { // insert replaces the seeded row and clears its metadata. Consumers see the // seed until the insert's transaction applies. seedUnderOpenInsert: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), a(`upsert`, 1, false), @@ -419,7 +419,7 @@ const witnesses = { // The same history with the insert committed before the seed arrives: the // outcome does not depend on arrival order. seedAfterCommittedInsert: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), a(`upsert`, 1, false), @@ -430,7 +430,7 @@ const witnesses = { // A source insert equal to a seeded row is still an insert: the source never // wrote the row, so the insert names its metadata and clears the seed's. equalInsertOverSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -440,7 +440,7 @@ const witnesses = { // The latest seed of a row no source write has written wins whole, so a // narrow seed drops the earlier seed's other field. laterSeedReplacesSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), { type: `seed`, key: 1, narrow: true }, @@ -449,7 +449,7 @@ const witnesses = { // Once the source has updated a seeded row, a later seed is ignored for the // row's data; the update wrote no metadata, so the late seed supplies it. partialUpdateThenLateSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -460,7 +460,7 @@ const witnesses = { }, // The latest seed wins whole: a later seed without metadata leaves none. bareLaterSeedClearsMetadata: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), { type: `seed`, key: 1, bare: true }, @@ -469,7 +469,7 @@ const witnesses = { // A fresh source insert over a row only a seed supplied replaces the row // and, naming no metadata, clears the seed's. insertOverSeedReplaces: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -480,7 +480,7 @@ const witnesses = { // A source update merges over a seeded row under partial row updates, so the // seed's other field and its metadata stay. updateOverSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -491,7 +491,7 @@ const witnesses = { // Under full row updates the update replaces the seeded row; its metadata // stays, because the update names none. updateOverSeedFullRows: { - lane: seedFullLane, + grammar: seedFullGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -501,7 +501,7 @@ const witnesses = { }, // An open truncate hides a held row write: a row read stops at the truncate. openTruncateHidesHeldRow: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -513,7 +513,7 @@ const witnesses = { }, // An open delete hides a held collection entry from `list()`. openDeleteHidesHeldListEntry: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -525,7 +525,7 @@ const witnesses = { }, // `list('gc:')` includes the key equal to the prefix and excludes `gc`. prefixBoundary: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`begin`), a(`collectionSet`, `gc`), @@ -536,7 +536,7 @@ const witnesses = { // of the updated row is an equal re-insert, not a duplicate. A classifier // that merged the held update would keep the inserted row's field `m`. fullUpdateThenEqualInsertDuringHold: { - lane: seedFullLane, + grammar: seedFullGrammar, history: [ a(`begin`), a(`upsert`, 1, true), @@ -549,16 +549,19 @@ const witnesses = { a(`reinsert`, 1, false), ] as Array, }, -} satisfies Record }> +} satisfies Record< + string, + { grammar: SessionGrammar; history: Array } +> /** - * Each lane's replay names: its campaign, and the paired formulations that + * Each grammar's replay names: its campaign, and the paired formulations that * apply to it (key independence without full row updates, seed order with * seeds). */ -const lanes = [ +const grammars = [ { - lane: flatLane, + grammar: flatGrammar, property: `sync-metadata-session.flat`, paired: { 'hold-invariance': `sync-metadata-session.flat.hold-invariance`, @@ -567,7 +570,7 @@ const lanes = [ seed: 2_220_001, }, { - lane: nestedLane, + grammar: nestedGrammar, property: `sync-metadata-session.nested`, paired: { 'hold-invariance': `sync-metadata-session.nested.hold-invariance`, @@ -576,7 +579,7 @@ const lanes = [ seed: 2_220_002, }, { - lane: seedLane, + grammar: seedGrammar, property: `sync-metadata-session.seeds`, paired: { 'hold-invariance': `sync-metadata-session.seeds.hold-invariance`, @@ -585,11 +588,11 @@ const lanes = [ }, seed: 2_220_003, // A seed beneath an open update needs both on one row, and duplicate - // inserts share the lane's weight, so it runs more fixed histories. + // inserts share the grammar's weight, so it runs more fixed histories. runs: 150, }, { - lane: seedFullLane, + grammar: seedFullGrammar, property: `sync-metadata-session.seeds-full`, paired: { 'hold-invariance': `sync-metadata-session.seeds-full.hold-invariance`, @@ -598,7 +601,7 @@ const lanes = [ seed: 2_220_004, }, { - lane: nestedSeedLane, + grammar: nestedSeedGrammar, property: `sync-metadata-session.nested-seeds`, paired: { 'hold-invariance': `sync-metadata-session.nested-seeds.hold-invariance`, @@ -608,7 +611,7 @@ const lanes = [ seed: 2_220_005, }, { - lane: nestedConflictLane, + grammar: nestedConflictGrammar, property: `sync-metadata-session.nested-conflicts`, paired: { 'hold-invariance': `sync-metadata-session.nested-conflicts.hold-invariance`, @@ -616,13 +619,13 @@ const lanes = [ }, seed: 2_220_006, // Invalidation needs an outer insert, an inner write to the same row, - // and the inner commit, so this lane runs more fixed histories. + // and the inner commit, so this grammar runs more fixed histories. runs: 450, }, ] as const /** - * Declared reach (ORC-015): the states each lane's laws need, and how many + * Declared reach (ORC-015): the states each grammar's laws need, and how many * fixed-campaign histories must enter each. A seed over a source insert or an * open update needs both on one row, and a write to an invalid transaction * needs an invalidation first, so those minimums are lower. @@ -672,9 +675,9 @@ const declaredReach: Record> = { ], } -/** One reach counter per minimum, for a lane's fixed campaign. */ -function createLaneReach(lane: SessionLane, property: string) { - const states = declaredReach[lane.name]! +/** One reach counter per minimum, for a grammar's fixed campaign. */ +function createCampaignReach(grammar: SessionGrammar, property: string) { + const states = declaredReach[grammar.name]! const counters = [...new Set(states.map(([, minimum]) => minimum))].map( (minimum) => { const declared = states @@ -688,7 +691,7 @@ function createLaneReach(lane: SessionLane, property: string) { ) return { record(history: ReadonlyArray): void { - const regimes = historyRegimes(lane, history) + const regimes = historyRegimes(grammar, history) for (const { declared, reach } of counters) { reach.beginHistory() for (const regime of regimes) @@ -705,8 +708,8 @@ describe(`sync metadata session oracle`, () => { it(`derives the named witnesses from the laws`, async () => { type Observation = ReturnType const at = async (name: keyof typeof witnesses, step: number) => { - const { lane, history } = witnesses[name] - return (await modelCheckpoints(lane, history)).get(step) as Observation + const { grammar, history } = witnesses[name] + return (await modelCheckpoints(grammar, history)).get(step) as Observation } // Wrong answer on 30175be48: the held cursor reads as absent. const held = await at(`heldCursor`, 6) @@ -823,8 +826,8 @@ describe(`sync metadata session oracle`, () => { it.each(Object.entries(witnesses))( `keeps the session law on witness %s`, - async (_name, { lane, history }) => { - await assertSessionHistory(lane, history) + async (_name, { grammar, history }) => { + await assertSessionHistory(grammar, history) }, ) }) @@ -840,63 +843,65 @@ describe(`sync metadata session oracle`, () => { */ describe(`sync metadata session grammar ablation`, () => { const ablations: Array<{ - lane: SessionLane + grammar: SessionGrammar drop: (action: SessionAction) => boolean regime: string }> = [ { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `hold`, regime: `read with two held transactions`, }, { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `truncate`, regime: `truncate during a hold`, }, { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `reinsert`, regime: `equal re-insert`, }, { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `collectionSet` && x.undefinedValue === true, regime: `undefined collection value`, }, { - lane: nestedLane, + grammar: nestedGrammar, drop: (x) => x.type === `cancel`, regime: `abort keeps an inner commit`, }, { - lane: { ...nestedLane, maxDepth: 1 }, + grammar: { ...nestedGrammar, maxDepth: 1 }, drop: () => false, regime: `nested commit`, }, - { lane: seedLane, drop: (x) => x.type === `seed`, regime: `seed` }, + { grammar: seedGrammar, drop: (x) => x.type === `seed`, regime: `seed` }, ] it.each(ablations.map((ablation) => [ablation.regime, ablation] as const))( `removing the axis for %s removes that regime`, - (_regime, { lane, drop, regime }) => { + (_regime, { grammar, drop, regime }) => { const count = (filter: (action: SessionAction) => boolean) => fc - .sample(sessionHistoryArbitrary(lane), { + .sample(sessionHistoryArbitrary(grammar), { numRuns: 200, seed: 2_220_100, }) .filter((history) => - historyRegimes(lane, history.filter(filter)).has(regime), + historyRegimes(grammar, history.filter(filter)).has(regime), ).length - const fullLane = - lane.maxDepth === 1 && regime === `nested commit` ? nestedLane : lane + const fullGrammar = + grammar.maxDepth === 1 && regime === `nested commit` + ? nestedGrammar + : grammar const full = fc - .sample(sessionHistoryArbitrary(fullLane), { + .sample(sessionHistoryArbitrary(fullGrammar), { numRuns: 200, seed: 2_220_100, }) .filter((history) => - historyRegimes(fullLane, history).has(regime), + historyRegimes(fullGrammar, history).has(regime), ).length expect(full).toBeGreaterThan(10) expect(count((action) => !drop(action))).toBe(0) @@ -909,28 +914,28 @@ describe(`sync metadata session grammar ablation`, () => { * It does not consult the model's reads, so it cannot share a fault in them. */ describe(`sync metadata session hold invariance`, () => { - for (const { lane, paired, seed } of lanes) { + for (const { grammar, paired, seed } of grammars) { const invariance = paired[`hold-invariance`] const run = async (history: Array) => { - const mismatch = await runHoldInvariance(lane, history, () => - createCoreDriver(lane.fullRows), + const mismatch = await runHoldInvariance(grammar, history, () => + createCoreDriver(grammar.fullRows), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) } - oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(lane)], { + oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(grammar)], { numRuns: oracleRuns(50), seed: seed + 100, })( - `keeps ${lane.name} source reads independent of holds (fixed)`, + `keeps ${grammar.name} source reads independent of holds (fixed)`, run, 60_000, ) // A path replay of this property runs here. oracleReplayTest(fcTest, invariance).prop( - [sessionHistoryArbitrary(lane)], + [sessionHistoryArbitrary(grammar)], oraclePropertyOptions(50, invariance), )( - `keeps ${lane.name} source reads independent of holds (random)`, + `keeps ${grammar.name} source reads independent of holds (random)`, run, 60_000, ) @@ -946,49 +951,57 @@ const pairedFormulations = [ suffix: `key-independence`, title: `keeps row 1 independent of row 2`, run: runKeyIndependence, - lanes: lanes.filter((entry) => `key-independence` in entry.paired), + grammars: grammars.filter((entry) => `key-independence` in entry.paired), }, { suffix: `seed-order`, title: `keeps outcomes independent of seed arrival`, run: runSeedOrderInvariance, - lanes: lanes.filter((entry) => `seed-order` in entry.paired), + grammars: grammars.filter((entry) => `seed-order` in entry.paired), }, ] as const describe(`sync metadata session paired formulations`, () => { for (const formulation of pairedFormulations) - for (const { lane, paired, seed } of formulation.lanes) { + for (const { grammar, paired, seed } of formulation.grammars) { const name = (paired as Record)[formulation.suffix]! const run = async (history: Array) => { - const mismatch = await formulation.run(lane, history, () => - createCoreDriver(lane.fullRows), + const mismatch = await formulation.run(grammar, history, () => + createCoreDriver(grammar.fullRows), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) } - oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(lane)], { + oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(grammar)], { numRuns: oracleRuns(50), seed: seed + 200, - })(`${formulation.title} on ${lane.name} histories (fixed)`, run, 60_000) + })( + `${formulation.title} on ${grammar.name} histories (fixed)`, + run, + 60_000, + ) // A path replay of this property runs here. oracleReplayTest(fcTest, name).prop( - [sessionHistoryArbitrary(lane)], + [sessionHistoryArbitrary(grammar)], oraclePropertyOptions(50, name), - )(`${formulation.title} on ${lane.name} histories (random)`, run, 60_000) + )( + `${formulation.title} on ${grammar.name} histories (random)`, + run, + 60_000, + ) } }) describe(`sync metadata session campaigns`, () => { - for (const entry of lanes) { - const { lane, property, seed } = entry + for (const entry of grammars) { + const { grammar, property, seed } = entry const run = (history: Array) => - assertSessionHistory(lane, history) - const reach = createLaneReach(lane, property) - oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(lane)], { + assertSessionHistory(grammar, history) + const reach = createCampaignReach(grammar, property) + oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(grammar)], { numRuns: oracleRuns(`runs` in entry ? entry.runs : 100), seed, })( - `keeps the session law on ${lane.name} histories (fixed)`, + `keeps the session law on ${grammar.name} histories (fixed)`, (history) => { reach.record(history) return run(history) @@ -996,13 +1009,17 @@ describe(`sync metadata session campaigns`, () => { 120_000, ) oracleReplayTest(it)( - `reaches the declared states in the ${lane.name} fixed campaign`, + `reaches the declared states in the ${grammar.name} fixed campaign`, () => reach.assertReached(), ) // A path replay of this property runs here. oracleReplayTest(fcTest, property).prop( - [sessionHistoryArbitrary(lane)], + [sessionHistoryArbitrary(grammar)], oraclePropertyOptions(100, property), - )(`keeps the session law on ${lane.name} histories (random)`, run, 60_000) + )( + `keeps the session law on ${grammar.name} histories (random)`, + run, + 60_000, + ) } }) diff --git a/packages/db/tests/sync-metadata-session-oracle.ts b/packages/db/tests/sync-metadata-session-oracle.ts index eacb7199fb..7711beb8d1 100644 --- a/packages/db/tests/sync-metadata-session-oracle.ts +++ b/packages/db/tests/sync-metadata-session-oracle.ts @@ -87,10 +87,10 @@ import { declareOracle } from './oracle-declaration.js' * the error an invalid transaction's commit rejects with. Stale runs and * writes without an open transaction are fixed boundary witnesses, not * generated histories. Only the `seeds-full` and `persisted-peers-full` - * lanes use full row updates. The `persisted-peers` lanes add a second tab's - * source through its notices; no other lane has a peer, and a peer's write + * grammars use full row updates. The `persisted-peers` grammars add a second tab's + * source through its notices; no other grammar has a peer, and a peer's write * reaches this tab only at a point where the wrapper has no durable step or - * storage read in flight. Only the peer lanes model storage, so only they + * storage read in flight. Only the peer grammars model storage, so only they * compare stored rows after settlement. Durable metadata is also compared * after each action whose storage I/O is not held, and after settlement. * A restart reads storage, but this oracle's claim ends with one sync run. A @@ -188,14 +188,14 @@ export type SessionAction = export type PeerNotice = `row` | `metadata` | `delete` | `reload` | `reset` /** - * A lane fixes which actions the grammar may choose and the nesting depth. + * A grammar profile fixes which actions the grammar may choose and the nesting depth. * `flat` histories are the issue's domain: no transaction begins while one * is open. `nested` histories begin transactions inside open ones. `seeds` * add a second writer; `seeds-full` repeats it under full row updates, and - * `nested-seeds` crosses seeds with nested transactions. Each lane is its own - * replay property. + * `nested-seeds` crosses seeds with nested transactions. Each grammar is its own + * oracle property. */ -export type SessionLane = { +export type SessionGrammar = { name: string maxDepth: number seeds: boolean @@ -212,14 +212,14 @@ export type SessionLane = { readHold?: boolean /** * Whether `collectionSet` may store `undefined`. `get` cannot tell such a - * key from an absent one, but `list()` includes it. The persisted lane + * key from an absent one, but `list()` includes it. The persisted grammar * excludes it: stored collection metadata must be bindable. */ undefinedValues?: boolean /** * Whether the Collection uses `rowUpdateMode: 'full'`. An update then * replaces the row instead of merging over it, so a source insert of a - * seeded row drops the seed's other fields. Other lanes use `'partial'`. + * seeded row drops the seed's other fields. Other grammars use `'partial'`. */ fullRows?: boolean /** @@ -262,59 +262,59 @@ export type SessionLane = { peers?: boolean } -export const flatLane: SessionLane = { +export const flatGrammar: SessionGrammar = { name: `flat`, maxDepth: 1, seeds: false, undefinedValues: true, } -export const nestedLane: SessionLane = { +export const nestedGrammar: SessionGrammar = { name: `nested`, maxDepth: 3, seeds: false, undefinedValues: true, } -export const seedLane: SessionLane = { +export const seedGrammar: SessionGrammar = { name: `seeds`, maxDepth: 1, seeds: true, undefinedValues: true, } -export const nestedSeedLane: SessionLane = { +export const nestedSeedGrammar: SessionGrammar = { name: `nested-seeds`, maxDepth: 3, seeds: true, undefinedValues: true, } -export const nestedConflictLane: SessionLane = { +export const nestedConflictGrammar: SessionGrammar = { name: `nested-conflicts`, maxDepth: 3, seeds: false, undefinedValues: true, conflicts: true, } -export const seedFullLane: SessionLane = { +export const seedFullGrammar: SessionGrammar = { name: `seeds-full`, maxDepth: 1, seeds: true, undefinedValues: true, fullRows: true, } -export const persistedFlatLane: SessionLane = { +export const persistedFlatGrammar: SessionGrammar = { name: `persisted-flat`, maxDepth: 1, seeds: false, storeHold: true, insertsReplace: true, } -export const persistedNestedLane: SessionLane = { +export const persistedNestedGrammar: SessionGrammar = { name: `persisted-nested`, maxDepth: 3, seeds: false, storeHold: true, insertsReplace: true, } -export const persistedConflictLane: SessionLane = { +export const persistedConflictGrammar: SessionGrammar = { name: `persisted-conflicts`, maxDepth: 3, seeds: false, @@ -322,7 +322,7 @@ export const persistedConflictLane: SessionLane = { insertsReplace: true, conflicts: true, } -export const persistedCachedLane: SessionLane = { +export const persistedCachedGrammar: SessionGrammar = { name: `persisted-cached`, maxDepth: 2, seeds: false, @@ -330,7 +330,7 @@ export const persistedCachedLane: SessionLane = { insertsReplace: true, cached: true, } -export const persistedSeedLane: SessionLane = { +export const persistedSeedGrammar: SessionGrammar = { name: `persisted-seeds`, maxDepth: 1, seeds: true, @@ -339,7 +339,7 @@ export const persistedSeedLane: SessionLane = { } /** * Seeds crossed with nested transactions through the persisted wrapper, with - * the conflict lane's weighting: an inner re-insert of an enclosing row can + * the conflict grammar's weighting: an inner re-insert of an enclosing row can * commit first and land on a seeded row. The cached row and the favoured * metadata-keeping writes let a row's metadata stay a seed's after the * source wrote the row, so a later seed can replace it. @@ -349,7 +349,7 @@ export const persistedSeedLane: SessionLane = { * persisted wrapper: a read hold keeps an on-demand load open, so the wrapper * buffers transactions committed during it. */ -export const persistedReadLane: SessionLane = { +export const persistedReadGrammar: SessionGrammar = { name: `persisted-reads`, maxDepth: 2, seeds: false, @@ -358,18 +358,18 @@ export const persistedReadLane: SessionLane = { insertsReplace: true, } /** - * The read lane with `DbClient` seeds and the cached row, which a demand for + * The read-hold grammar with `DbClient` seeds and the cached row, which a demand for * every row loads at the start: a transaction the wrapper buffers during a * read may have its signal aborted after it commits, which abandons it. */ -export const persistedReadSeedLane: SessionLane = { - ...persistedReadLane, +export const persistedReadSeedGrammar: SessionGrammar = { + ...persistedReadGrammar, name: `persisted-reads-seeds`, seeds: true, cached: true, onDemand: true, } -export const persistedNestedSeedLane: SessionLane = { +export const persistedNestedSeedGrammar: SessionGrammar = { name: `persisted-nested-seeds`, maxDepth: 2, seeds: true, @@ -383,7 +383,7 @@ export const persistedNestedSeedLane: SessionLane = { * Another tab's source writes beside this tab's source, its seeds, the cached * row, and nested transactions, through the persisted wrapper. */ -export const persistedPeerLane: SessionLane = { +export const persistedPeerGrammar: SessionGrammar = { name: `persisted-peers`, maxDepth: 2, seeds: true, @@ -396,8 +396,8 @@ export const persistedPeerLane: SessionLane = { * The same under full row updates: an update replaces its row, so storage * keeps no field the update dropped, neither the stored row's nor a seed's. */ -export const persistedPeerFullLane: SessionLane = { - ...persistedPeerLane, +export const persistedPeerFullGrammar: SessionGrammar = { + ...persistedPeerGrammar, name: `persisted-peers-full`, fullRows: true, } @@ -405,26 +405,26 @@ export const persistedPeerFullLane: SessionLane = { * The same on demand, with a demand for every row: a reset keeps a held * `DbClient` seed and deletes every other row (decision 19). */ -export const persistedPeerOnDemandLane: SessionLane = { - ...persistedPeerLane, +export const persistedPeerOnDemandGrammar: SessionGrammar = { + ...persistedPeerGrammar, name: `persisted-peers-on-demand`, onDemand: true, } const anyRowKey = fc.constantFrom(...sessionRowKeys) -/** The cached row and metadata a `cached` lane starts with. */ +/** The cached row and metadata a `cached` grammar starts with. */ export const cachedRow = { id: 1, v: `cached`, seeded: `cached` } export const cachedMetadata = { cached: true } -/** A model at the start of a run on this lane. */ -export function createSessionModel(lane: SessionLane): SessionModel { +/** A model at the start of a run on this grammar. */ +export function createSessionModel(grammar: SessionGrammar): SessionModel { const model = new SessionModel( - lane.fullRows, - lane.insertsReplace === true, - lane.onDemand === true, + grammar.fullRows, + grammar.insertsReplace === true, + grammar.onDemand === true, ) - if (lane.cached === true) + if (grammar.cached === true) model.seed(1, cachedRow, { present: true, value: cachedMetadata }, true) return model } @@ -436,13 +436,13 @@ const collectionKeyArbitrary = fc.constantFrom(...sessionCollectionKeys) * transaction, and most histories commit more than once during a hold. */ export function sessionActionArbitrary( - lane: SessionLane, + grammar: SessionGrammar, ): fc.Arbitrary { - // The conflict lane multiplies row writes and names row 1 three times as + // The conflict grammar multiplies row writes and names row 1 three times as // often as row 2. - const rows = lane.conflicts === true ? 3 : 1 + const rows = grammar.conflicts === true ? 3 : 1 const rowKeyArbitrary = - lane.conflicts === true + grammar.conflicts === true ? fc.constantFrom(1, 1, 1, 2) : anyRowKey const weighted = ( @@ -450,7 +450,7 @@ export function sessionActionArbitrary( arbitrary: fc.Arbitrary, ): fc.WeightedArbitrary => ({ weight, arbitrary }) const metadataArbitrary = - lane.keepsMetadata === true + grammar.keepsMetadata === true ? fc.constantFrom(false, false, false, false, false, false, false, true) : fc.boolean() const actions: Array> = [ @@ -458,7 +458,7 @@ export function sessionActionArbitrary( weighted(4, fc.constant({ type: `commit` as const })), weighted(1, fc.constant({ type: `cancel` as const })), weighted( - lane.keepsMetadata === true ? 1 : 2, + grammar.keepsMetadata === true ? 1 : 2, rowKeyArbitrary.map((key) => ({ type: `rowSet` as const, key })), ), weighted( @@ -466,7 +466,7 @@ export function sessionActionArbitrary( rowKeyArbitrary.map((key) => ({ type: `rowDelete` as const, key })), ), weighted( - lane.keepsMetadata === true ? 4 * rows : 2 * rows, + grammar.keepsMetadata === true ? 4 * rows : 2 * rows, fc .tuple(rowKeyArbitrary, metadataArbitrary) .map(([key, metadata]) => ({ type: `upsert` as const, key, metadata })), @@ -480,12 +480,12 @@ export function sessionActionArbitrary( })), ), weighted( - lane.keepsMetadata === true ? 1 : rows, + grammar.keepsMetadata === true ? 1 : rows, rowKeyArbitrary.map((key) => ({ type: `remove` as const, key })), ), weighted(1, fc.constant({ type: `truncate` as const })), - // The conflict lane leaves collection metadata to the other lanes. - ...(lane.conflicts === true + // The conflict grammar leaves collection metadata to the other grammars. + ...(grammar.conflicts === true ? [] : [ weighted( @@ -495,7 +495,7 @@ export function sessionActionArbitrary( .map(([key, roll]) => ({ type: `collectionSet` as const, key, - ...(lane.undefinedValues && roll === 0 + ...(grammar.undefinedValues && roll === 0 ? { undefinedValue: true } : {}), })), @@ -510,26 +510,26 @@ export function sessionActionArbitrary( ]), weighted( 3, - lane.readHold === true + grammar.readHold === true ? fc.constantFrom(`core`, `store`, `read`).map((kind) => ({ type: `hold` as const, store: kind === `store`, read: kind === `read`, })) - : lane.storeHold + : grammar.storeHold ? fc.boolean().map((store) => ({ type: `hold` as const, store })) : fc.constant({ type: `hold` as const }), ), weighted(1, fc.constant({ type: `release` as const })), - ...(lane.readHold === true + ...(grammar.readHold === true ? // Legal only just after a commit during a read, so it weighs more. [weighted(8, fc.constant({ type: `abandon` as const }))] : []), ] - if (lane.seeds || lane.cached === true) + if (grammar.seeds || grammar.cached === true) actions.push( weighted( - lane.keepsMetadata === true ? 1 : 2, + grammar.keepsMetadata === true ? 1 : 2, fc.tuple(rowKeyArbitrary, fc.boolean()).map(([key, metadata]) => ({ type: `insertOverSeed` as const, key, @@ -537,7 +537,7 @@ export function sessionActionArbitrary( })), ), ) - if (lane.seeds) + if (grammar.seeds) actions.push( weighted( 4, @@ -551,7 +551,7 @@ export function sessionActionArbitrary( })), ), ) - if (lane.peers === true) + if (grammar.peers === true) actions.push( weighted( 4, @@ -593,9 +593,9 @@ export function sessionActionArbitrary( } export function sessionHistoryArbitrary( - lane: SessionLane, + grammar: SessionGrammar, ): fc.Arbitrary> { - return fc.array(sessionActionArbitrary(lane), { + return fc.array(sessionActionArbitrary(grammar), { minLength: 12, maxLength: 48, size: `medium`, @@ -611,7 +611,7 @@ export function sessionHistoryArbitrary( * This constructive subgrammar rebuilds the #2281 metadata prefix and varies * both keys, seed shape/metadata and arrival, explicit metadata, repeated * re-inserts, a core hold, the enclosing transaction's ending, and named - * versus full-reload notices. Every action is legal in the peer lanes. Store + * versus full-reload notices. Every action is legal in the peer grammars. Store * and read holds are excluded because their pending work blocks peer notices. * The general session grammar continues to cover their independent laws. */ @@ -726,7 +726,7 @@ export const keptMetadataDurabilityHistoriesArbitrary = fc * seed metadata forms with immediate writes and writes queued behind a store * hold. Releasing that hold may complete both commits before the next action * cut, so the controlled driver records each completed durable transaction. - * All histories replay the same model and comparison, without fixed answers. + * All histories run through the same model and comparison, without fixed answers. */ export const queuedMetadataRepairHistoriesArbitrary = fc .record({ @@ -905,10 +905,10 @@ export function completedSeedReinsertReloadRegime( * replays. The campaign reach test requires each law's premise. */ export function historyRegimes( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Set { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const regimes = new Set() let reading = false // Under full row updates an update can drop a field its row had; the @@ -916,7 +916,7 @@ export function historyRegimes( // counts once an outermost commit accepts it. let pendingDrop = false for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue if (action.type === `hold` && action.read === true) reading = true if (action.type === `release`) reading = false if (reading && writesSource(action) && model.depth > 0) @@ -1085,7 +1085,7 @@ function writesSource(action: SessionAction): boolean { * can land on a different row that the source wrote. That transaction is then * invalid, as a duplicate-key insert would be: no read includes its writes, * it ignores later writes, a truncate included, and its commit rejects. - * Lanes whose inserts replace (the persisted wrapper) keep the transaction, + * Grammars whose inserts replace (the persisted wrapper) keep the transaction, * and the insert replaces that row and clears its metadata. * * The source and consumers read different layers of the same order. The @@ -2128,12 +2128,12 @@ export type SessionDriver = { isDuplicateKeyError?: (error: unknown) => boolean /** * Aborts the signal of the newest outermost transaction committed during - * the read hold. Lanes with read holds use it. + * the read hold. Grammars with read holds use it. */ abandon?: () => void observe: () => SessionObservation /** - * Each row as storage holds it. Where a lane models storage, the settled + * Each row as storage holds it. Where a grammar models storage, the settled * rows must match the model's: a restart reads them, and a full reload * reads them back into this tab. */ @@ -2187,7 +2187,7 @@ export type SessionMismatch = { * compares the full observation at the checkpoint after the action returns. * Illegal actions for the current model state are skipped, so every * generated history is legal: `commit`, `cancel`, and writes need an open - * transaction; `begin` respects the lane's depth; `hold` and `release` + * transaction; `begin` respects the grammar's depth; `hold` and `release` * alternate; `remove` and `reinsert` need the row. A seed is legal at any * point, including over a row the source has inserted. * @@ -2205,7 +2205,7 @@ export type SessionMismatch = { * is an insert. */ export async function runSessionHistory( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise<{ @@ -2213,7 +2213,7 @@ export async function runSessionHistory( steps: number recoveryInputs: ReadonlyArray }> { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const driver = await createDriver() let mismatch: SessionMismatch | undefined let steps = 0 @@ -2235,7 +2235,7 @@ export async function runSessionHistory( applyRecoveryInputs() recordDurableMetadata() for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue steps++ const value = { s: step } let thrown: string | undefined @@ -2272,7 +2272,7 @@ export async function runSessionHistory( mismatch = compareStoredMetadata( step, action, - lane, + grammar, model, driver, durableTrace, @@ -2300,7 +2300,7 @@ export async function runSessionHistory( model.observe(), driver.observe(), ) - if (!mismatch && lane.peers === true && driver.storedRows) { + if (!mismatch && grammar.peers === true && driver.storedRows) { const expected = model.storedRows() const actual = driver.storedRows() if (canonical(expected) !== canonical(actual)) @@ -2316,7 +2316,7 @@ export async function runSessionHistory( mismatch = compareStoredMetadata( history.length, `settle`, - lane, + grammar, model, driver, durableTrace, @@ -2355,12 +2355,12 @@ export async function runSessionHistory( function compareStoredMetadata( step: number, action: SessionAction | `settle`, - lane: SessionLane, + grammar: SessionGrammar, model: SessionModel, driver: SessionDriver, durableTrace: ReadonlyArray>, ): SessionMismatch | undefined { - if (lane.peers !== true || !driver.storedRowMetadata) return undefined + if (grammar.peers !== true || !driver.storedRowMetadata) return undefined // Empty transactions may stutter. Preserve every change of durable metadata, // including a wrong write that a later queued commit repairs during release. const changes = (trace: ReadonlyArray>) => @@ -2394,17 +2394,17 @@ function compareStoredMetadata( * holds does not change which other actions are legal. */ export async function runHoldInvariance( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise { const held = await createDriver() const unheld = await createDriver() - const model = createSessionModel(lane) + const model = createSessionModel(grammar) let mismatch: SessionMismatch | undefined try { for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue const value = { s: step } let thrown: string | undefined try { @@ -2445,13 +2445,13 @@ type PlannedCall = { step: number; action: SessionAction; call: DriverCall } /** The legal actions of a history and their driver calls, planned once. */ function planHistory( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Array { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const planned: Array = [] for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue planned.push({ step, action, call: planAction(model, action, { s: step }) }) } return planned @@ -2504,17 +2504,17 @@ function withoutOtherRow(observation: SessionObservation): SessionObservation { * where the model invalidates a transaction are therefore not compared. */ export async function runKeyIndependence( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise { - if (historyRegimes(lane, history).has(`transaction invalidated`)) + if (historyRegimes(grammar, history).has(`transaction invalidated`)) return undefined const both = await createDriver() const rowOneOnly = await createDriver() let mismatch: SessionMismatch | undefined try { - for (const planned of planHistory(lane, history)) { + for (const planned of planHistory(grammar, history)) { mismatch = await performCall(both, planned) if (mismatch) break if (namesOtherRow(planned.action)) continue @@ -2550,21 +2550,21 @@ export async function runKeyIndependence( * stays equal when the seed moves first. Under partial row updates a seed * that arrives after a source update is ignored, while a seed moved first is * the update's merge base, so consumers' rows are compared only under full - * row updates; metadata is compared in every lane. For the same reason a + * row updates; metadata is compared in every grammar. For the same reason a * re-insert planned from the arrival-order row need not equal the row when - * the seed moves first (decision "b"), so partial lanes drop `reinsert` from + * the seed moves first (decision "b"), so partial grammars drop `reinsert` from * both runs. */ export async function runSeedOrderInvariance( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise { const asArrived = await createDriver() const seedsFirst = await createDriver() const planned = planHistory( - lane, - lane.fullRows + grammar, + grammar.fullRows ? history : history.filter((action) => action.type !== `reinsert`), ) @@ -2586,7 +2586,7 @@ export async function runSeedOrderInvariance( if (!mismatch) { await Promise.all([asArrived.settle(), seedsFirst.settle()]) const comparable = (observation: SessionObservation) => - lane.fullRows ? observation : { ...observation, base: {} } + grammar.fullRows ? observation : { ...observation, base: {} } mismatch = compare( history.length, `settle`, @@ -2632,13 +2632,13 @@ const modelOnly: SessionDriver = { * expectations derived by hand from the laws. */ export async function modelCheckpoints( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Promise> { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const checkpoints = new Map() for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue await planAction(model, action, { s: step })(modelOnly) checkpoints.set(step, model.observe()) } @@ -2646,7 +2646,7 @@ export async function modelCheckpoints( } function isLegal( - lane: SessionLane, + grammar: SessionGrammar, model: SessionModel, action: SessionAction, ): boolean { @@ -2654,18 +2654,18 @@ function isLegal( if (model.awaitsAbandonment && action.type !== `release`) return false switch (action.type) { case `begin`: - return model.depth < lane.maxDepth + return model.depth < grammar.maxDepth case `hold`: return !model.isHolding case `release`: return model.isHolding case `abandon`: - return lane.readHold === true && model.canAbandon + return grammar.readHold === true && model.canAbandon case `seed`: - return lane.seeds + return grammar.seeds case `peer`: return ( - lane.peers === true && + grammar.peers === true && !model.blocksPeerNotices && (action.notice === `row` || action.notice === `reload` || @@ -2674,7 +2674,7 @@ function isLegal( ) case `insertOverSeed`: return ( - (lane.seeds || lane.cached === true) && + (grammar.seeds || grammar.cached === true) && model.depth > 0 && model.sourceRow(action.key).present && !model.sourceWroteRow(action.key) diff --git a/packages/db/tests/sync-metadata-session.test.ts b/packages/db/tests/sync-metadata-session.test.ts index d793683ce6..c84ed6feae 100644 --- a/packages/db/tests/sync-metadata-session.test.ts +++ b/packages/db/tests/sync-metadata-session.test.ts @@ -3,7 +3,7 @@ import { DbClient, collectionOptions } from '../src/client.js' import { createCollection } from '../src/collection/index.js' import { createSessionModel, - persistedPeerLane, + persistedPeerGrammar, runSessionHistory, } from './sync-metadata-session-oracle.js' import type { SessionDriver } from './sync-metadata-session-oracle.js' @@ -19,7 +19,7 @@ type SyncActions = Parameters[`sync`]>[0] it(`consumes completed setup inputs before authored actions`, async () => { const order: Array = [] let setup = true - const observation = createSessionModel(persistedPeerLane).observe() + const observation = createSessionModel(persistedPeerGrammar).observe() const driver: SessionDriver = { begin: () => { order.push(`action`) @@ -54,7 +54,7 @@ it(`consumes completed setup inputs before authored actions`, async () => { }, } const result = await runSessionHistory( - persistedPeerLane, + persistedPeerGrammar, [{ type: `begin` }], () => Promise.resolve(driver), ) diff --git a/packages/db/tests/temporal-index-order.test.ts b/packages/db/tests/temporal-index-order.test.ts index 3e041f0022..73a7a67253 100644 --- a/packages/db/tests/temporal-index-order.test.ts +++ b/packages/db/tests/temporal-index-order.test.ts @@ -21,8 +21,8 @@ import type { CompareOptions } from '../src/query/builder/types' * direction, and equal-instant ties, then compares exact ordered keys after * the action. A ready source exercises scan and indexed live-query paths at * preload; an on-demand Collection checks the first published ordered row. - * Index spies prove the named indexed reads. The PlainDate lane checks one - * extended-year calendar boundary; the exception lane requires `TypeError` + * Index spies prove the named indexed reads. The PlainDate grammar checks one + * extended-year calendar boundary; the exception grammar requires `TypeError` * for its named mixed or unordered Temporal pairs and claims no ordered * result for them. Custom index callbacks, mutated built-ins, and other * mixed Temporal pairs remain outside this owner. diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index e7b5f356d2..03ad2566bf 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -759,7 +759,7 @@ describe(`settled transactions leave every Collection that tracked them`, () => ) }, 300_000) - it(`pinned: a subscriber throws during an asynchronous success and failure settlement`, async () => { + it(`fixed history: a subscriber throws during an asynchronous success and failure settlement`, async () => { await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 3 }, @@ -774,7 +774,7 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a merged-away pair, a conflicting rollback that throws, and a truncate rollback`, async () => { + it(`fixed history: a merged-away pair, a conflicting rollback that throws, and a truncate rollback`, async () => { await runHistory([ { type: `open`, reuse: undefined }, { type: `cancel`, tx: 0, on: `A` }, @@ -792,7 +792,7 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a rolled-back transaction does not remove a live one that shares its id`, async () => { + it(`fixed history: a rolled-back transaction does not remove a live one that shares its id`, async () => { await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, @@ -807,7 +807,7 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a second live transaction with a tracked id is rejected`, async () => { + it(`fixed history: a second live transaction with a tracked id is rejected`, async () => { await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, @@ -816,7 +816,7 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: conflicting rollbacks that throw settle every transaction and rethrow one subscriber error`, async () => { + it(`fixed history: conflicting rollbacks that throw settle every transaction and rethrow one subscriber error`, async () => { await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, @@ -830,7 +830,7 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a failed commit whose rollback subscriber throws keeps the mutation error`, async () => { + it(`fixed history: a failed commit whose rollback subscriber throws keeps the mutation error`, async () => { await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, diff --git a/packages/db/tests/utils-oracle.property.test.ts b/packages/db/tests/utils-oracle.property.test.ts index 322e197ef1..bbc28f9fc9 100644 --- a/packages/db/tests/utils-oracle.property.test.ts +++ b/packages/db/tests/utils-oracle.property.test.ts @@ -251,7 +251,7 @@ function adjacentDateTime(time: number): number { // Legal rings have 1–5 reachable nodes, integer payloads, and one of four // native edge carriers at each node. A zero-node ring has no root and is // excluded. The fixed pair enumeration below reconstructs every ordered -// two-carrier overlap; the length cases pin the lower and upper bounds. +// two-carrier overlap; the length cases fix the lower and upper bounds. const ringArbitrary = fc.array( fc.record({ value: fc.integer(), @@ -540,7 +540,7 @@ describe(`deepEquals property-based tests`, () => { }) // `deepEquals` drives change-event suppression, so it deliberately ignores // state that draft revert detection keeps (see proxy-revert-oracle): Map and - // Set insertion order, RegExp `lastIndex`, and array holes. These laws pin + // Set insertion order, RegExp `lastIndex`, and array holes. These laws fix // that current relation so a shared walker cannot leak draft rules into it. describe(`order and state the general relation ignores`, () => { utilsProperty([ diff --git a/packages/db/tests/utils.test.ts b/packages/db/tests/utils.test.ts index 8570b58130..75402c22fd 100644 --- a/packages/db/tests/utils.test.ts +++ b/packages/db/tests/utils.test.ts @@ -58,7 +58,7 @@ describe(`oracle run configuration`, () => { }), ).toEqual({ multiplier: 100, - replaySeed: -42, + campaignSeed: -42, replayPath: `1:0:2`, replayProperty: `includes.incremental-history`, }) @@ -67,7 +67,7 @@ describe(`oracle run configuration`, () => { it(`uses one run multiplier and no replay coordinates by default`, () => { expect(readOracleRunConfig({})).toEqual({ multiplier: 1, - replaySeed: undefined, + campaignSeed: undefined, replayPath: undefined, replayProperty: undefined, }) @@ -137,12 +137,12 @@ describe(`oracle run configuration`, () => { it(`adds a shrink path only to its named property`, () => { const ordinaryRun = { - replaySeed: undefined, + campaignSeed: undefined, replayPath: undefined, replayProperty: undefined, } const replayRun = { - replaySeed: -42, + campaignSeed: -42, replayPath: `1:0:2`, replayProperty: `includes.incremental-history`, } diff --git a/packages/db/tests/virtual-props-cache.test.ts b/packages/db/tests/virtual-props-cache.test.ts index 7bca5712bf..f83be003bb 100644 --- a/packages/db/tests/virtual-props-cache.test.ts +++ b/packages/db/tests/virtual-props-cache.test.ts @@ -5,7 +5,7 @@ import { mockSyncCollectionOptions, withExpectedRejection } from './utils.js' /** * A row read with virtual props is a copy, cached so repeated reads and - * publications share one object. These laws pin what that cache must keep: + * publications share one object. These laws fix what that cache must keep: * the value a change publishes is the row later reads return, for every * subscriber, and a key that leaves the collection leaves the cache. A sync * source that reuses a row object and changes it in place still reads its new diff --git a/packages/electric-db-collection/tests/async-hold-outcomes.test.ts b/packages/electric-db-collection/tests/async-hold-outcomes.test.ts index 0cb9823ed5..9f2a88b3ab 100644 --- a/packages/electric-db-collection/tests/async-hold-outcomes.test.ts +++ b/packages/electric-db-collection/tests/async-hold-outcomes.test.ts @@ -14,7 +14,7 @@ import type { Message } from '@electric-sql/client' * and during the fetch. The retry-decision law in * `electric-oracle.property.test.ts` owns the outcomes of a stream error, an * `onError` decision, an Electric client give-up, an external abort, and - * cleanup. This file pins two outcomes outside that grammar: + * cleanup. This file fixes two outcomes outside that grammar: * * - a must-refetch after the first full sync renews the wait, so a new load * settles only with the replacement snapshot; diff --git a/packages/electric-db-collection/tests/electric-live-query.test.ts b/packages/electric-db-collection/tests/electric-live-query.test.ts index 9616251d8c..ab57bd3cb8 100644 --- a/packages/electric-db-collection/tests/electric-live-query.test.ts +++ b/packages/electric-db-collection/tests/electric-live-query.test.ts @@ -901,7 +901,7 @@ describe(`Electric Collection with Live Query - syncMode integration`, () => { // Initial-query readiness and Effect `skipInitial` follow the settled subset // in both cases. Electric buffers progressive changes until the full sync, // so it cannot deliver a change between settlement and source readiness; - // the controlled core lane owns that history. + // the controlled core path owns that history. describe(`initial readiness follows the settled subset`, () => { const selectedUser = sampleUsers[0]! const laterUser = { ...sampleUsers[3]!, active: true, id: 5 } @@ -964,7 +964,7 @@ describe(`Electric Collection with Live Query - syncMode integration`, () => { ]) }) - // A known limit, pinned so that a change to Electric's buffering is + // A known limit, fixed so that a change to Electric's buffering is // visible: before the full sync, a ready progressive query shows its // snapshot rows, and stream changes to them wait for the atomic swap. it(`a ready query's rows update only when the full sync completes`, async () => { diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index c88b0aa197..c6cb31b62a 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -3832,7 +3832,7 @@ describe(`Electric adapter laws`, () => { * client awaits the decision; and stream steps after a final failure or an * abort, because the stream has stopped. It also omits a second * up-to-date while the first snapshot's acceptance is pending. The - * installed-client lane cannot hold an acceptance, so this dimension runs + * installed-client receiving path cannot hold an acceptance, so this dimension runs * only here. A progressive history starts with * a must-refetch, so its demands wait on the established snapshot. * @@ -3840,7 +3840,7 @@ describe(`Electric adapter laws`, () => { * ShapeStream. After each step and a timer turn it compares the status, * every demand's settlement and cause, a readiness wait's settlement and * cause, and whether the snapshot's row is visible. The model lives in `electric-retry-decision-model.ts`. An - * installed-client lane in `electric-sdk-delivery-oracle.property.test.ts` + * installed-client receiving path in `electric-sdk-delivery-oracle.property.test.ts` * drives the same model through the real Electric client and HTTP. */ const retryDecisionArb = fc.constantFrom( @@ -4134,7 +4134,7 @@ describe(`Electric adapter laws`, () => { }, 60_000, [ - // Named replays: a retried provider error while a transaction holds the + // Named fixed histories: a retried provider error while a transaction holds the // recovered snapshot, for each wait, with and without a hold (this // replaces the former fixed "renews full recovery demand" case); a // held snapshot whose stream aborts before the hold settles; an @@ -4418,7 +4418,7 @@ describe(`Electric adapter laws`, () => { `a rejected initial Electric row commit creates no durable source prefix (fixed)`, checkDurablePrefix, ) - // A shared seed reseeds this random lane; a path replay selects it by property. + // A shared campaign seed reseeds this random campaign; a path replay selects it by property. oracleReplayTest(fcTest, durablePrefixProperty).prop( [durablePrefixArbitrary], durablePrefixPropertyOptions(`random`), @@ -4871,7 +4871,7 @@ describe(`Electric adapter laws`, () => { ) }) -// Each campaign's random lane has its own suite. An ordinary run runs it +// Each random campaign has its own suite. An ordinary run runs it // beside the adapter laws. A path replay of its property runs only it. oracleReplayTest(describe, persistenceInterleavingProperty)( `persisted Electric interleaving campaign`, diff --git a/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts b/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts index 2a752391bb..8cab2fd7a2 100644 --- a/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts +++ b/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts @@ -237,7 +237,7 @@ afterEach(() => { }) describe(`persisted progressive Electric query while offline (current behavior)`, () => { - // Observed on main at 31dad2dd3; these pin current behavior, not a law. + // Observed on main at 31dad2dd3; these fix current behavior, not a law. it.each([ [`full-sync`, { resumed: true, ready: true, rows: [1, 2] }], [`subset-only`, { resumed: false, ready: false, rows: [1, 2] }], diff --git a/packages/electric-db-collection/tests/electric-retry-decision-model.ts b/packages/electric-db-collection/tests/electric-retry-decision-model.ts index 46d3f3db28..ad94966fb3 100644 --- a/packages/electric-db-collection/tests/electric-retry-decision-model.ts +++ b/packages/electric-db-collection/tests/electric-retry-decision-model.ts @@ -1,7 +1,7 @@ /** * The independent model for the Electric retry-decision law. It is shared by * the retry-decision oracle in `electric-oracle.property.test.ts`, which - * drives a controlled ShapeStream, and by the installed-client lane in + * drives a controlled ShapeStream, and by the installed-client receiving path in * `electric-sdk-delivery-oracle.property.test.ts`. It reads no production * state. The law and its history grammar are documented beside the * retry-decision oracle. @@ -247,7 +247,7 @@ export function createRetryModel( wait: RetryWait | boolean, cleanupOnError = false, ): RetryModel { - // The boolean form is the installed-client lane's full-log wait (`false`) + // The boolean form is the installed-client path's full-log wait (`false`) // or a progressive wait (`true`). const resolved: RetryWait = typeof wait === `boolean` ? (wait ? `progressive` : `full-log`) : wait diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts index 99dbab2599..341fd1db37 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts @@ -41,7 +41,7 @@ import type { Message } from '@electric-sql/client' import type * as ElectricSdkModule from '@electric-sql/client' // Observe the real constructor boundary without changing the installed SDK's -// options, prototype, methods, or HTTP behavior. Only the rotation lane enables +// options, prototype, methods, or HTTP behavior. Only the rotation grammar enables // the callback; every construction still executes the original ShapeStream. const sdkConstruction = vi.hoisted(() => ({ observe: undefined as (() => void) | undefined, @@ -94,7 +94,7 @@ vi.mock(`@electric-sql/client`, async (importOriginal) => { * A real node:sqlite cold-launch witness starts with an incompatible partial * cache and no reset marker. It must exclude that row before and after the * installed SDK returns an empty source snapshot for the demanded predicate. - * A source-release lane, documented at its property, checks that cleanup + * A source-release grammar, documented at its property, checks that cleanup * removes every listener that a run's streams registered on `document`. * * These finite HTTP fixtures do not prove that a live Electric service emits @@ -702,7 +702,7 @@ fixedCase(`detects dropped move-in at its visibility checkpoint`, async () => { * # The retry-decision law through the installed Electric client * * The retry-decision oracle in `electric-oracle.property.test.ts` drives the - * adapter through a controlled ShapeStream. This lane receives the same law + * adapter through a controlled ShapeStream. This receiving path receives the same law * through the installed `@electric-sql/client`, its retry policy, and HTTP. * It uses the same independent model (`electric-retry-decision-model.ts`). * @@ -726,7 +726,7 @@ fixedCase(`detects dropped move-in at its visibility checkpoint`, async () => { * load. An eager or progressive Collection has no subset demand before its * first ready, so its demand is the readiness wait (`preload()`). * - * At each checkpoint the lane compares the Collection status, each demand's + * At each checkpoint the receiving path compares the Collection status, each demand's * settlement, and whether the snapshot's row is visible with the model: * after the first error reaches `onError`, after an asynchronous decision, * after the outcome, and after the later demand. It also checks that a @@ -953,7 +953,7 @@ fixedCase.each(clientRetryCells)( * retired HTTP snapshot arrives while rotation is held, just before replacement * snapshots, or after replacement settlement. A forwarding constructor observer * records listener ownership before invoking the real SDK. It does not release - * resources or replace SDK behavior. This lane + * resources or replace SDK behavior. This receiving path * covers sequential rotations in an on-demand run with the installed browser * SDK; overlapping rotations, other SDK versions, and native hosts remain separate. * Both retained demands must remain pending until replacement source responses @@ -1362,10 +1362,10 @@ sdkCampaigns( * bound and one-registration-per-run calibration describe this installed SDK * and the driver's one-stream topology, rather than a universal SDK policy. * - * Limits: the lane observes the browser listener in jsdom. Client versions + * Limits: the receiving path observes the browser listener in jsdom. Client versions * that observe React Native's AppState instead register through a runtime - * adapter, which this lane does not install. A live Electric service and - * snapshot requests are outside this lane. The managed cache rotation case + * adapter, which this receiving path does not install. A live Electric service and + * snapshot requests are outside this receiving path. The managed cache rotation case * above replaces a provider session within one run; it receives the same law * for the retired session. */ diff --git a/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts index ec62d0399d..c1cda01dde 100644 --- a/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts @@ -50,7 +50,7 @@ const synchronizedOutput = { * at `string | number`; it does not infer the schema's branded ID type. * * The assertions observe those production type paths after overload - * resolution. Hostile controls reject an untransformed shape row at the sync + * resolution. Mutant controls reject an untransformed shape row at the sync * boundary and an invalid value at the mutation boundary. This partial oracle * does not execute Shape parsing, network I/O, or mutation-handler timing. */ diff --git a/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts b/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts index 885e371362..b396123033 100644 --- a/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts +++ b/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts @@ -59,7 +59,7 @@ import type { * reply, which is the only part of the IPC transport that this law depends * on. With `TANSTACK_DB_ELECTRON_E2E_ALL=1`, each envelope instead crosses a * real Electron main process through the runtime bridge. The histories cover - * a renderer without a coordinator. A second lane below runs a follower and + * a renderer without a coordinator. A second production path below runs a follower and * an owner renderer with coordinators. */ @@ -253,7 +253,7 @@ type Checkpoints = { // before it is ever ready. A demand released while its hydration is in flight // starts no live upstream load, installs no upstream rows, and causes no // error. Whether locally hydrated rows stay is retention, which the persisted -// oracle's retained-demand lane owns, so the model does not predict it. +// oracle's retained-demand grammar owns, so the model does not predict it. function expectedCheckpoints(history: IpcReadinessHistory): Checkpoints { const local = history.renderer === `reopened` ? [`local`] : [] if (history.hydration === `fails`) @@ -466,7 +466,7 @@ function coordinatedRenderer(options: { trace.push(`ensure-settled`), ) } - // Record when the owner's commit notice reaches this renderer, so the lane + // Record when the owner's commit notice reaches this renderer, so the test // can check the premise it relies on: the notice precedes the reply. const subscribe = coordinator.subscribe.bind(coordinator) coordinator.subscribe = (collectionId, onMessage) => @@ -597,7 +597,7 @@ async function observeCoordinatedRendererHistory( follower.trace.indexOf(`ensure-settled`), ) const settledWhileApplyHeld = settledAt !== undefined - // The topology this lane claims: the owner loads upstream, and the + // The topology this test claims: the owner loads upstream, and the // follower routes its demand instead of running its own source. expect(owner.trace).toContain(`upstream`) expect(follower.trace).not.toContain(`upstream`) diff --git a/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts b/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts index af3d6a4802..5c298effcc 100644 --- a/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts +++ b/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts @@ -20,7 +20,7 @@ * Observations: the records of both sessions, judged by the shared judge * (`persisted-demand-coverage-model-oracle.ts`) at each session 2 checkpoint. * - * Limits: No device runs here. The helper backs the driver with better-sqlite3 unless a runtime factory is supplied (`TANSTACK_DB_MOBILE_SQLITE_FACTORY_MODULE`). The emulator runtime lane (`TANSTACK_DB_EXPO_RUNTIME_PLATFORM`) skips this witness, as it skips the conformance suite, because that lane runs the native driver through its own app. The sessions are two driver instances in one process over one file, not an app restart. + * Limits: No device runs here. The helper backs the driver with better-sqlite3 unless a runtime factory is supplied (`TANSTACK_DB_MOBILE_SQLITE_FACTORY_MODULE`). The emulator runtime production path (`TANSTACK_DB_EXPO_RUNTIME_PLATFORM`) skips this witness, as it skips the conformance suite, because that production path runs the native driver through its own app. The sessions are two driver instances in one process over one file, not an app restart. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -44,7 +44,7 @@ afterEach(() => { const DESCRIBE = `expo persisted demand coverage (receiving witness)` -// The emulator lane runs the native driver; this witness runs the shimmed driver. +// The emulator production path runs the native driver; this witness runs the shimmed driver. const runtimePlatform = process.env.TANSTACK_DB_EXPO_RUNTIME_PLATFORM?.trim() const describeShimmedDriver = runtimePlatform ? describe.skip : describe diff --git a/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts b/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts index 67db0fb857..9a363bf26e 100644 --- a/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts +++ b/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts @@ -147,8 +147,8 @@ type GeneratedPropertyCampaign = { parameters: ReturnType } -// The fixed campaign pins its seed and stands aside on every path replay. The -// random campaign runs in ordinary and seed-only runs, and as the replay of +// The fixed campaign fixes its campaign seed and stands aside on every path replay. The +// random campaign runs in ordinary and reseeded campaigns, and as the replay of // its property, where oraclePropertyOptions returns the recorded path. const fixedGeneratedCampaign: GeneratedPropertyCampaign = { name: `fixed`, diff --git a/packages/node-db-sqlite-persistence/tests/expression-index.test.ts b/packages/node-db-sqlite-persistence/tests/expression-index.test.ts index 316d36ba75..5fbef18a5c 100644 --- a/packages/node-db-sqlite-persistence/tests/expression-index.test.ts +++ b/packages/node-db-sqlite-persistence/tests/expression-index.test.ts @@ -80,7 +80,7 @@ describe(`SQLite expression-index oracle`, () => { expect(readOracleRunConfig({ TANSTACK_DB_ORACLE_SEED: `1659005` })).toEqual( { multiplier: 1, - replaySeed: 1659005, + campaignSeed: 1659005, replayPath: undefined, replayProperty: undefined, }, @@ -93,7 +93,7 @@ describe(`SQLite expression-index oracle`, () => { }), ).toEqual({ multiplier: 1, - replaySeed: -1659005, + campaignSeed: -1659005, replayPath: `0:1:2`, replayProperty: `sqlite-persistence.expression-index`, }) @@ -1763,7 +1763,7 @@ describe(`SQLite expression-index oracle`, () => { .all(jsonPath, target) as Array // Same correct rows, different plan: a row-only assertion is - // false-green. This predicate-plan mismatch kills the hostile mutant. + // false-green. This predicate-plan mismatch kills the mutant. expect(boundRows).toEqual(literalRows) expect(planUsesNamedIndex(literalPlan, `rows`, `literal_ddl`)).toBe(true) expect(planScansTable(literalPlan, `rows`)).toBe(false) diff --git a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts index dbe9876e9b..9a4849a283 100644 --- a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts @@ -97,15 +97,15 @@ type Snapshot = { * `ledger` (ordered pending records and counts), `serial` (the active count), * and `retirement` (what a removal or clear retires). * - * Reach and controls: one pinned history reaches every transition. Two pinned - * histories cross retry-deadline order, and two more pin an active removal + * Reach and controls: one fixed history reaches every transition. Two fixed + * histories cross retry-deadline order, and two more fix an active removal * with a waiting sibling and a blocked FIFO head at its retry deadline. Five * calibrations prove the comparison rejects bypass, double issue, stale * payload, stale clear, and failed selective retirement, and three more reject * a stale next payload, revoked work kept in the pending ledger, and a wrong * count. The property is `offline.key-scheduler-fifo-ledger` on * `oracleProperty`; a failure prints the seed, path, and property to set to - * replay it, and a path replay runs only the random lane. + * replay it, and a path replay runs only the random campaign. * * Limits: persistence, caller promise settlement, leadership, and real timers * have separate owners. The model does not promise fairness beyond FIFO order. @@ -286,7 +286,7 @@ function buildLegalHistory(tokens: Array): Array { } // The tokens a command list came from, found by search and accepted only if -// they rebuild that exact list. A pinned command list is then a member of the +// they rebuild that exact list. A fixed command list is then a member of the // generated language, and fast-check can shrink it. function tokensOf(history: ReadonlyArray): Array { const tokens: Array = [] @@ -404,7 +404,7 @@ function expectedSnapshot(model: Model): Snapshot { // snapshot, the transaction a `start` is given, and the IDs a removal // retires (requested pending IDs other than the active one, once each). A // command the lifecycle does not allow is a setup error with its name: the -// grammar never generates one, so a pin or a grammar change that does is +// grammar never generates one, so a fixed history or a grammar change that does is // caught here. type Owed = { @@ -729,7 +729,7 @@ oracleProperty({ measured: commandTypes, runs: 150, fixedSeeds: [1815], - pins: [ + fixed: [ { name: `every modeled transition`, enters: [...commandTypes], diff --git a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts index 10b84b6090..15dece72eb 100644 --- a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts @@ -424,7 +424,7 @@ oracleProperty({ fixedSeeds: [20260916], // A storage fault in the middle of the queue: the stop law needs a peer // behind the fault. - segments: [ + constructed: [ { name: `a phase write fault before a queued peer`, enters: [`phase write fault`, `fault before a queued peer`], @@ -438,7 +438,7 @@ oracleProperty({ }), }, ], - pins: [ + fixed: [ { name: `a ready tail waits for a delayed head with the smallest delay`, history: { delay: 2, plans: [{ ...ok }, { ...ok }] }, diff --git a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts index 7eae4855f2..b4822c314c 100644 --- a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts @@ -74,7 +74,7 @@ import type { TestItem } from './harness' * Reach: where the interruption landed for each transaction: before * admission (`refused`), after admission but before its provider call started * (`queued`), or after the call started (`issued`), whether or not the - * provider has answered, per interruption. Pins enter every + * provider has answered, per interruption. Fixed histories enter every * reachable landing; going offline cannot refuse admission. * * Limits: disjoint keys only, so sibling rollback is the settlement oracle's; @@ -455,7 +455,7 @@ function checkInterruption( // Fixed histories that reach each landing: a late interruption lands after // the provider call (`issued`), an early one before it (`queued`), and with // two transactions an immediate one stops the second admission (`refused`). -const landingPin = ( +const fixedLanding = ( name: string, interruption: Interruption, outcomes: Array, @@ -481,15 +481,15 @@ oracleProperty({ // each timing keeps the 60 runs the immediate-only oracle had. runs: 120, fixedSeeds: [20251103], - pins: [ - landingPin(`leadership issued`, `leadership`, [`fulfil`], 40), - landingPin(`leadership queued`, `leadership`, [`fulfil`], 1), - landingPin(`leadership refused`, `leadership`, [`fulfil`, `reject`], 0), - landingPin(`offline issued`, `offline`, [`fulfil`], 40), - landingPin(`offline queued`, `offline`, [`fulfil`], 1), - landingPin(`dispose issued`, `dispose`, [`fulfil`], 40), - landingPin(`dispose queued`, `dispose`, [`fulfil`], 1), - landingPin(`dispose refused`, `dispose`, [`fulfil`, `reject`], 0), + fixed: [ + fixedLanding(`leadership issued`, `leadership`, [`fulfil`], 40), + fixedLanding(`leadership queued`, `leadership`, [`fulfil`], 1), + fixedLanding(`leadership refused`, `leadership`, [`fulfil`, `reject`], 0), + fixedLanding(`offline issued`, `offline`, [`fulfil`], 40), + fixedLanding(`offline queued`, `offline`, [`fulfil`], 1), + fixedLanding(`dispose issued`, `dispose`, [`fulfil`], 40), + fixedLanding(`dispose queued`, `dispose`, [`fulfil`], 1), + fixedLanding(`dispose refused`, `dispose`, [`fulfil`, `reject`], 0), ], }, model: interruptionModel, diff --git a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts index fb13405d1b..85b33280c5 100644 --- a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts @@ -14,7 +14,7 @@ import { } from './harness' import { storedTransaction } from './leadership-replay-support' import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' /** @@ -58,7 +58,7 @@ import type { OfflineTransaction } from '../src/types' * calls and idempotency keys, outbox contents, scheduler counts, leadership, * retry records, caller promise settlement, optimistic rows, and cleanup. * - * Reach and controls: pinned histories force each lifecycle boundary and both + * Reach and controls: fixed histories force each lifecycle boundary and both * mixed-deletion positions. Fixed and random campaigns cover legal adjacent * histories and support seed/path replay. Delayed-read, stale-admission, and * mixed-removal witnesses reject the recorded pre-fix behaviors. The action @@ -112,8 +112,8 @@ function foldReplayLedger(events: ReadonlyArray): { // pnpm --dir packages/offline-transactions exec vitest run \ // tests/leadership-replay-oracle.property.test.ts // -// Grammar controls: each fixed lane enumerates the named causal boundary, and -// the seedless lane samples adjacent values with the same property/checkpoint. +// Grammar controls: each fixed campaign enumerates the named causal boundary, and +// the random campaign samples adjacent values with the same property/checkpoint. // Startup count 1–4 distinguishes empty peer work from multiple retained IDs; // the four disposal cuts distinguish construction, election, read, and hook. // Serial count 2–5 supplies an issued call plus peers; loss/disposal and @@ -121,11 +121,11 @@ function foldReplayLedger(events: ReadonlyArray): { // the payload checks that a surviving row is replayed unchanged. Repeated // reports range 0–5 before and after settlement; separate turns and a false // then true report distinguish coalescing from leadership reacquisition. The -// pinned one-report example reconstructs that overlap. Stale reads remove -// 1–5 of 2–6 stored IDs (clamped to leave a peer); the pinned 3/1 example +// fixed one-report example reconstructs that overlap. Stale reads remove +// 1–5 of 2–6 stored IDs (clamped to leave a peer); the fixed 3/1 example // reconstructs durable removal after capture. Delayed reads vary 1–4 peers, // 1–3 scans, and removal before/after acknowledgment; both one-scan cuts are -// pinned. Storage failure varies keys/get and 1–3 retained records. Removing +// fixed. Storage failure varies keys/get and 1–3 retained records. Removing // any of these axes loses the stated cut, multiplicity, or preservation check. // Delivery before capture and acknowledgment before provider completion are // causally invalid histories, so neither is generated. Zero stored work and @@ -162,7 +162,7 @@ const storageFailureOracle = readOfflineOracleConfig({ }) // The stale-read campaign compares each delivered read with the ledger fold, -// the model. The append-only judgment pins the fold's own result. +// the model. The append-only judgment fixes the fold's own result. declareOracle({ check: [checkStaleRead], model: [foldReplayLedger] }) const startupCases = (seed: number | undefined) => @@ -173,8 +173,8 @@ const startupCases = (seed: number | undefined) => }), ) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of STARTUP_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of STARTUP_PROPERTY. async function checkStartup({ boundary, seed, @@ -284,7 +284,7 @@ async function checkStartup({ ) } }), - oracleLaneOptions(startupOracle, seed), + oracleCampaignOptions(startupOracle, seed), ) } @@ -306,8 +306,8 @@ const serialWorkCases = (seed: number | undefined) => })), ) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of SERIAL_WORK_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of SERIAL_WORK_PROPERTY. async function checkSerialWork({ stop, boundary, @@ -484,7 +484,7 @@ async function checkSerialWork({ } }, ), - oracleLaneOptions(serialWorkOracle, seed), + oracleCampaignOptions(serialWorkOracle, seed), ) } @@ -503,8 +503,8 @@ oracleReplayTest(it, SERIAL_WORK_PROPERTY).each(serialWorkCases(undefined))( const reportCases = (seed: number | undefined) => [`provider`, `acknowledgment`].map((boundary) => ({ boundary, seed })) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of REPORT_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of REPORT_PROPERTY. async function checkReport({ boundary, seed, @@ -673,7 +673,7 @@ async function checkReport({ }, ), { - ...oracleLaneOptions(leadershipReportOracle, seed), + ...oracleCampaignOptions(leadershipReportOracle, seed), examples: [ [ { @@ -698,8 +698,8 @@ oracleReplayTest(it, REPORT_PROPERTY).each(reportCases(undefined))( checkReport, ) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of STALE_READ_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of STALE_READ_PROPERTY. async function checkStaleRead(seed: number | undefined): Promise { await fc.assert( fc.asyncProperty( @@ -775,7 +775,7 @@ async function checkStaleRead(seed: number | undefined): Promise { }, ), { - ...oracleLaneOptions(staleReadOracle, seed), + ...oracleCampaignOptions(staleReadOracle, seed), examples: [[{ count: 3, removedPrefix: 1 }]], }, ) @@ -807,8 +807,8 @@ it(`append-only replay judgment rejects stale admission after durable removal`, // A replay scan can capture A, then block on another storage read until A's // successful deletion. Pending-only dedupe no longer remembers A at admission. // Peers admitted only through this scan must still execute exactly once. -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of DELAYED_READ_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of DELAYED_READ_PROPERTY. async function checkDelayedRead(seed: number | undefined): Promise { await fc.assert( fc.asyncProperty( @@ -947,7 +947,7 @@ async function checkDelayedRead(seed: number | undefined): Promise { }, ), { - ...oracleLaneOptions(delayedReadOracle, seed), + ...oracleCampaignOptions(delayedReadOracle, seed), examples: [ [ { @@ -982,8 +982,8 @@ oracleReplayTest(it, DELAYED_READ_PROPERTY).each([undefined])( const storageFailureCases = (seed: number | undefined) => [`keys`, `get`].map((operation) => ({ operation, seed })) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of STORAGE_FAILURE_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of STORAGE_FAILURE_PROPERTY. async function checkStorageFailure({ operation, seed, @@ -1037,7 +1037,7 @@ async function checkStorageFailure({ ) } }), - oracleLaneOptions(storageFailureOracle, seed), + oracleCampaignOptions(storageFailureOracle, seed), ) } diff --git a/packages/offline-transactions/tests/leadership-replay.test.ts b/packages/offline-transactions/tests/leadership-replay.test.ts index 22081f396c..426aa16221 100644 --- a/packages/offline-transactions/tests/leadership-replay.test.ts +++ b/packages/offline-transactions/tests/leadership-replay.test.ts @@ -1,5 +1,5 @@ // Leadership replay cases whose expectations are written by hand: a fixed -// outbox restart, pinned admission and retry-hook histories, stale reads, +// outbox restart, fixed admission and retry-hook histories, stale reads, // and failed deletions. The leadership and replay laws they illustrate are // owned by leadership-replay-oracle.property.test.ts. import { expect, it, vi } from 'vitest' diff --git a/packages/offline-transactions/tests/oracle-config.ts b/packages/offline-transactions/tests/oracle-config.ts index 47ff04632a..343db988ea 100644 --- a/packages/offline-transactions/tests/oracle-config.ts +++ b/packages/offline-transactions/tests/oracle-config.ts @@ -11,7 +11,7 @@ type OfflineOracleConfigOptions = { /** * The budgets of one offline oracle property. `runs` sizes its fixed-seed - * lane. `random` holds the shared property options for its random lane: a seed + * campaign. `random` holds the shared property options for its random campaign: a seed * alone reseeds it, and a path replay of `property` runs its recorded history * once. */ @@ -23,7 +23,7 @@ export function readOfflineOracleConfig({ runs: number random: ReturnType } { - // Both lanes share one budget: the scaled base plus the reserved fixed + // Both campaigns share one run budget: the scaled base plus the reserved fixed // examples. A path replay keeps its single run. const runs = oracleRuns(defaultRuns) + reservedRuns const random = oraclePropertyOptions(defaultRuns, property) @@ -34,14 +34,15 @@ export function readOfflineOracleConfig({ } /** - * A lane that passes fixed `examples` keeps them during a path replay. + * A campaign that passes fixed `examples` keeps them during a path replay. * fast-check counts the examples in the recorded path, so a replay without * them would select a different value. * - * The fast-check options for one lane. An `undefined` seed selects the random - * lane. A number selects the fixed lane, which keeps its ordinary budget. + * The fast-check options for one campaign. An `undefined` seed selects the random + * campaign. A number selects the fixed-seed campaign, which keeps its ordinary run + * budget. */ -export function oracleLaneOptions( +export function oracleCampaignOptions( config: ReturnType, seed: number | undefined, ): diff --git a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts index 2497620615..ad19287df8 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -13,7 +13,7 @@ import { import { declareOracle } from '../../db/tests/oracle-declaration' import { cleanupOfflineOracle } from './oracle-lifecycle' import { FakeStorageAdapter } from './harness' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' /** @@ -48,8 +48,8 @@ import type { OfflineTransaction } from '../src/types' * process or restarting an existing Collection's sync run. Seed/path replay is * oracle replay of a recorded failure. * - * Reach and controls: pinned histories force every edit kind and hostile marker. - * Fixed and random lanes support seed/path replay. Encoder and decoder faults + * Reach and controls: fixed histories force every edit kind and hostile marker. + * Fixed and random campaigns support seed/path replay. Encoder and decoder faults * inject Date/string confusion, wrong registry, omitted changes, and unknown * versions. Malformed generated markers are rejected before durable data can * be replaced; the single-case Temporal failure paths are unit tests. @@ -467,7 +467,7 @@ it.each([`modified`, `original`, `changes`] as const)( } }, ) -const pinned: Array = [ +const fixedHistory: Array = [ { kind: `insert`, slot: 1, before: twin, after: { runtime: 0.5, wire: 0.5 } }, { kind: `insert`, slot: 0, before: twin, after: datePair(1704067200000) }, { kind: `update`, slot: 1, before: datePair(0), after: twin }, @@ -488,20 +488,20 @@ const pinned: Array = [ } }), ] -// Grammar controls: the pinned history is a legal ten-edit reconstruction of +// Grammar controls: the fixed history is a legal ten-edit reconstruction of // every edit kind, both registry slots, Date/string twins, marker escapes, and // an own __proto__ key. Dropping one of those axes loses a distinct wire or // application-restart observation. Generated trees add empty and nested arrays // and objects with depth two and at most three children or keys. Current records // admit zero edits, while compatibility records start at one. Cycles and malformed // markers are outside this valid-tree grammar and have rejection witnesses -// above. The generated edit bound includes the complete pinned history. A +// above. The generated edit bound includes the complete fixed history. A // never-escape rule fails its marker-shaped user object; an always-escape rule // fails its ordinary object with an own __proto__ key at the wire checkpoint. -// This package's test root is separate from core's named replay portfolio. +// This package's test root is separate from core's named fixed-history portfolio. // Keep a local replay entry point rather than importing files outside rootDir. // Each random campaign is its own replay property, because a recorded path -// belongs to one generator. Its fixed lane and its random lane share one +// belongs to one generator. Its fixed campaign and its random campaign share one // generator, budget, and assertion set. const MUTATION_WIRE_PROPERTY = `offline.serializer.mutation-wire` const DATE_MARKER_PROPERTY = `offline.serializer.date-marker` @@ -526,7 +526,7 @@ const mutationEdits = fc.array( before: tree(2), after: tree(2), }), - { maxLength: pinned.length }, + { maxLength: fixedHistory.length }, ) const mutationTime = fc.integer({ min: -2000000000000, @@ -539,8 +539,8 @@ const mutationMeaningProperty = (fault: Fault = `none`) => const checkMutationWire = async (seed: number | undefined): Promise => { await fc.assert(mutationMeaningProperty(), { - ...oracleLaneOptions(mutationWireOracle, seed), - examples: [[pinned, 1704067200000]], + ...oracleCampaignOptions(mutationWireOracle, seed), + examples: [[fixedHistory, 1704067200000]], }) } @@ -565,15 +565,15 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( // TANSTACK_DB_ORACLE_PROPERTY=offline.serializer.mutation-wire const property = mutationMeaningProperty(`omit-changes`) const replay = mutationWireOracle.random - const { replaySeed } = readOracleRunConfig() + const { campaignSeed } = readOracleRunConfig() const captured = await fc.check(property, { ...replay, - seed: replaySeed ?? 20260914, + seed: campaignSeed ?? 20260914, }) expect(captured.failed).toBe(true) expect(captured.errorInstance).toMatchObject({ name: `AssertionError` }) expect(String(captured.errorInstance)).toContain(`mutation wire checkpoint`) - if (replaySeed !== undefined) return + if (campaignSeed !== undefined) return expect(captured.numShrinks).toBeGreaterThan(0) expect(captured.counterexamplePath).toMatch(/^\d+(?::\d+)*$/) @@ -603,7 +603,7 @@ it(`retains the wire mismatch when cleanup also fails`, async () => { let failure: unknown try { await checkRoundtrip( - [pinned[0]!], + [fixedHistory[0]!], 0, `omit-changes`, `encoder`, @@ -633,7 +633,7 @@ const checkDateMarker = async (seed: number | undefined): Promise => { before: leaf, after: leaf, }), - { minLength: 1, maxLength: pinned.length }, + { minLength: 1, maxLength: fixedHistory.length }, ), async (edits) => { // Old records had no object escape. Use only unambiguous legacy trees, @@ -656,7 +656,7 @@ const checkDateMarker = async (seed: number | undefined): Promise => { }, ), { - ...oracleLaneOptions(dateMarkerOracle, seed), + ...oracleCampaignOptions(dateMarkerOracle, seed), examples: [ [[{ kind: `update`, slot: 0, before: datePair(0), after: twin }]], ], @@ -683,13 +683,13 @@ const checkVersionTwo = async (seed: number | undefined): Promise => { before: tree(2), after: tree(2), }), - { minLength: 1, maxLength: pinned.length }, + { minLength: 1, maxLength: fixedHistory.length }, ), async (edits) => checkRoundtrip(edits, 0, `none`, `encoder`, false, true), ), { - ...oracleLaneOptions(versionTwoOracle, seed), - examples: [[pinned]], + ...oracleCampaignOptions(versionTwoOracle, seed), + examples: [[fixedHistory]], }, ) } @@ -734,7 +734,7 @@ it.each( : `Unsupported transaction value encoding: 5`, } await expect( - checkRoundtrip(pinned, 0, fault, boundary), + checkRoundtrip(fixedHistory, 0, fault, boundary), ).rejects.toMatchObject(expectedFailure) expect(decode).toHaveBeenCalledTimes(boundary === `encoder` ? 0 : 1) } finally { diff --git a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts index 7dfe23d709..afc512390b 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -20,7 +20,7 @@ import { captureUnhandledRejections, cleanupOfflineOracle, } from './oracle-lifecycle' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { TestItem } from './harness' import type { Transaction } from '@tanstack/db' import type { OfflineConfig } from '../src/types' @@ -65,7 +65,7 @@ import type { OfflineConfig } from '../src/types' * generated. When nobody reads an auto-commit's receipt, the Collection that * tracks the transaction owns its rejection; every generated transaction * writes to one. A caller may also register its completion waiter before the - * transaction is admitted. Pinned examples + * transaction is admitted. Fixed histories * reconstruct all-success, middle-failure, all-failure, and alternating * outcomes. Shared keys distinguish sibling rollback from disjoint-key * survival. Width distinguishes complete optimistic state for several rows @@ -94,13 +94,13 @@ import type { OfflineConfig } from '../src/types' * crash windows around the phase write. The controlled provider and fake * storage establish this executor boundary, not real server acknowledgement * timing, native storage completion, or multiple-owner leadership. A normal - * run pairs fixed and seedless campaigns. + * run pairs fixed and random campaigns. * A permanent named mutation function failure uses a separate rejection-pending * marker. The original caller promise rejects with that failure. A storage * failure also rejects the executor batch promise and stops further work. An * offline executor restart removes a marked outbox entry without another named * mutation function call or restoration of optimistic state. - * Direct executor checks pin the storage error, FIFO peer hold, and refusal of + * Direct executor checks cover the storage error, FIFO peer hold, and refusal of * further attempts. Outbox replay can invoke the named mutation function after * a crash if the marker write failed. * The `shouldRetry` option changes only the decision after a named mutation @@ -747,7 +747,7 @@ const checkSettlement = async (seed: number | undefined): Promise => { }, ), { - ...oracleLaneOptions(settlementOracle, seed), + ...oracleCampaignOptions(settlementOracle, seed), examples: [ [ { @@ -1612,7 +1612,7 @@ async function checkRetryDecision(seed: number | undefined): Promise { }, ), { - ...oracleLaneOptions(retryDecisionOracle, seed), + ...oracleCampaignOptions(retryDecisionOracle, seed), examples: retryDecisionExamples, }, ) @@ -2099,7 +2099,7 @@ async function checkRetryRecordFailure(seed: number | undefined) { } }), { - ...oracleLaneOptions(retryRecordOracle, seed), + ...oracleCampaignOptions(retryRecordOracle, seed), examples: [[2], [5]], }, ) @@ -2115,7 +2115,7 @@ oracleReplayTest(it, RETRY_RECORD_PROPERTY).each([undefined])( ) // The failed durable write can be first, middle, or last among three distinct -// IDs. All positions are pinned, so the conditional failure premise is reached +// IDs. All positions are fixed, so the conditional failure premise is reached // at each boundary. Removing the two admitted peers would hide accidental // global rejection. A duplicate ID is outside this legal admission grammar. async function checkDurableAdmissionFailure(seed: number | undefined) { @@ -2211,7 +2211,7 @@ async function checkDurableAdmissionFailure(seed: number | undefined) { } }), { - ...oracleLaneOptions(admissionOracle, seed), + ...oracleCampaignOptions(admissionOracle, seed), examples: [[0], [1], [2]], }, ) diff --git a/packages/preact-db/tests/conformance.test.ts b/packages/preact-db/tests/conformance.test.ts index 4b876f74af..2834662120 100644 --- a/packages/preact-db/tests/conformance.test.ts +++ b/packages/preact-db/tests/conformance.test.ts @@ -278,7 +278,7 @@ const preactDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/preact-db/tests/infinite-query-conformance.test.ts b/packages/preact-db/tests/infinite-query-conformance.test.ts index c5d7f5b517..e972e3ad9a 100644 --- a/packages/preact-db/tests/infinite-query-conformance.test.ts +++ b/packages/preact-db/tests/infinite-query-conformance.test.ts @@ -273,7 +273,7 @@ const preactInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(preactInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts b/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts index dab72779e3..09b8882e7d 100644 --- a/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts @@ -76,7 +76,7 @@ describe(`cursor adapter through production pagination`, () => { // cursor pager. Immediately after each direct write it compares public rows // and row-cache data with the fold, then checks that the page Query retains // its prior state and the pager retains its original rows. Fixed seed - // 20261012 and seedless campaigns use the same property and 50-run budget. + // 20261012 and random campaigns use the same property and 50-run budget. // Replay one shrink directly with TANSTACK_DB_ORACLE_SEED, _PATH, and // _PROPERTY=cursor-pagination.manual-write. it.each( diff --git a/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts index e050390895..c97a179468 100644 --- a/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts @@ -43,7 +43,7 @@ async function assertBoundaryProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(30, name) - if (replay.replaySeed === undefined) + if (replay.campaignSeed === undefined) await fc.assert(property, { ...options, seed: fixedSeed }) await fc.assert(property, options) } diff --git a/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts index b8d8a30665..b76e762b12 100644 --- a/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts @@ -44,7 +44,7 @@ async function assertCacheProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(runs, name) - if (replay.replaySeed === undefined) + if (replay.campaignSeed === undefined) await fc.assert(property, { ...options, seed: fixedSeed }) await fc.assert(property, options) } diff --git a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts index ba6a44f123..c65555bc6d 100644 --- a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts @@ -74,7 +74,7 @@ async function assertCursorProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(runs, name) - if (replay.replaySeed === undefined) { + if (replay.campaignSeed === undefined) { await fc.assert(property, { ...options, seed: fixedSeed }) } await fc.assert(property, options) @@ -343,8 +343,8 @@ describe(`opaque cursor adapter`, () => { // one, and the page count from the backend's page sizes alone (the model // never sees tokens). That the count is the shortest covering prefix is not // one README sentence: it follows from its transport rules with the exact -// counts the previous fixed tests pinned (three peer windows on pages of two -// fetch 4 pages; a drained source fetches every page). Driver: the production pager over the opaque backend; +// counts the previous fixed tests asserted (three peer windows on pages of two +// fetch 4 pages; a drained source fetches every page). Production driver: the production pager over the opaque backend; // it records each read's rows or error name and the backend's call count // after each step. Limits: one scope per pager, no reset, faults, or // cancellation (the reset and failure properties below own those). @@ -550,7 +550,7 @@ function checkReads( return pass } -const pinned = ( +const fixedHistory = ( name: string, pageSize: number, steps: ReadHistory[`steps`], @@ -590,9 +590,9 @@ oracleProperty({ ], runs: 100, fixedSeeds: [fixedSeed], - pins: [ + fixed: [ ...[1, 2, 5, 50].map((pageSize) => - pinned(`windows across backend pages of ${pageSize}`, pageSize, [ + fixedHistory(`windows across backend pages of ${pageSize}`, pageSize, [ readsOf( { offset: 0, limit: 0 }, { offset: 0, limit: 3 }, @@ -601,7 +601,7 @@ oracleProperty({ readsOf({ offset: 0, limit: undefined }, { offset: 30, limit: 2 }), ]), ), - pinned(`unequal peer windows serialize without refetching`, 2, [ + fixedHistory(`unequal peer windows serialize without refetching`, 2, [ readsOf( { offset: 0, limit: 3 }, { offset: 2, limit: 6 }, @@ -609,18 +609,18 @@ oracleProperty({ ), ]), // Alone and first, so no peer's fetch hides a zero-limit fetch. - pinned(`a zero limit performs no transport work`, 2, [ + fixedHistory(`a zero limit performs no transport work`, 2, [ readsOf({ offset: 3, limit: 0 }), readsOf({ offset: 0, limit: 0 }), ]), - pinned(`the largest safe window end`, 2, [ + fixedHistory(`the largest safe window end`, 2, [ readsOf({ offset: Number.MAX_SAFE_INTEGER - 1, limit: 1 }), ]), - pinned(`a window changed after its call`, 2, [ + fixedHistory(`a window changed after its call`, 2, [ { reads: [{ window: { offset: 1, limit: 3 } }], changeAfterCall: true }, ]), ...invalidWindows.map(({ offset, limit }, invalid) => - pinned( + fixedHistory( `invalid window ${offset}/${limit} rejects before transport`, 2, [ diff --git a/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts index f5ca35b1ef..df27909c39 100644 --- a/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts @@ -61,7 +61,7 @@ async function assertPublicationProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(runs, name) - if (replay.replaySeed === undefined) + if (replay.campaignSeed === undefined) await fc.assert(property, { ...options, seed: fixedSeed }) await fc.assert(property, options) } diff --git a/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts index 0055b1b4fa..ef7635f97c 100644 --- a/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts +++ b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts @@ -520,9 +520,9 @@ describe(`nested includes reachable-shape oracle`, () => { ) }) - it(`pins the reported 20-by-2-by-5-by-10 tree`, async () => { + it(`checks the reported 20-by-2-by-5-by-10 tree`, async () => { // These semantic counters do not claim to measure elapsed time or internal - // allocations. They pin source delivery at preload and reachable shape. + // allocations. They fix source delivery at preload and reachable shape. await checkNestedTreeShape(20, (observation) => expectNestedTreeShape(observation, 20), ) diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index 5156a7df4d..eff2f3fc57 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -5621,7 +5621,7 @@ describe(`query collection ownership lifecycle`, () => { * those indices to the documented static on-demand Query key for metadata * comparison. It compares public rows and exact accepted owner identities * after each action and release; forgetting ownership on any write fails the - * update lane, while retaining a deleted lifetime fails the reinsert lane. + * update grammar, while retaining a deleted lifetime fails the reinsert grammar. */ it.each( ([1, 2] as const).flatMap((ownerCount) => @@ -5657,7 +5657,7 @@ describe(`query collection ownership lifecycle`, () => { * and subset release it compares public rows with the reference. A fetched * cached result needs no request; a local cache edit with no later fetch * requires one to establish authority. The inverse order rejects blindly - * trusting any cached result; the cache-edit lanes reject mistaking + * trusting any cached result; the cache-edit grammars reject mistaking * setQueryData for a remote success. */ it.each( diff --git a/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts b/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts index e832509f60..b0a7f2f7b9 100644 --- a/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts +++ b/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts @@ -33,7 +33,7 @@ import type { Op, Row, StorageKind } from './persisted-direct-write-support' * the write runs. The reference does not share the persistence wrapper, which * is the code under judgment. It shares the Query Collection's direct-write * code, so it cannot find a fault in that code that both paths share. The - * precedence rules for writes that interleave with refetches are pinned as + * precedence rules for writes that interleave with refetches are fixed as * unit tests in `persisted-direct-write.test.ts`; they are not an oracle. * * ## Contract and ownership table diff --git a/packages/query-db-collection/tests/persisted-direct-write.test.ts b/packages/query-db-collection/tests/persisted-direct-write.test.ts index c2cd2fb983..2903a2c58b 100644 --- a/packages/query-db-collection/tests/persisted-direct-write.test.ts +++ b/packages/query-db-collection/tests/persisted-direct-write.test.ts @@ -1,5 +1,5 @@ // Ordinary unit tests for direct writes that interleave with refetches, and for -// the oracle harness's failure reporting. Their expected rows are pinned by +// the oracle harness's failure reporting. Their expected rows are fixed by // hand from the precedence rules in each test's comments. The matrix and ordering // laws are owned by persisted-direct-write-window.oracle.test.ts. import { describe, expect, it, vi } from 'vitest' @@ -65,7 +65,7 @@ describe(`the oracle harness keeps the primary failure`, () => { * stores nothing. * * Each case below states its expected rows from these rules. The histories - * are finite and pinned. Rule 4 is observed inside the refetch's fulfillment + * are finite and fixed. Rule 4 is observed inside the refetch's fulfillment * callback, not after a later flush, because a later flush hides a refetch * that settles early. */ diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 33647437aa..b7f18255be 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -508,7 +508,7 @@ describe(`Query collection type resolution tests`, () => { * types at compile time. * * Observations: `select` input, output-row keys, delete keys, collection - * reads, and mutation inputs. The paired configs are the hostile control: + * reads, and mutation inputs. The paired configs are the mutant control: * they differ only in whether `select` returns schema output or input. * Runtime schema parsing and result publication are outside this oracle. */ diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 3bfc49436c..e29328cb32 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11527,7 +11527,7 @@ describe(`QueryCollection`, () => { * A reload runs the Query Collection's `sync` again while the Collection still * shows the previous run's status. The new run's first successful result makes * it ready, and a failure before that fails the reload, whatever status the - * Collection showed when the reload began. The lane crosses the status before + * Collection showed when the reload began. This grammar crosses the status before * the reload (a failed first load, or ready) with the new run's outcome. */ describe(`reload settles on the new run's first result`, () => { @@ -11861,7 +11861,7 @@ describe(`reload settles on the new run's first result`, () => { * Checkpoint: the driver observes the window when the entry is gone (the * housekeeping commit has applied), polling rather than sleeping a fixed * time, so a loaded runner cannot move the observation before the expiry. - * A housekeeping begin marked `snapshot: true` is the hostile mutant: it + * A housekeeping begin marked `snapshot: true` is the mutant: it * replaces the kept rows with none. * * A third kind, `release-at-startup`, reaches the other housekeeping @@ -11872,7 +11872,7 @@ describe(`reload settles on the new run's first result`, () => { * Limits: releasing a Query's rows (`cleanupQueryInternal`) begins a * housekeeping transaction too, but no legal history begins it in the new * run while the reload is in flight, so a snapshot there is an equivalent - * mutant for this lane. Its callers are the run's own + * mutant for this grammar. Its callers are the run's own * cleanup and the fallback of a subset release. Core invokes a run's * cleanup (on cleanup, on a reload's retirement, or after sync entry) only * after it has retired that run's callbacks, so that begin does nothing. @@ -12078,7 +12078,7 @@ describe(`reload settles on the new run's first result`, () => { * run starts and gives that run startup maintenance. During the reload the * driver requests and releases a fresh subset, through a public change * subscription, in every microtask turn from `reload()` until the new - * run's `markReady()`. So the lane covers every turn of the window, + * run's `markReady()`. So the grammar covers every turn of the window, * whatever its width. The refetch history makes the same requests and * releases for the same number of turns. * @@ -12328,7 +12328,7 @@ describe(`reload settles on the new run's first result`, () => { * The model keeps each failing subset's latest error value and the listed * values, oldest first. A failure lists its value at the end unless it is * already listed. After every step, a listed value that no failing subset - * still holds leaves the list. The lane loads two subsets and enumerates + * still holds leaves the list. The grammar loads two subsets and enumerates * every history of four steps, each step a fetch of one subset's Query that * succeeds, fails with a fresh error, or fails with one error value shared by * every Query and fetch. After each step the node's `failureReason.errors` @@ -12479,7 +12479,7 @@ describe(`failureReason follows the still-failing Queries`, () => { thrownBefore + 1, ) const error = fails ? thrown.at(-1) : undefined - // The persisted run replays the plain lane's histories, whose reach + // The persisted production path replays the plain path's histories, whose reach // the plain run records. if (!persisted) recordReach(model, fetch, error) const previous = model.listed diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts index cf1801237a..620cde9671 100644 --- a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts +++ b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts @@ -112,15 +112,15 @@ import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' * that honors only `offset` already gets a wrong window at preload under a * joined filter: the loader repeats a continuation offset capped at the * window size. That loader defect is separate from #2136 and is recorded as - * open in the coverage map. The named replays still run both encodings. + * open in the coverage map. The named fixed histories still run both encodings. * - It does not check the window after a marker flip, even when the provider * is current. The loader used to stop refilling after a joined-side - * removal; a named replay now checks that trace, but the campaigns do not + * removal; a named fixed history now checks that trace, but the campaigns do not * check after flips. * - The generated campaigns use an uncapped provider. With a provider that * caps pages, a row whose order changes can drop out of the window after a * refetch even on main, through the loader's own repair. That defect is - * separate from #2136 and has an `it.fails` witness. The named replays run + * separate from #2136 and has an `it.fails` witness. The named fixed histories run * the issue trace with a page cap. * - The report laws bound when Query DB reports, not exactly when it must. * Whether a missing report leaves a gap is judged through the window law. @@ -132,7 +132,7 @@ import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' * accepted, so a later successful retry still sees the change. No public * history distinguishes that from recording earlier: the failed attempt * reports on its own, and the repair it starts reads the provider again. - * The application-failure replay checks only the window around a failed + * The application-failure fixed history checks only the window around a failed * application. * - A Query refetch that joins a running `refetch: true` acquisition on the * same Query hash does not report, because the guard cannot tell the two @@ -252,7 +252,7 @@ function expectedCoverage( // write from its start, or from just before its refetch, and releases it // after the refetch. Ids come from a small space, so // inserts often land between served pages and upserts often move a served -// row. Named replays use the same commands in other orders. With one order +// row. Named fixed histories use the same commands in other orders. With one order // term, the fixture maps a generated rank to `rank * 100 + id`, so ranks stay // unique. type Command = @@ -307,7 +307,7 @@ const caseArbitrary: fc.Arbitrary = fc.record({ functionKey: fc.boolean(), terms: fc.constantFrom(1 as const, 2 as const), orderKey: fc.constantFrom(`rank` as const, `label` as const), - // Page caps run in named replays only; see the limits above. + // Page caps run in named fixed histories only; see the limits above. pageCap: fc.constant(undefined), limit: fc.integer({ min: 1, max: 3 }), offset: fc.integer({ min: 0, max: 1 }), diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 70eb208559..f2b0e61ad0 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -317,7 +317,7 @@ const reactDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: true, suspense: true, pooledEqFilters: true }, } diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index eaa5a39aa7..2fc82b0f55 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -40,7 +40,7 @@ * claims its local source objects while the browser source is still loading; * a later same-hash preload over another object must reject. The model expects * the server row before browser readiness and the browser row after handoff. - * A hostile observer that omits the committed claim fails at the conflicting + * A mutant observer that omits the committed claim fails at the conflicting * preload assertion while the source remains unready. * A Suspense read is itself a preload, even if React abandons its render. With * a pending hydrated stream, that first local preload starts the source and diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index 2cde689f47..1c53ae12f8 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -325,7 +325,7 @@ const reactInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(reactInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/packages/react-db/tests/infinite-query-render-cuts-support.tsx b/packages/react-db/tests/infinite-query-render-cuts-support.tsx index 7388301247..735ee85d34 100644 --- a/packages/react-db/tests/infinite-query-render-cuts-support.tsx +++ b/packages/react-db/tests/infinite-query-render-cuts-support.tsx @@ -4,7 +4,7 @@ import { mockSyncCollectionOptions } from '../../db/tests/utils' import type { ReactNode } from 'react' // Shared fixtures for the infinite query render-cut tests. The laws are owned by -// infinite-query-render-cuts-oracle.test.tsx, and the ordinary pinned tests live in +// infinite-query-render-cuts-oracle.test.tsx, and the ordinary fixed tests live in // infinite-query-render-cuts.test.tsx. export type Row = { id: string; label: string; rank: number } diff --git a/packages/react-db/tests/source-id-reuse-support.tsx b/packages/react-db/tests/source-id-reuse-support.tsx index 2ee1ead0f7..ac8164957b 100644 --- a/packages/react-db/tests/source-id-reuse-support.tsx +++ b/packages/react-db/tests/source-id-reuse-support.tsx @@ -4,7 +4,7 @@ import { mockSyncCollectionOptions } from '../../db/tests/utils' import type { ReactNode } from 'react' // Shared fixtures for the React source ID reuse tests. The reuse law is owned by -// source-id-reuse-oracle.test.tsx; ordinary pinned tests live in source-id-reuse.test.tsx. +// source-id-reuse-oracle.test.tsx; ordinary fixed tests live in source-id-reuse.test.tsx. export type Row = { id: string; value: string; rank: number } export class TestErrorBoundary extends Component< diff --git a/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx b/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx index b18428f106..55364cc895 100644 --- a/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx +++ b/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx @@ -13,10 +13,10 @@ * Suspense commits its rows. Rechecking the first binding after the second * rejects a cache repair that overwrites a retained Collection's settings. * Both unscoped and DbProvider-scoped caches receive the same histories. A - * second lane uses distinct constructor references with the same class name; - * names cannot replace the declared constructor reference. A presence lane + * same-name grammar uses distinct constructor references with the same class name; + * names cannot replace the declared constructor reference. A presence grammar * compares omitted fields with explicit undefined in both orders. - * A focused pending lane holds source writes while two separate boundaries + * A focused pending grammar holds source writes while two separate boundaries * show fallbacks, then checks the same output law after delivery. It crosses * mode/constructor equality and both cache scopes, without multiplying the * entire ready matrix. This driver judges no hydration, abandoned render, @@ -85,17 +85,17 @@ const namedConfigurations = ([`off`, `eager`] as const).flatMap((autoIndex) => const pairs = [ ...indexOptionAbsencePairs().map((history) => ({ ...history, - lane: `presence`, + kind: `presence`, })), ...indexConfigurationPairs(configurations).map((history) => ({ ...history, - lane: `built-in`, + kind: `built-in`, })), ...indexConfigurationPairs(namedConfigurations) .filter( ({ first, second }) => first.defaultIndexType !== second.defaultIndexType, ) - .map((history) => ({ ...history, lane: `same-name` })), + .map((history) => ({ ...history, kind: `same-name` })), ] const histories = pairs.flatMap((history, cell) => (history.first.autoIndex === `off` && @@ -151,9 +151,9 @@ afterAll(() => { test.each(histories)( `each initial Suspense binding keeps its indexing settings: %j`, - async ({ first, second, identity, cell, scope, lane, phase }) => { + async ({ first, second, identity, cell, scope, kind, phase }) => { const reach = createOracleReach( - `React Suspense indexing/${scope}/${phase}/${lane}/${identity}/${cell}`, + `React Suspense indexing/${scope}/${phase}/${kind}/${identity}/${cell}`, [ `first-admission`, `second-admission`, @@ -170,7 +170,7 @@ test.each(histories)( const pendingReach = phase === `pending` ? pendingReaches[scope]! : undefined pendingReach?.beginHistory() - if (lane === `same-name`) { + if (kind === `same-name`) { expect(first.defaultIndexType).not.toBe(second.defaultIndexType) expect(first.defaultIndexType?.name).toBe(second.defaultIndexType?.name) } diff --git a/packages/react-db/tests/suspense-local-options-oracle.test.tsx b/packages/react-db/tests/suspense-local-options-oracle.test.tsx index 36195c75fe..efec76c070 100644 --- a/packages/react-db/tests/suspense-local-options-oracle.test.tsx +++ b/packages/react-db/tests/suspense-local-options-oracle.test.tsx @@ -7,9 +7,9 @@ * each committed mount and the first is checked again after its peer mounts. * These mounts create no reusable DbClient preload record. Cached-result * getKey/schema conflicts are judged by cache-options-oracle's warm driver. - * A focused pending lane keeps delivery held until two separate boundaries + * A focused pending grammar keeps delivery held until two separate boundaries * show their fallbacks. Equal/different callback and scalar declarations then - * use the same committed-output comparison as the ready lane. It checks no + * use the same committed-output comparison as the ready grammar. It checks no * abandoned render, rejection or cleanup race. * This receiver makes no server-render or hydration scheduling claim. */ diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx index a196313acf..2c8c3351f2 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx @@ -32,7 +32,7 @@ describe(`useLiveInfiniteQuery type assertions`, () => { * Law and source: the public pre-created live-query Collection overload must * preserve its row, key, and utility types through `useLiveInfiniteQuery`. * The compile-time observation cut is the returned public result. Missing-row - * and wrong-key accesses are hostile controls. Pagination timing and runtime + * and wrong-key accesses are mutant controls. Pagination timing and runtime * page contents remain in the infinite-query conformance suite. */ it(`preserves pre-created collection row, key, and utility types`, () => { diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts index 80b32dcbae..07eda7060e 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts @@ -52,7 +52,7 @@ afterEach(runActiveCleanupFns) * Refinement evidence: query execution counts prove the SELECT reached the * adapter. Named empty, reordered, duplicated, missing-key, and swapped-value * wrong answers challenge the checker. Normal execution runs the same alias - * properties and budgets in retained fixed-seed and seedless-random lanes. + * properties and budgets in retained fixed-seed and seedless-random campaigns. * TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY * select one exact replay. * Real-provider refinement: frozen receipts from the published 15.2.7 Native @@ -83,9 +83,9 @@ const aliasOracleRuns = readAliasOracleRuns() const aliasOracleReplay = readOracleRunConfig() /** - * Registers a property's two campaigns. The fixed campaign pins its seed and + * Registers a property's two campaigns. The fixed campaign fixes its campaign seed and * stands aside on every path replay. The random campaign runs in ordinary and - * seed-only runs, and as the replay of its property, where the shared helper + * reseeded campaigns, and as the replay of its property, where the shared helper * returns the recorded path. */ function registerAliasOracle( diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 0eda3194a7..a1a00aa81f 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -1,7 +1,7 @@ /** * Ordinary op-sqlite driver tests: provider receipts, result-envelope decoding, * transactions, and fixed SQL-alias cases. Each checks a fixed case against - * values written out in the test, a replay-configuration parser, or a hostile + * values written out in the test, a replay-configuration parser, or a mutant * fixture. None compares with an independent model, so none is oracle coverage. * The alias law and its generated checks are in op-sqlite-driver-oracle.test.ts, * and the shared helpers are in op-sqlite-driver-support.ts. @@ -328,7 +328,7 @@ it(`selects a fresh seed without changing the random alias campaign budget`, () ).toEqual({ numRuns: runs, seed: expect.any(Number) }) }) -it(`reseeds the random alias campaigns from a seed-only replay`, () => { +it(`reseeds the random alias campaigns from a campaign seed alone`, () => { expect( oracleRandomParameters( 50, diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index bba314c10f..c1df9c893b 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -298,7 +298,7 @@ const solidDriver: LiveQueryDriver = { // rather than exposing a readable isLoadingError flag. That's a framework idiom, not a // gap — the error-status scenario is parametrized to assert it via the boundary. errorSurface: `throw`, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: true, pooledEqFilters: true }, } diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index fa7143ec6f..22d0423260 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -259,7 +259,7 @@ const svelteDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts index 74123a75d1..39d95dde83 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -270,7 +270,7 @@ const svelteInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(svelteInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts index c8ff8436db..bd99907fb7 100644 --- a/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts @@ -10,7 +10,7 @@ * portable error-object identity. The fixture supplies native SSR, JSON * transport and hydrate(); construction and pre-effects record the two public * cuts. A failure is the provider's fail() before readiness, a loading error - * (`isLoadingError`). This lane does not model throwing provider callbacks, + * (`isLoadingError`). This grammar does not model throwing provider callbacks, * which crash the Collection (`isCrashed`), or Suspense. */ import { TextEncoder } from 'node:util' diff --git a/packages/svelte-db/tests/ssr-snapshot-fixture.ts b/packages/svelte-db/tests/ssr-snapshot-fixture.ts index f05cd2895a..762f011a0d 100644 --- a/packages/svelte-db/tests/ssr-snapshot-fixture.ts +++ b/packages/svelte-db/tests/ssr-snapshot-fixture.ts @@ -59,7 +59,7 @@ export async function renderSsrSnapshotHistory( if (history.sourceState === `ready`) await warming else void warming.catch(() => {}) } - // Register the warmed source for normalized dehydration. This lane models + // Register the warmed source for normalized dehydration. This `source` preparation models // explicit source registration, not another awaited preload operation. if (history.preparation === `source`) client.collection(descriptor) if ( diff --git a/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts index 851eab67ff..75678eb69a 100644 --- a/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts @@ -224,7 +224,7 @@ describe(`Svelte real SSR handoff`, () => { }) /** - * This receiving lane keeps one client's transport while native Svelte roots + * This receiving path keeps one client's transport while native Svelte roots * initialize at different times. It reuses the shared preparation judgment and * receiving-history grammar. Only initialization kind changes the initial-view * obligation: hydrate() must reproduce its server markup; mount() must read @@ -364,7 +364,7 @@ async function checkReceivingHistory( try { // The real server fixture returns a JSON copy. A second copy here makes - // the transport boundary explicit for this receiving lane too. + // the transport boundary explicit for this receiving path too. client.hydrate(JSON.parse(JSON.stringify(rendered.state))) for (let index = 0; index < history.previousReaders; index++) { await initialize(`hydrate`, { diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index d194c3afa8..a73e61e9b9 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -735,7 +735,7 @@ it.each(earlyDemandPlans)( * has arrived. A later change to another row then checks that the listener * is still alive. An older event delivered after a newer page can show its * older value until the newer event that follows it arrives; the adapter has - * no row version to order an event against a page, so the lane only checks a + * no row version to order an event against a page, so the page-event grammar only checks a * row once all of its events have arrived. */ type RacePlan = { @@ -1033,8 +1033,8 @@ it.each(heldPlans)( * The load lists the service's current rows. Because the sync adapter stops * receiving changes, it reports the Collection's `error`, so an application can * see that the rows can grow stale; only a reload or restart subscribes again. This - * lane makes that limit executable; reconnecting is left to the TrailBase - * provider, and the lane does not claim the limit is the right product + * stream-end grammar makes that limit executable; reconnecting is left to the TrailBase + * provider, and the grammar does not claim the limit is the right product * behavior. * * Reference: the same service model. The prediction after the stream ends is @@ -1042,7 +1042,7 @@ it.each(heldPlans)( * * Grammar (exhaustive, 2 histories): the stream closes gracefully or fails * with a read error after the first page applies; then the service changes a - * row, and an on-demand load runs. The lane pins one stream end followed by + * row, and an on-demand load runs. The stream-end grammar covers one stream end followed by * one change and one load. It does not cover several later loads, a page in * flight when the stream ends, or cleanup between them. * @@ -1126,7 +1126,7 @@ it.each([`close`, `read-error`] as const)( /** * ## Do several pages, several changes, and overlapping loads still converge? * - * Law: the same one as the page-event lane. Once every page and every change + * Law: the same one as the page-event grammar. Once every page and every change * event for a row have arrived, the Collection holds the service's current * value for that row, or no row if the service deleted it. Between steps, a * row that a change event touched never returns to an older value. @@ -1387,8 +1387,8 @@ async function checkPageHistory(history: PageHistory) { } } -// Named replays: each closes one dimension the single-page lane leaves open. -const pageReplays: Array<[string, PageHistory]> = [ +// Named fixed histories: each closes one dimension the single-page grammar leaves open. +const pageFixedHistories: Array<[string, PageHistory]> = [ [ `an insert and its update in transit until after the page`, { @@ -1460,7 +1460,7 @@ const pageReplays: Array<[string, PageHistory]> = [ ], ] -it.each(pageReplays)(`converges: %s`, (_name, history) => +it.each(pageFixedHistories)(`converges: %s`, (_name, history) => checkPageHistory(history), ) fcTest.prop([pageHistoryArb], { seed: 205_917, numRuns: oracleRuns(40) })( diff --git a/packages/trailbase-db-collection/tests/lifecycle.test.ts b/packages/trailbase-db-collection/tests/lifecycle.test.ts index e330f80fa9..8b50bea70f 100644 --- a/packages/trailbase-db-collection/tests/lifecycle.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle.test.ts @@ -278,7 +278,7 @@ it.each( * row the service deleted while no stream listened stays: that is this * adapter's limit for on-demand reloads. * - * Reference: the same service as the eager reload lane. Between the runs + * Reference: the same service as the eager reload grammar. Between the runs * the service updates row 1 and deletes row 2 with no change event; row 3 * is untouched. Checkpoint: once the reload settles and the replayed load * has applied, row 1 shows its edit, row 3 survives, and row 2 keeps its diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index ead1e1c23c..9185fd2f05 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -272,7 +272,7 @@ const vueDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index 9bc8190393..d44803a874 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -278,7 +278,7 @@ const vueInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(vueInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/scripts/check-oracle-run-counts.mjs b/scripts/check-oracle-run-counts.mjs index 7a56a68865..ba2984e560 100644 --- a/scripts/check-oracle-run-counts.mjs +++ b/scripts/check-oracle-run-counts.mjs @@ -17,7 +17,7 @@ * `{ property: 'p', defaultRuns: n }`, or the framework definition * `oracleProperty({ id: 'p', histories: { runs: n } })`), and otherwise by its * fixed seed (`{ seed: s, numRuns: oracleRuns(n) }`, or a seed `s` in that - * definition's `fixedSeeds`, whose lane runs the same `n`). A key or count is a literal, a + * definition's `fixedSeeds`, whose campaign runs the same `n`). A key or count is a literal, a * constant in the same file, or a constant that the file imports. An import * follows relative re-exports (`export { x } from`, `export * from`), so a * constant defined in a support module is tracked where the oracle uses it. @@ -334,7 +334,7 @@ function runBudgets(file, revision, reader) { if (histories?.type === `ObjectExpression`) { const runs = propertyValue(histories, `runs`, file, reader, revision) if (typeof property === `string`) record(`property ${property}`, runs) - // Each fixed seed's lane runs the same `runs` histories. + // Each fixed-seed campaign runs the same `runs` histories. const fixedSeeds = histories.properties.find( (candidate) => candidate.type === `Property` && diff --git a/scripts/demand-coverage-mutants.mjs b/scripts/demand-coverage-mutants.mjs index 49d3bda754..29affa6b91 100644 --- a/scripts/demand-coverage-mutants.mjs +++ b/scripts/demand-coverage-mutants.mjs @@ -1,4 +1,4 @@ -// Hostile mutants for persisted demand coverage. +// Mutants for persisted demand coverage. // // Each mutant replaces one exact piece of production code with a plausible // wrong design. The harness applies it in memory through diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js index db699657d2..a8ffdf39ca 100644 --- a/scripts/eslint-plugin-oracle/no-disabled.test.js +++ b/scripts/eslint-plugin-oracle/no-disabled.test.js @@ -106,7 +106,7 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { code: `type JSON = { marker: true }; const witness: OracleReplayWitness = { numRuns: 1 }; JSON.stringify(witness)`, filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, }, - // A known gap is pinned as failing on a fixed seed, not skipped. + // A known counterexample is kept failing on a fixed seed, not skipped. `it.fails('checks the fixed history', () => fc.assert(property, { seed: 2128, numRuns: oracleRuns(12) }))`, // The shared replay helpers may select a replay, in either polarity for // a registration and through the alias helper. @@ -354,7 +354,7 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { errors: [{ messageId: `singleRun` }], }, { - code: `fc.assert(property, { seed: replay.replaySeed, numRuns: 1 })`, + code: `fc.assert(property, { seed: replay.campaignSeed, numRuns: 1 })`, errors: [{ messageId: `singleRun` }], }, { @@ -362,7 +362,7 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { errors: [{ messageId: `singleRun` }], }, { - code: `fc.assert(property, { seed: replay.replaySeed, path: undefined, numRuns: 1 })`, + code: `fc.assert(property, { seed: replay.campaignSeed, path: undefined, numRuns: 1 })`, errors: [{ messageId: `singleRun` }], }, { @@ -397,7 +397,7 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { errors: [{ messageId: `guarded` }], }, { - code: `fc.assert(property, { seed: replay.replaySeed, path: replay.replayPath, numRuns: 1 })`, + code: `fc.assert(property, { seed: replay.campaignSeed, path: replay.replayPath, numRuns: 1 })`, errors: [{ messageId: `singleRun` }], }, { @@ -527,7 +527,7 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { { // A comment does not grant an exception. code: ` - // oracle-exclusion: known gap + // oracle-exclusion: known limit it.skip('checks random histories', () => fc.assert(property)) `, errors: [{ messageId: `skip` }], diff --git a/scripts/eslint-plugin-oracle/no-unit-test.test.js b/scripts/eslint-plugin-oracle/no-unit-test.test.js index c04ad96995..a894b99024 100644 --- a/scripts/eslint-plugin-oracle/no-unit-test.test.js +++ b/scripts/eslint-plugin-oracle/no-unit-test.test.js @@ -92,10 +92,10 @@ it('retains a row after an update', async () => { await run([{ type: 'update' }] { code: `${header}const run = (h) => checkHistory(h) const replay = () => run([]) -it('replays the pinned history', replay)`, +it('replays the fixed history', replay)`, filename, }, - // A model pin reaches the declared model. + // A model-rule expectation reaches the declared model. { code: `${header}it('keeps an empty diff pending', () => { expect(modelHistory([])).toEqual([]) })`, filename, @@ -110,7 +110,7 @@ it('replays the pinned history', replay)`, code: `import { declareOracle, oracleCalibrationTest } from './oracle-config.js' function checkHistory(history) { return history } declareOracle({ check: [checkHistory] }) -oracleCalibrationTest(it, 'p', 'fault')('rejects the hostile mutant', () => checkHistory(bad))`, +oracleCalibrationTest(it, 'p', 'fault')('rejects the mutant', () => checkHistory(bad))`, filename, }, // A class model. @@ -132,7 +132,7 @@ it('checks a fixed history', () => runHistory([]))`, { code: `import { replayHistory, modelHistory } from './history-reexport.js' it('replays', () => replayHistory([])) -it('pins the model', () => modelHistory([]))`, +it('checks the model', () => modelHistory([]))`, filename, }, // A private package's src/ is test code, so the rule follows it. diff --git a/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js b/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js index 21c226bd1c..c447d19228 100644 --- a/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js +++ b/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js @@ -2,7 +2,8 @@ * A completed-history count is output data, not a generated-property budget. * The maintainer's 2026-10-28 decision permits the typed OracleReplayWitness * record in its shared writer only when every value use serializes that record. - * AGENTS.md still requires one-run unpinned property options to be reported. + * AGENTS.md still requires one-run property options with no fixed history to + * be reported. * A type annotation cannot excuse an options alias, escape, mutation or second * use. The initializer must be a plain data record with witness fields; custom * serialization hooks can receive the record without a binding reference. @@ -91,7 +92,7 @@ for (const program of [...programs]) programs.push({ ...program, initializer }) // Only a dedicated recording program emits a count observation. Every other -// authored program has an unpinned literal count whose enforcement must remain. +// authored program has a literal count with no fixed history, whose enforcement must remain. function expectedReports(program) { return program.location === 'writer' && program.annotation === 'witness' && diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js index 2967ac95ef..ea169c1cd2 100644 --- a/scripts/eslint-plugin-oracle/rules/no-disabled.js +++ b/scripts/eslint-plugin-oracle/rules/no-disabled.js @@ -28,14 +28,14 @@ * - Playwright's runtime `test.skip()` or `test.fixme()` inside a test, * unless its condition is `browserName !== ''`: a control that * only one browser supports. - * - A generated property limited to one run that pins no history: a literal - * `numRuns` of 1 or 0, or a base of 1 passed to the run helpers. One random - * history is not a generated property. A witness pins its history with a - * recorded `path` (a literal, or a failure's `counterexamplePath`) or with - * a seed that is a number literal or a constant bound to one. A replay from - * configuration takes its options from `oraclePropertyOptions`, which + * - A generated property limited to one run that has no fixed history: a + * literal `numRuns` of 1 or 0, or a base of 1 passed to the run helpers. One + * random history is not a generated property. A witness fixes its history + * with a recorded `path` (a literal, or a failure's `counterexamplePath`) or + * with a seed that is a number literal or a constant bound to one. A replay + * from configuration takes its options from `oraclePropertyOptions`, which * returns one run for a path replay. - * - A literal run count above 1 that pins no history. It does not scale with + * - A literal run count above 1 that has no fixed history. It does not scale with * `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER`, so `oracleRuns` must wrap it. * - A private replay variable: a `process.env` read whose name ends in * `_SEED`, `_PATH`, `_PROPERTY`, or `_RUNS`, other than the shared @@ -43,8 +43,8 @@ * `TANSTACK_DB_ORACLE_PROPERTY`. The replay manifest, the guarded replay * runner, and the retired-variable check see only the shared variables. * - * The rule has no inline exception. A known gap is pinned with `it.fails` on - * a fixed seed, not skipped. + * The rule has no inline exception. A known counterexample is kept failing with + * `it.fails` on a fixed seed, not skipped. * * The rule cannot see a run count lowered to any value above 1, a run count * or seed held in a variable that is not a constant bound to a number, a call @@ -388,11 +388,11 @@ function propertyName(property) { } /** - * A one-run object pins its history when it replays a `path` or passes a + * A one-run object has a fixed history when it replays a `path` or passes a * fixed seed. An object that an `expect` matcher compares holds an observed * run count, and `fc.sample` draws examples without running a property. */ -function isPinnedOrObservation(object, sourceCode) { +function isFixedOrObservation(object, sourceCode) { const option = (name) => object.properties.find((property) => propertyName(property) === name) const path = option(`path`) @@ -587,17 +587,17 @@ export default { meta: { type: `problem`, docs: { - description: `Report oracle tests that stop running: .only, a skipped body, a switch-off condition, or one unpinned run.`, + description: `Report oracle tests that stop running: .only, a skipped body, a switch-off condition, or one run with no fixed history.`, }, schema: [], messages: { only: `\`.only\` runs only this test, so every other law in the file stops running. Remove it before committing.`, - skip: `\`.{{name}}\` stops this test from enforcing its law. Remove it. To keep a known gap visible, pin a failing history with \`it.fails\` and a fixed seed.`, + skip: `\`.{{name}}\` stops this test from enforcing its law. Remove it. To keep a known counterexample visible, write it as a failing fixed history with \`it.fails\` and a fixed seed.`, conditional: `\`.{{name}}\` can switch this test off in ordinary runs. Only \`.skipIf(oracleReplayRequested())\` or \`.skipIf(oracleReplaySkips(property))\` may switch an oracle test off, so that a replay can stand it aside. Remove the condition.`, alias: `\`.{{name}}\` is passed around as a value, so the rule cannot see what it switches off. Call it directly, or select a replay with \`oracleReplayTest(test, property)\` from oracle-config.`, aliasTest: `\`{{name}}\` is passed around as a value, so the rule cannot see what it registers or switches off. Call it directly, or select a replay with \`oracleReplayTest({{name}}, property)\` from oracle-config.`, guarded: `This test registers only under a condition, so it can stop running in ordinary runs. Register it unconditionally. A replay selection uses \`oracleReplayRequested()\` or \`oracleReplaySkips(property)\` from oracle-config, or \`oracleReplayTest(test, property)\`.`, - singleRun: `A generated property with one run that pins no history checks one random history. Pass a base run count to oracleRuns or oraclePropertyOptions, which also selects a replay. A witness passes a recorded \`path\` or a seed that is a number literal or a constant.`, + singleRun: `A generated property with one run that has no fixed history checks one random history. Pass a base run count to oracleRuns or oraclePropertyOptions, which also selects a replay. A witness passes a recorded \`path\` or a seed that is a number literal or a constant.`, replayVariable: `\`{{name}}\` selects a replay or a run count outside the shared variables, so the replay manifest, the guarded replay runner, and the retired-variable check cannot see it. Take the options from \`oraclePropertyOptions\` or \`oracleRandomParameters\` in oracle-config, which read TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY, and scale run counts with \`oracleRuns\`.`, literalRuns: `A literal run count does not scale with TANSTACK_DB_ORACLE_RUNS_MULTIPLIER. Wrap it in oracleRuns, or pass it to oraclePropertyOptions.`, }, @@ -605,14 +605,14 @@ export default { create(context) { const { sourceCode } = context - /** Whether `node` is the value of a `numRuns` option that a replay pins. */ - function isPinnedRunCount(node) { + /** Whether `node` is the value of a `numRuns` option that a replay fixes. */ + function isFixedRunCount(node) { const { parent } = node return ( parent.type === `Property` && propertyName(parent) === `numRuns` && parent.parent.type === `ObjectExpression` && - (isPinnedOrObservation(parent.parent, sourceCode) || + (isFixedOrObservation(parent.parent, sourceCode) || isSerializedReplayWitness( parent.parent, context.filename, @@ -639,7 +639,7 @@ export default { if (runHelpers.has(root) && names.length === 0) { if ( isSingleRunLiteral(constantNumber(node.arguments[0], sourceCode)) && - !isPinnedRunCount(node) + !isFixedRunCount(node) ) { context.report({ node: node.arguments[0], messageId: `singleRun` }) } @@ -756,7 +756,7 @@ export default { } // A constant bound to a number literal counts as that literal. const count = constantNumber(node.value, sourceCode) - if (count === undefined || isPinnedRunCount(node.value)) return + if (count === undefined || isFixedRunCount(node.value)) return context.report({ node, messageId: isSingleRunLiteral(count) ? `singleRun` : `literalRuns`, diff --git a/scripts/eslint-plugin-oracle/rules/no-unit-test.js b/scripts/eslint-plugin-oracle/rules/no-unit-test.js index a29c111f8e..d934d9779c 100644 --- a/scripts/eslint-plugin-oracle/rules/no-unit-test.js +++ b/scripts/eslint-plugin-oracle/rules/no-unit-test.js @@ -26,8 +26,8 @@ import { * use. Every test in the file must reach a declared part: * * - A generated property or a fixed history reaches the check. - * - A model pin reaches the model. It checks one model rule against its - * authority. + * - A model-rule expectation reaches the model. It checks one model rule + * against its authority. * - A calibration reaches the check. It shows that the check rejects a * wrong answer. * - A grammar calibration reaches the grammar. It shows that the generators @@ -1270,7 +1270,7 @@ export default { }, schema: [], messages: { - unitTest: `This test reaches no part that this file declares with declareOracle, so its expected result has no independent source. A unit test belongs in an ordinary test file: move it there. A fixed history runs through the declared check, a model pin uses the declared model, and a grammar calibration uses the declared grammar.`, + unitTest: `This test reaches no part that this file declares with declareOracle, so its expected result has no independent source. A unit test belongs in an ordinary test file: move it there. A fixed history runs through the declared check, a model-rule expectation uses the declared model, and a grammar calibration uses the declared grammar.`, unknownTypeModel: `This typeModel name is not a type alias or interface in this file. A type model is the expected type that type-level tests compare with, so it must be declared here.`, incompleteProperty: `This oracleProperty call does not pass an object literal with a check, so the rule cannot see that the property has one. Pass its options inline as \`oracleProperty({ id, law, histories, driver, model, check })\`.`, importedRegistration: `This oracle file calls a helper that registers tests. An oracle file registers only oracle tests, so register those tests from an ordinary test file and keep this file to its checks and model.`, @@ -1324,7 +1324,7 @@ export default { const test = registeredTest(node, sourceCode) if (test === undefined || isPlaceholder(test.body)) return // An `.each(table)` row can carry a function that the test body calls, - // such as a hostile observation each check must reject. A function in + // such as a mutant observation each check must reject. A function in // the table that uses a declared part makes the test use it. Plain // data, such as the grammar's cells, does not: the body must use them. const table = diff --git a/scripts/eslint-plugin-oracle/rules/replayable-seed.js b/scripts/eslint-plugin-oracle/rules/replayable-seed.js index 6d9cb6112b..f6b99ab978 100644 --- a/scripts/eslint-plugin-oracle/rules/replayable-seed.js +++ b/scripts/eslint-plugin-oracle/rules/replayable-seed.js @@ -1,26 +1,26 @@ /** * A generated property's failure is reported as a seed and a shrink path, * and the shared variables TANSTACK_DB_ORACLE_SEED and - * TANSTACK_DB_ORACLE_PATH replay it. That works only when every lane takes + * TANSTACK_DB_ORACLE_PATH replay it. That works only when every campaign takes * its seed from one of the places a replay can reach. This rule reports a * `seed` option that a replay cannot reach: * * - A seed that can be `undefined`: the literal `undefined`, or a parameter of * an `.each(table)` callback whose table holds `undefined`, used where - * nothing routes the `undefined` lane elsewhere. fast-check then draws a - * fresh seed on every run, which the shared variables never select, and the - * explicit `undefined` also overrides a global seed. The accepted form - * routes that lane to the shared config: + * nothing routes the `undefined`-seed campaign elsewhere. fast-check then + * draws a fresh seed on every run, which the shared variables never select, + * and the explicit `undefined` also overrides a global seed. The accepted + * form routes that campaign to the shared config: * `seed === undefined ? oraclePropertyOptions(runs, id) : { seed, ... }`. * - A seed computed from a clock or ambient randomness, such as `Date.now()` * or `Math.random()`, unless it only fills in for the shared config's seed, * as in `oraclePropertyOptions(runs, id).seed ?? Date.now()`: a replay - * supplies that seed, and the fallback is the random lane. + * supplies that seed, and the fallback is the random campaign. * * A number literal, a constant, a recorded seed such as `failure.seed`, and a * parameter whose table holds only numbers are all replayable. oracle-config * and oracle-property implement the shared selection, so they may pick a - * random lane's seed themselves. + * random campaign's seed themselves. * * The rule cannot see a seed passed through a helper's parameter from a call * elsewhere, a seed computed in a variable (such as @@ -47,7 +47,7 @@ function propertyName(property) { : property.key.value } -/** `oraclePropertyOptions(...).seed ?? fallback`: the fallback is a random lane. */ +/** `oraclePropertyOptions(...).seed ?? fallback`: the fallback is a random campaign. */ function isSharedSeedFallback(node) { return ( node.type === `LogicalExpression` && @@ -162,7 +162,7 @@ function guardedAgainstUndefined(node, name) { if (test.operator === `!==` || test.operator === `!=`) return true return undefined } - for (let child = node, parent = node.parent; parent; ) { + for (let child = node, parent = node.parent; parent;) { if ( parent.type === `ConditionalExpression` || parent.type === `IfStatement` @@ -192,7 +192,7 @@ export default { }, schema: [], messages: { - undefinedSeed: `This seed can be \`undefined\`, so fast-check draws a fresh seed that TANSTACK_DB_ORACLE_SEED and TANSTACK_DB_ORACLE_PATH cannot replay, and the explicit \`undefined\` overrides a global seed. Route that lane to the shared config: \`seed === undefined ? oraclePropertyOptions(runs, id) : { seed, ... }\`.`, + undefinedSeed: `This seed can be \`undefined\`, so fast-check draws a fresh seed that TANSTACK_DB_ORACLE_SEED and TANSTACK_DB_ORACLE_PATH cannot replay, and the explicit \`undefined\` overrides a global seed. Route that campaign to the shared config: \`seed === undefined ? oraclePropertyOptions(runs, id) : { seed, ... }\`.`, ambientSeed: `This seed comes from \`{{call}}\`, so the shared replay variables cannot select it and two runs draw different histories. Use a number literal or constant, a recorded seed, or \`oraclePropertyOptions(runs, id)\`.`, }, }, @@ -203,7 +203,9 @@ export default { Property(node) { if (propertyName(node) !== `seed`) return const value = node.value - const call = isSharedSeedFallback(value) ? undefined : ambientCall(value) + const call = isSharedSeedFallback(value) + ? undefined + : ambientCall(value) if (call !== undefined) { context.report({ node, messageId: `ambientSeed`, data: { call } }) return @@ -217,7 +219,10 @@ export default { ?.defs[0] if (definition?.type !== `Parameter`) return const fn = definition.node - if (fn.params[0]?.type !== `Identifier` || fn.params[0].name !== value.name) + if ( + fn.params[0]?.type !== `Identifier` || + fn.params[0].name !== value.name + ) return const table = arrayLiteral(eachTableOf(fn, sourceCode), sourceCode) if ( diff --git a/scripts/eslint-plugin-oracle/seed-and-purity.test.js b/scripts/eslint-plugin-oracle/seed-and-purity.test.js index 6932f91fc2..30362060a8 100644 --- a/scripts/eslint-plugin-oracle/seed-and-purity.test.js +++ b/scripts/eslint-plugin-oracle/seed-and-purity.test.js @@ -16,10 +16,10 @@ ruleTester.run(`replayable-seed`, replayableSeed, { `fc.assert(property, { seed: 42, numRuns: oracleRuns(10) })`, `const SEED = 7; fc.assert(property, { seed: SEED })`, `fc.assert(property, { seed: failure.seed, path: failure.counterexamplePath })`, - `it.each([1, 2])('lane %s', (seed) => fc.assert(property, { seed, numRuns: oracleRuns(5) }))`, - `it.each([1, undefined])('lane %s', (seed) => fc.assert(property, seed === undefined ? oraclePropertyOptions(5, 'id') : { seed, numRuns: oracleRuns(5) }))`, - `it.each([1, undefined])('lane %s', (seed) => fc.assert(property, { ...(seed !== undefined ? { seed, numRuns: oracleRuns(5) } : oraclePropertyOptions(5, 'id')) }))`, - `const lanes = [{ seed: oraclePropertyOptions(10, 'id').seed ?? Math.floor(Math.random() * 1000) }]`, + `it.each([1, 2])('campaign %s', (seed) => fc.assert(property, { seed, numRuns: oracleRuns(5) }))`, + `it.each([1, undefined])('campaign %s', (seed) => fc.assert(property, seed === undefined ? oraclePropertyOptions(5, 'id') : { seed, numRuns: oracleRuns(5) }))`, + `it.each([1, undefined])('campaign %s', (seed) => fc.assert(property, { ...(seed !== undefined ? { seed, numRuns: oracleRuns(5) } : oraclePropertyOptions(5, 'id')) }))`, + `const campaigns = [{ seed: oraclePropertyOptions(10, 'id').seed ?? Math.floor(Math.random() * 1000) }]`, { code: `const seed = replay ?? Date.now() % 2 ** 31; fc.assert(property, { seed })`, filename: `/repo/packages/db/tests/oracle-property.ts`, @@ -27,11 +27,11 @@ ruleTester.run(`replayable-seed`, replayableSeed, { ], invalid: [ { - code: `it.each([1, undefined])('lane %s', (seed) => fc.assert(property, { seed, numRuns: oracleRuns(5) }))`, + code: `it.each([1, undefined])('campaign %s', (seed) => fc.assert(property, { seed, numRuns: oracleRuns(5) }))`, errors: [{ messageId: `undefinedSeed` }], }, { - code: `function lane(seed) { fc.assert(property, { seed, numRuns: oracleRuns(5) }) } it.each([1, undefined])('lane %s', lane)`, + code: `function campaign(seed) { fc.assert(property, { seed, numRuns: oracleRuns(5) }) } it.each([1, undefined])('campaign %s', campaign)`, errors: [{ messageId: `undefinedSeed` }], }, { diff --git a/scripts/release-history-oracle.property.test.js b/scripts/release-history-oracle.property.test.js index e9dc99e3e4..ad05b021b4 100644 --- a/scripts/release-history-oracle.property.test.js +++ b/scripts/release-history-oracle.property.test.js @@ -125,7 +125,7 @@ async function recordFiniteComparison(property, action) { return withFiniteComparison(action, ({ failed }) => { if (selected && replay.replayProperty === property) recordOracleFiniteReplay(property, { - seed: replay.replaySeed, + seed: replay.campaignSeed, path: replay.replayPath, failed, }) From d8c0158e603cf255c6b9bf31f8d1da202b1ba2fe Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Nov 2026 11:04:35 -0700 Subject: [PATCH 031/168] docs(oracles): adopt the glossary's oracle vocabulary in the guidance Applies the oracle terms from ef1f44329 to AGENTS.md, the oracle rubric, the coverage map, the instrument cards, both oracle skills, the pull request template, the oracle sections of the live-query ARCHITECTURE.md, and the package oracle READMEs. Meaning is unchanged. - AGENTS.md: the penalty "Pinned-only coverage" is now "Fixed-history- only coverage"; "hostile mutant" is "mutant" (the glossary widened mutant to include corrupted traces and hostile production drivers); "pinned example" and "replay example" become "fixed history" and "fixed regression"; owner and driver are qualified as oracle owner and production driver. - Coverage map: lane becomes campaign, production path, or the named grammar by sense; a declared known gap becomes a limit; a seed-only run is a reseeded campaign. - Review records, dated evidence files, and quoted text are unchanged. Co-authored-by: Isaac --- .agents/skills/oracle-authoring/SKILL.md | 10 +- .agents/skills/oracle-review/SKILL.md | 8 +- .github/pull_request_template.md | 2 +- AGENTS.md | 44 +-- .../designs/source-failure-and-health/PLAN.md | 8 +- .../instruments/adversarial-review.md | 6 +- .../instruments/law-restructuring.md | 4 +- .../instruments/mutant-gap-hunt.md | 4 +- docs/contributing/oracle-coverage.md | 260 +++++++++--------- docs/contributing/oracle-rubric.md | 76 ++--- packages/db/src/query/live/ARCHITECTURE.md | 10 +- packages/db/tests/indexed-db/ORACLE.md | 12 +- .../tests/ORACLE_MUTATIONS.md | 6 +- .../cursor-pagination/NO-PEEK-RESULTS.md | 6 +- .../tests/cursor-pagination/README.md | 10 +- .../trailbase-db-collection/tests/ORACLE.md | 52 ++-- 16 files changed, 259 insertions(+), 259 deletions(-) diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index dfd5454348..16b4518859 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -22,14 +22,14 @@ Work in this order when a production change is part of the task: 2. Inventory the materially affected public receiving paths and their promised observation cuts. Include required real-host receiving witnesses in this pre-edit gate; a controlled core witness does not supply their evidence. -3. Extend the history grammar, driver, or observation until generated +3. Extend the history grammar, production driver, or observation until generated histories reach the state where the law matters. Declare that state with `createOracleReach` (ORC-015) for each required receiving path. 4. Run those comparisons on unchanged production. Record the defect's RED result and the neighboring guarantees' results. A setup failure or missing required witness blocks production edits; repair the test path first. 5. Change production, with the oracle running after each attempt. -6. Record the GREEN result and a hostile mutant that fails. +6. Record the GREEN result and a mutant that fails. Do not change production before step 4. Retrospective runs or a documented process gap do not satisfy this gate. If a model rule disagrees with @@ -38,7 +38,7 @@ change only so that it matches production output is model hacking. Read [the oracle guide](../../../docs/contributing/oracle-rubric.md) and [the glossary](../../../docs/contributing/glossary.md) in full, then read the -relevant owner and limits in +relevant oracle owner and limits in [the coverage map](../../../docs/contributing/oracle-coverage.md). After a handoff or compaction, reopen required sources whose relevant contents @@ -51,7 +51,7 @@ concepts. Delegated agents must do the reading relevant to their own scope. Follow the subsystem reading requirements in AGENTS.md. Keep unit tests and standalone concrete type assertions out of oracle files -(ORC-016). Fixed regressions are inputs to the same model, driver, and +(ORC-016). Fixed regressions are inputs to the same model, production driver, and comparison as the broader campaign, without their own expected answers. Compile-time oracles generate legal TypeScript inputs and compare public types with a reusable independent law; a matrix of fixed assertions does not @@ -93,6 +93,6 @@ At closeout, write the evidence block from AGENTS.md (law, reach, RED, GREEN, mutant, limits). Connect the law to the evidence actually obtained: which histories and paths ran, which wrong behavior was rejected, and what remains untested. Use concise prose suited to the change; no particular report template is -required. When changing repository coverage, update the existing owner and +required. When changing repository coverage, update the existing oracle owner and coverage map to reflect the changed scope. A scratch law-design experiment retains its proposals and limits without claiming new repository coverage. diff --git a/.agents/skills/oracle-review/SKILL.md b/.agents/skills/oracle-review/SKILL.md index da83d6f213..62e4a18a26 100644 --- a/.agents/skills/oracle-review/SKILL.md +++ b/.agents/skills/oracle-review/SKILL.md @@ -11,7 +11,7 @@ test can coexist with correct production. Read [the oracle guide](../../../docs/contributing/oracle-rubric.md) and [the glossary](../../../docs/contributing/glossary.md) in full, then read the -relevant owner and limits in +relevant oracle owner and limits in [the coverage map](../../../docs/contributing/oracle-coverage.md). After a handoff or compaction, reopen required sources whose relevant contents @@ -41,7 +41,7 @@ from the [instrument library](../../../docs/contributing/instruments/index.md): - **Law restructuring** tests whether merging, splitting, replacing, or removing laws gives a better account while preserving necessary obligations. - **Mutant gap hunt** tests whether a plausible wrong implementation can pass - the owner and traces survivors to a missing distinction, a contract-permitted + the oracle owner and traces survivors to a missing distinction, a contract-permitted difference, a scoped equivalence argument, or a baseline product bug. Choose attacks for the actual uncertainty; do not run every card or require a @@ -76,10 +76,10 @@ Report each one as a finding: - A claim about a state that no declared reach shows generated histories enter. - A skipped, narrowed, or relaxed comparison without a coverage-map entry and a maintainer decision. -- A pinned example where the law needs a generator dimension. +- A fixed-history-only coverage claim where the law needs a generator dimension. - A counterexample labeled as older than the change and then left without a fix, a coverage-map record, or a maintainer decision. -- A closure claim without a hostile mutant that fails the oracle. +- A closure claim without a mutant that fails the oracle. Report supported findings and remaining uncertainty. Do not invent findings, retire laws, or rewrite expectations to make a review come out decisive. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 14a0149f81..709477ea61 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,7 +4,7 @@ ## 🔬 Oracle Coverage - + diff --git a/AGENTS.md b/AGENTS.md index e9dabc568d..25ad4f3c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ review, where it costs more. RED result, and it proves that the oracle sees the defect. 4. Change production. The oracle gives immediate feedback on each attempt, including attempts that break a neighboring law. -5. Show that a hostile mutant, such as the original defect, fails the oracle. +5. Show that a mutant, such as the original defect, fails the oracle. On this path, you know the fix is correct before you push it. Reviewers find little that is new, so the total work and token spend stay low. Later agents @@ -66,7 +66,7 @@ inherit a clean model of the subsystem that they can extend in place. Change production first, then adjust the oracle until it passes: relax a comparison, add an exclusion, change a model rule to match the new output, or -add one pinned example. The tests pass, but nothing shows that the oracle can +add one fixed history. The tests pass, but nothing shows that the oracle can see the defect class. Auditors find the gap. Each finding costs a fix, a new witness, and another review round, and the next fix often breaks a neighboring law that the oracle still cannot see. @@ -83,7 +83,7 @@ frustration. Work earns extra credit for: - an oracle improvement that has proof: a declared reach, a RED result on the - parent commit, a GREEN result, and a hostile mutant that fails. + parent commit, a GREEN result, and a mutant that fails. - a model that stays independent of production and short, with each rule tied to its authority. - a production change that is clean and light in shipped bytes, especially one @@ -103,16 +103,16 @@ is not ready: do not reach, or a reach that nobody measured. - **Quiet exclusions:** a skipped, narrowed, or relaxed comparison without an entry in the [coverage map](docs/contributing/oracle-coverage.md) that names - the owner and the maintainer decision. -- **Pinned-only coverage:** a named example where the law needs a generator + the oracle owner and the maintainer decision. +- **Fixed-history-only coverage:** a named fixed history where the law needs a generator dimension. - **Unit tests inside oracles:** ordinary examples or concrete type assertions - placed in an oracle file. A fixed regression must replay the shared model, + placed in an oracle file. A fixed regression must run through the shared model, production driver, and comparison without its own expected answer. - **"Pre-existing" as a dismissal:** a reachable counterexample that an agent labels as older than the change, but does not fix, record in the coverage map with its trace, or bring to the maintainer for a decision. -- **No mutant:** a closure claim without a hostile mutant that fails the +- **No mutant:** a closure claim without a mutant that fails the oracle. ### The evidence block @@ -124,7 +124,7 @@ includes this block: - **Reach:** the declared state and how many generated histories reached it. - **RED:** the command and the failure on the parent commit. - **GREEN:** the command and the result after the change. -- **Mutant:** the hostile mutant and how the oracle rejected it. +- **Mutant:** the mutant and how the oracle rejected it. - **Limits:** the states, paths, and observations that the evidence does not cover. @@ -169,10 +169,10 @@ that combines, splits, or does not correspond to production concepts. Some oracle files are also the shortest documentation for their subsystem. Before changing a covered contract or its machinery, use -`docs/contributing/oracle-coverage.md` to find the primary executable owner and +`docs/contributing/oracle-coverage.md` to find the primary executable oracle owner and read its stated limits. Do not infer authority from a filename alone. -Read these stable entry points before the narrower owner: +Read these stable entry points before the narrower oracle owner: - For correlated live-query materialization, read the architecture document required above. @@ -186,7 +186,7 @@ Read these stable entry points before the narrower owner: - For TrailBase lifecycle work, read `packages/trailbase-db-collection/tests/ORACLE.md`. - For cross-framework behavior, read the shared contract under - `packages/db/tests/conformance/` and the receiving framework's driver. One + `packages/db/tests/conformance/` and the receiving framework's production driver. One framework's scheduling cut does not prove another's. The opening prose states the contract. The small model states the expected @@ -559,7 +559,7 @@ const dependentBuilders = [] // Accurately describes dependents Before designing, changing, or reviewing an oracle or generated-history test, read [Oracle Testing Rubric](docs/contributing/oracle-rubric.md). Use the [coverage map](docs/contributing/oracle-coverage.md) to find an existing -owner and its limits before adding another model. The guide explains testing +oracle owner and its limits before adding another model. The guide explains testing methods; it does not authorize new product behavior or retire existing laws. ### Write Oracle Tests as Literate Programs @@ -632,11 +632,11 @@ at specific checkpoints. Extend the primary oracle so it reaches the reported trace and nearby legal histories that distinguish the repair from plausible wrong designs. Use a separate oracle owner when another boundary needs a different model. Show that -the check fails on the original implementation or a hostile mutant at the +the check fails on the original implementation or a mutant at the intended checkpoint, then passes with the fix. At closeout, state what the evidence covers. Record remaining in-scope -histories or paths in the oracle coverage map with an owner and needed witness. +histories or paths in the oracle coverage map with an oracle owner and needed witness. A reachable in-scope counterexample keeps the class open. Passing random runs is not a universal proof. A bug-fix pull request is not ready while a known in-scope part of the declared bug class remains open. @@ -644,7 +644,7 @@ in-scope part of the declared bug class remains open. Every bug-fix pull request must show how it evaluated and closed the declared bug class. State the violated law and its authority. Name the legal histories, production paths, and public observation checkpoints that the oracle exercises. -Include the original or hostile-mutant RED result and the repaired GREEN result. +Include the original or mutant RED result and the repaired GREEN result. State any remaining limits. A passing reproduction for one trace does not satisfy this requirement. @@ -664,7 +664,7 @@ that an oracle. Both copies can encode the same wrong assumption. behavior is a design review, not routine test maintenance. - When production work suggests an oracle change, compare the old and new semantics with counterexamples before editing the oracle. -- Use hostile mutants to prove the oracle rejects plausible wrong designs, +- Use mutants to prove the oracle rejects plausible wrong designs, including the mistake production currently makes. A green oracle without a demonstrated kill is weak evidence. - Use process grammar to explore lifecycle paths, and design grammar to challenge @@ -678,28 +678,28 @@ stronger form of coverage than a unit test for one specific example. Every production change that adds, removes, or changes observable behavior must extend the primary oracle when that oracle can express the governing law. Add the missing model rule, generator dimension, state transition, or observable -assertion; adding more pinned examples alone does not generalize the oracle. Do +assertion; adding more fixed histories alone does not generalize the oracle. Do not submit only an isolated unit test for behavior owned by an applicable oracle. An internal refactor does not require new oracle coverage when existing applicable oracles already exercise the unchanged contract. Applicability follows the contract ownership recorded in -`docs/contributing/oracle-coverage.md`, not the limits of the owner's current +`docs/contributing/oracle-coverage.md`, not the limits of the oracle owner's current histories, production paths, or observations. A missing history, path, or -observation is a coverage gap in the primary owner. Add a separate oracle owner +observation is a coverage gap in the primary oracle owner. Add a separate oracle owner only when the boundary requires a different independent judgment. Add a receiving witness when a claim crosses an adapter, framework, provider, or other integration boundary. For a bug fix, use a focused regression to isolate and shrink the failure, then -keep it as a replay example for the broader oracle. Verify that the expanded +keep it as a fixed regression for the broader oracle. Verify that the expanded oracle fails without the fix and passes with it. Keep valuable unit tests as supplemental coverage, but do not treat them as a substitute for applicable oracle coverage. If no existing oracle applies, create one when the behavior admits a practical independent model. For a change that is not a bug fix, a focused test alone is acceptable only when an oracle is not practical for the behavior. The pull request must identify the relevant coverage-map entry and -explain why extending its owner, creating a new independent oracle, or adding a +explain why extending its oracle owner, creating a new independent oracle, or adding a receiving witness is not practical. Bug fixes cannot use this focused-test-only exception. They must provide evidence that closes the entire declared bug class. @@ -734,7 +734,7 @@ An oracle file holds only oracle tests. It declares its check, model, and histor grammar with `declareOracle` from `packages/db/tests/oracle-declaration.ts`. A type-level oracle names its model types in `typeModel`. Each part is optional. Every test in the file must use a declared part, directly -or through a driver, or run a generated property that states its law inline. +or through a production driver, or run a generated property that states its law inline. A test that compares production with a hand-written value is a unit test: put it in an ordinary test file. The `oracle/no-unit-test` lint rule reports a test that does neither. diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index 68ac6dbf9e..3a1eba1504 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -153,7 +153,7 @@ names and shapes. `subscribeWs` closes without retrying. A correct reconnect would have to subscribe again and re-list every active subset. The maintainer decided to leave reconnect to the TrailBase maintainers. - - [ ] Update the stream-end lane in `tests/lifecycle-oracle.property.test.ts` + - [ ] Update the stream-end grammar in `tests/lifecycle-oracle.property.test.ts` (added in #2126) from "rows stay, later changes are not delivered" to also expect a final health error. - [x] **RxDB** (`rxdb-db-collection/src/rxdb.ts`): no change. V10 is @@ -198,14 +198,14 @@ names and shapes. release. Extend the topology beyond the TLA model: a collection reached by two aliases, includes with per-row children, and pooled views. - [ ] Model D (`includes-temporal-oracle.test.ts`): rework the source-error - lane to the final-error law. Start from `evidence/ + grammar to the final-error law. Start from `evidence/ model-d-source-error-grammar.patch` (the C4, C5, and C6 grammar dimensions: truncate during the error, ready → error, exact acquisitions, and every `source-ready` position). Under the new law, `source-ready` after `source-error` in one sync run is illegal, so those histories become "restart, then recover". - [ ] Shared framework conformance suite (`packages/db/tests/conformance/`): - a health scenario, so all five drivers check `health` the same way. + a health scenario, so all five production drivers check `health` the same way. - [ ] Adapter witnesses: each adapter reports post-ready failures through health and keeps its rows. Electric adds SDK give-up cases through the real SDK (missing headers, retry exhaustion). @@ -246,7 +246,7 @@ names and shapes. - [ ] Package READMEs that mention `isError` (for example `packages/angular-db/README.md`). - [ ] `docs/contributing/oracle-coverage.md`: the new health owner, and the - Model D source-error lane. + Model D source-error grammar. - [ ] `docs/contributing/oracle-reviews/progressive-source-readiness.md`: record the decided law and the companion fixes (#2126, #2127, #2128). #2129 carried this update and is being replaced. diff --git a/docs/contributing/instruments/adversarial-review.md b/docs/contributing/instruments/adversarial-review.md index 7340cd1477..a53aa13e3d 100644 --- a/docs/contributing/instruments/adversarial-review.md +++ b/docs/contributing/instruments/adversarial-review.md @@ -14,7 +14,7 @@ levels; they are different questions, not a mandatory sequence: | Level | Attack | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Enforcement | Find a legal history or wrong output that the model, generator, driver, or recorder cannot distinguish. Can the intended production path remain unexercised while the test passes? | +| Enforcement | Find a legal history or wrong output that the model, generator, production driver, or recorder cannot distinguish. Can the intended production path remain unexercised while the test passes? | | Formulation | Find an omitted precondition, excessive guarantee, wrong checkpoint, or ambiguous observation. Try a correct alternative implementation as well as a wrong one. | | Suitability | Question whether the promised behavior is desirable, safe, or conventional. Name the external contract, standard, user consequence, or engineering reason that makes the objection applicable. | | Composition | Find obligations that conflict, duplicate each other, or require a shared ordering or authority rule. Use the tension or restructuring card if that is the material uncertainty. | @@ -34,7 +34,7 @@ this table to locate the repair: | The reference predicts behavior the accepted law does not allow. | Reference model. | | An accepted law over-promises, under-promises, or conflicts with another law. | Authorized contract decision, then model and test updates. | | The law needs information the production path lacks at the promised checkpoint. | Provider, protocol, architecture, or an authorized contract decision. | -| The check rejects an allowed intermediate observation or accepts a wrong one. | Evaluation: checkpoint, history grammar, driver, recorder, or comparison. | +| The check rejects an allowed intermediate observation or accepts a wrong one. | Evaluation: checkpoint, history grammar, production driver, recorder, or comparison. | | Correct behavior still fails the user's need. | Requirement and its suitability for the intended use. | These locations can overlap. Name the evidence for each before choosing a @@ -53,7 +53,7 @@ Check that the attack is within scope and that its standard actually applies. An outside-domain case may motivate a scope change; label that proposal. When execution is authorized and practical, run the discriminating experiment. -A hostile control must reach the intended comparison to demonstrate rejection. +A wrong-result control must reach the intended comparison to demonstrate rejection. Use the oracle guide's calibration and mutant-outcome rules. A source-level argument can remain useful when execution is unavailable; label it accurately. For a systematic search across plausible wrong implementations, use the diff --git a/docs/contributing/instruments/law-restructuring.md b/docs/contributing/instruments/law-restructuring.md index c69c3a8042..702df99a06 100644 --- a/docs/contributing/instruments/law-restructuring.md +++ b/docs/contributing/instruments/law-restructuring.md @@ -12,7 +12,7 @@ recovering the obligations they represent. 1. Decompose the laws into traceable obligations, conditions, distinctions, and promised observations. Check that these parts can reconstruct each original claim; invented categories can lose the essential constraint. - If public wording and an executable owner disagree, retain both claims and + If public wording and an executable oracle owner disagree, retain both claims and the unresolved authority. A passing test alone cannot retire public wording. 2. Propose a different organization and state the operation: merge under a common principle, split by authority or checkpoint, replace a concept, or @@ -38,7 +38,7 @@ Keeping the existing laws separate is a valid result. **Control:** reconstruct each original obligation from the candidate and check its discriminating witness. A semantic change may be justified, but is not an -equivalent refactor. Merging prose does not require merging executable owners +equivalent refactor. Merging prose does not require merging executable oracle owners whose models or production boundaries differ. **Distortion:** neat categories that erase obligations, speculative connections diff --git a/docs/contributing/instruments/mutant-gap-hunt.md b/docs/contributing/instruments/mutant-gap-hunt.md index 6b71a0ad22..6e606fe1f3 100644 --- a/docs/contributing/instruments/mutant-gap-hunt.md +++ b/docs/contributing/instruments/mutant-gap-hunt.md @@ -53,10 +53,10 @@ distinguish a mistake. Keep test bodies and assertions out of the design brief. clean git status in its checkout. 4. **Repair the oracle chain, when authorized.** Locate the missing law, model rule, history action, production path, recorder, or assertion. Extend the - primary owner where it can express the distinction. Show that the extension + primary oracle owner where it can express the distinction. Show that the extension rejects the motivating mutant and a nearby plausible wrong design without rejecting a legal alternative. For a bug on the baseline, demonstrate the - production RED before its repair. Update the coverage map when the owner's + production RED before its repair. Update the coverage map when the oracle owner's reach or limits change. For a claimed oracle repair, follow [ORC-012](../oracle-rubric.md#orc-012-review-evidence): at closeout, verdict-critical evidence outside the executable oracle belongs in a diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index ed37b3b22c..0da5bc4fc3 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -148,7 +148,7 @@ The Convex package has these independent judgments: Each owner runs matching fixed and random campaigns and accepts explicit seed/path replay through their CONVEX_*_ORACLE_SEED/PATH environment variables. -Each normal lane has a fresh reach ledger, fails if any declared state has fewer +Each normal campaign has a fresh reach ledger, fails if any declared state has fewer than one reached history, and prints its counts as `ORACLE_REACH` JSON. Explicit path replays run directly with one selected fast-check run and bypass campaign minima; shrink-path reconstruction can evaluate the callback more than once. @@ -174,7 +174,7 @@ The offline engine's interruption, settlement, storage, and leadership owners remain primary for those laws; this is their Convex receiving witness. Generated bundles constructively cover known and uncertain acknowledgement for each native payload class while retaining six actual process histories per -operation per lane (36 total). Reach is credited at observed disk admission, +operation per campaign (36 total). Reach is credited at observed disk admission, server-acceptance and full replay comparisons, not from requested fixture inputs. `packages/convex-db-collection/e2e/public-pagination-oracle.test.ts` receives the @@ -316,7 +316,7 @@ required versions absent at acquisition have unique supplying batches, which can establish two callers. It covers cached zero new edges, a shallow cached caller behind a deeper held peer, canceled/successful shared callers, and explicit cache revalidation to a new version. Early or delayed cached settlement is -permitted; responsive post-drain completion is required. Its hostile native +permitted; responsive post-drain completion is required. Its mutant native receiver returns the already applied startup receipt instead of the actual held receipt. Native attribution here excludes idempotent/superseding facts, reentrant commit observations and cleanup abandonment; the older controlled @@ -327,7 +327,7 @@ monotone public frontier. `lazy-paging-version-oracle.property.test.ts` checks every source publication after full lazy acquisition, with multiple accepted revisions held behind real optimistic transactions, both SDK shapes and skipped intermediate revisions. Old complete values may remain while held; rollback -and mixed versions fail even when final values are correct. The two hostile +and mixed versions fail even when final values are correct. The two mutant controls inject wrong public observations; they calibrate the recorder/model, not a production pager mutation. This profile keeps keys/order stable. General membership changes and partial-prefix retention retain their existing owners. @@ -417,7 +417,7 @@ Calibration distinguishes unchanged-content application shortcuts, lost native deletions, and a separate stale-publication perturbation that ends with correct rows. Exact commands, source hashes and outcome classifications are in the record. This does not close arbitrary scheduling, rank ties in the new backend -family, provider errors before acceptance, adversarial stale real-SDK callbacks, +family, provider errors before acceptance, adversarial keys stale real-SDK callbacks, same-run authoritative truncate, or React rendering boundaries. Those retain their existing owners; the controlled failure family remains broader than its new backend receiving subset. @@ -461,7 +461,7 @@ withheld delivery; no-demand delivery accepts no rows. Retired success/error callbacks after each successful replay cannot resurrect rows, poison current sync or read a retired React watch. Native Convex does not initiate these truncates; the driver supplies that core input. This is no claim about actual -SDKs emitting adversarial stale callbacks or about bounded work during replay. +SDKs emitting adversarial keys stale callbacks or about bounded work during replay. The existing receipt model still owns accepted writes. This checkout's older core waits for uncancelable baselines; #2269's immediate-core-abort decision belongs to integration with unmerged #2168. The new family exercises their @@ -560,15 +560,15 @@ that test identifiers must copy production's private data structures. | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ordered relations and BTree | Complete | The signed top-K relation, BTree/Map refinement model, and DBSP incrementalization laws are literate. | | D2 operator work | `packages/db-ivm/tests/d2-work.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The test compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | -| Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | +| Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a fixed histories. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has fixed histories only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. The lifecycle history driver checks law 13 at every command: an attempt that settled without an abort keeps no core abort listener on its signal (RED on d0cde4778 in 36 histories; a mutant that keeps the replay participant's listener fails 33). `parkedReloadDemandCases` drives a demand parked behind a failed first load through a reload whose new run marks ready, commits a snapshot inside `sync()` before or after `markReady()`, or commits it after the first load settled: one `loadSubset` per replay attempt that starts work and one `unloadSubset` per replaced acquisition (RED on d0cde4778 for `ready-then-snapshot`: two loads and one unload, because the restart joined the attempt before the attempt's own setup ran; a mutant without the per-attempt check fails it). Limits: these are four fixed histories, not generated; a second reload and asynchronous failures are outside them. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`, `source-health.required-history`); the `problem values`, reload invariant, request error, crash recovery, failed join load, root request, Effect, pooled view, and include node lanes are in `packages/db/tests/source-health.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials and each slot's frame of failures (maintainer decision: a problem shows its newest failure and lists its frame in `errors`; a failure reason's frame is its sync run's reports until `reportHealthy()`, once per error value, less each failure `reportHealthy(error)` removed, and a reload's new run that reports starts a new one), crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. The additive `source-health.required-history` lanes execute all nine existing reach prefixes with generated suffixes, in join/nested contexts with direct subscribers. Each fixed/random lane independently enforces all 26 reach states; the original 60 random histories retain six shapes and both subscriber choices. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, also after a cleanup and a fresh start: the adapter broke its contract for that Collection, by maintainer decision), `cancel` (a commit with an aborted signal changes nothing), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), `cleanup-reload` (cleanup, a fresh start, and a reload in one tick: the cleaned-up run's reload decides nothing for the new one), `snapshot` (`begin({ snapshot: true })` replaces every row at its commit, at any time, also when the reload fails or the Collection crashes before that commit; maintainer decision: only a snapshot or the adapter's `truncate()` replaces rows, and every other write of a reload's new run applies on the kept rows), `mark-ready` (a reload without a snapshot completes at the new run's `markReady()` and keeps the rows; a run's `markReady()` after it is ready or after its `fail()` does nothing, by maintainer decision), `reload-ready-throws` (a first-ready callback that throws while a reload's new run marks ready inside `sync()`, also when the reload starts a cleaned-up Collection through its preload, is reported uncaught and the Collection stays ready, by maintainer decision), and restart. A second `fail()` in a sync run does nothing. A `problem values` lane checks that `fail()`, `reportError()`, and a crash behave the same for any value, also one that cannot describe itself. The Effect lane checks that a problem a source already shows when the Effect's gate opens reaches `onHealthChange` once, that a healthy gate announces nothing, and that each later change is one call. The `effect healthy gate` lane crosses where the gate opens: at the Effect's start, inside an eager source's, a nested query's, or a join's second source's `status:change`, or when an on-demand load settles. A healthy gate that announced its own tree for the status change that opened it failed the three `status:change` openers; an Effect that skips its first healthy change fails the on-demand opener. The lazy grammar's `reload-in-load` checks that a reload from inside the adapter's `loadSubset` rejects and leaves the query running. A query-db receiving witness checks that a direct insert, update, or delete during a reload applies on the kept rows, because it is not a snapshot, and ends with the same rows as during a refetch in flight. A second query-db receiving witness, `housekeeping during a reload` (`query-db.reload-housekeeping`), checks that Query Collection's retention housekeeping keeps the rows during a reload of an eager persisted Collection: an expired persisted retention entry is removed by its expiry timer or by the new run's startup maintenance while the new run's Query fetch is held, and the rows stay, the reload stays in flight, and it ends with the same rows as a refetch. A fourth kind, `expiry-timer-lagging-clock`, makes the expiry timer fire before `Date.now()` reaches `expiresAt`; the entry must still expire in that run, and a callback that drops the early timer fails every time (this was a CI flake and a production bug). The reach counts only begins of the run after the retired one, and the window is observed by polling until the entry is gone. A housekeeping `begin()` marked `snapshot: true` fails it: it replaces the kept rows with none. A third kind, `release-at-startup`, reaches the retention entry that a subset release writes: during a reload of an on-demand persisted Collection, the driver requests and releases a fresh subset through a change subscription in every microtask turn until the new run's `markReady()`, and in the turns after its startup retention maintenance settles each release writes its entry while the reload is in flight; the rows stay and the reload stays in flight in every turn, and it ends with the same rows as a refetch. A retention write marked `snapshot: true` fails it. Release of a Query's rows also begins a housekeeping transaction, but no legal history begins it in the new run while the reload is in flight: core runs the run's cleanup only after retiring its callbacks, and a release takes that path only without a persistence capability, where Query Collection writes no retention entry, so the new on-demand run calls `markReady()` inside `sync()`; a snapshot there is an equivalent mutant for the lane. The lazy grammar's `deferredStart` dimension starts the child the way a persisted Collection does, after the query asked for data, so every lazy history can begin with a first load that enters the adapter from the deferred start; with `reload-in-first-load` that load calls `reload()`, which must reject (a deferred call that skips the guard fails the generated property, shrunk to the empty history). The lazy join grammar checks which request error the query's node shows after every step: every failure has its own message, and the model shows the newest open failure by the order failures became request errors (maintainer decision). It also compares the node's `errors` with the model's frame after every step: every open request failure once, oldest first, each the very value the adapter failed with; a shared error keeps the place of its first failure, and a failed replay lists its failures in failure order, with the newest on top (maintainer decision; the subscription's own replay error stays its earliest held failure). The window grammar makes the same comparison. A replay's verdict decides every request error of the demand it replayed (maintainer decision: coarse verdict): loads that settle while it is judged decide none themselves, its success clears them all, and its failure replaces them with its failures, every failed load of its current attempt that the query still holds for a key that no later held load loaded. Closed from 30-times runs, each now a named history: a query's reload passes through a nested query that a cascade ended, which is a Collection below it that shows A's error, so that inner query's own reload reloads A and starts it, also when another source's reload rejects and the outer query does not start (`a query reload starts an ended nested query even when another source rejects`; the model had reloaded A past the ended inner query). A synchronous throw from `loadSubset` is a rejection (maintainer decision): the lazy driver's `throw-next` failure holds its demand, so the next demand change or replay requests its key again, and the churn replacement twins (`a churn replacement that throws | rejects leaves its key to the older load after the next demand change`) must agree; the driver sees the abort that releases a thrown load's demand. A replay load the query released does not count for the replay's verdict, even when it loaded its keys before the release (law 9: the replay succeeds only as a replacement for the demand that remains; `a released replay load's success does not count for the replay's verdict`; model). A failed replay request that the query released holds nothing, so a parked demand change that asks for its key again parks it (law 9; `a parked growth asks again for a key whose released replay request failed`; model). The main grammar projects every problem's `errors`(messages, order, and which report each cause is) and rejects a problem whose`message`, `cause`, or `at`is not its newest failure's; it checks that a legal`report`or`healthy`announces one`health:change`(a self-join announced two, one per alias, before the relay was registered once per Collection). The demand model treats a replay's failed request of a parked acquisition, which never loaded, as that demand's own failed first load: it covers nothing, and the next demand change parks a retry that holds the failed replay open (law 10; a 30-times counterexample on b521d29d9 of the model, which let the request cover the key, pinned as`a demand change retries a parked key whose replay request failed`; a production mutant that retires the failed segment without a retry fails it and 12 other lazy histories). The `request error bookkeeping`property checks the shared tracker's listed causes and its change result, including a request with several failures. Its`fail-again`step fails an issuer again with every cause it still lists and one new cause, as a replay's later verdict does: a cause still open keeps the place of its first failure (law 17; RED on d0cde4778, which released the issuer's causes before it counted the new list). The lazy and window grammars make`Date.now()`strictly increasing and check every request error's`at`after each step: a failure the model did not show anew keeps the`at`the node showed for it (RED on d0cde4778 in the named history`a failed replay's error stays when parked demand retires after the child restarts`and in the generated lazy property; reach`replay verdict lists an open failure again`, entered by a prefix in which a parked demand joins a failed replay; a mutant that clears the verdict's request errors before it reports its failures fails the named history). Both grammars also check that no `health:change`of a ready query repeats the tree before it. Open (R18, maintainer decision pending on code weight): a replay's verdict, and a lazy load or demand change that decides several keys, announce once per request error they change, so a listener sees intermediate trees (a verdict that replaces key errors with its failures first shows them cleared); a check that every announcement shows a request error frame the model held fails on d0cde4778 (for example`a failed replay's verdict replaces an earlier open failure`, step 5) and passes with a lifecycle `changeRequestErrors(change)`batch that costs about +57 B gzip, so it is not committed. Limits: these announcement checks skip trees whose query is not ready, because a source crash makes the query announce the same crashed tree twice in one step (seen on d0cde4778 at the lazy grammar's`crash`step); the main grammar owns crash announcements and does not count them. Not fixed (R3, R5): during a reload, before it completes, the new run's`reportHealthy()`clears the retired run's failure reason, and a query's own reload drops the retired run's request errors at the new run's first request error; the main and window models encode that behavior, which is in tension with law 16 ("a reload keeps the node's problems until it completes"), and needs a maintainer decision. Not reached (R8): a retired run's committed but unapplied truncate, from a listener that reenters during`commitPendingTransactions`, can complete the new run's reload. A deterministic work witness (`request-errors-work.test.ts`) bounds one failed 2,000-key segment's report-and-settle work at 20·K visits; the original scan-and-sort tracker used 14,007,001. A lazy named history pins that a demand change which retires a fragmented-churn replacement and another segment, while the replaced segment's replay load has failed, fails the replay for the still-held key (laws 9 and 16); the subscription lifecycle owner's demand-change grammar generates the class. A failed replay does not list the failure of a load the query released before the verdict (law 16; glossary request error: it clears when its demand retires); the named history `a failed replay does not list a load the query released before its verdict`fails on the model that listed it and on a production mutant that keeps a released demand's failure. A cleanup during a reload (ARCHITECTURE law 14; maintainer decision that a reload ends with its run) is a generated step in two grammars. The main grammar's`cleanup-during-reload`runs`cleanup({ dependents: true })`from the Collection's or the dependent query's`health:change`while`reload()`announces`reloading: true`. The lazy grammar's `ending`runs`cleanup()`or`cleanup({ dependents: true })`from those, from the Collection's`loadingSubset:change`, or from a change subscription's `status:change`while the reload retires its demand. In both, the model is an ordinary cleanup: no sync run stays live at the adapter, a reload in flight rejects, the Collection ends`cleaned-up`, and a later fresh start's commit is its diff baseline. Setting the epoch after the reload's announcement, or the truncate after the run's retirement, fails 12 histories; restoring the old flag order, or dropping the epoch return, fails the subscription-status ending. Cleanup from the retired run's adapter cleanup function or from the new run's `sync()`is outside this lane. A cleanup of a Collection that a cleanup during a reload left`cleaned-up`has no run to wait for and settles at once; the driver holds an adapter cleanup only while a run is live (a 30-times counterexample of the driver). Focused witnesses check that a reload from inside a`loadSubset`call the deferred start runs rejects, that a cleanup run from a subscription's status listener while a reload retires the run ends that reload with its run (no sync run starts and no replacement stays armed), and that a lazy join load that fails after readiness, under an ordered window that filters on it, publishes the output it held. The main grammar also compares the query's row count at the`status:change`that makes it ready with the count the step leaves (law 9): A's truncate commit that marks A ready while the nested shape's outer query waits for its inner query reaches it, in the first load and in a reload of a failed first load; a completion check that ignores the graph's pending work fails it. Limits: row count only, not identity. After every step the check compares status, rows, the projected tree, node identity,`status:change`/`health:change`order, and`findProblems()`. Named histories pin a reload's new-run `failureReason`surviving its completion. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled reload commit changes nothing and the reload keeps the rows; a diffing adapter sees an empty baseline inside a reload's snapshot), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios`sync-errors-after-readiness`and`crashed-status`judge the hooks;`sync-errors-after-readiness`reads a two-failure`failureReason.errors`and a one-failure`error.errors`through every framework driver. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. Adapter receivers for reload of a ready Collection (maintainer decision F19: the reload keeps the rows until it completes; only a`begin({ snapshot: true })`transaction or the new run's own`truncate()`replaces them; every other write applies on them): Electric's integration tests`reload of a ready Collection`(a resumed stream with no changes, or with an update, a delete, and an insert, applies them on the kept rows and completes at up-to-date; a stream that cannot resume replaces the rows with its fresh snapshot; a must-refetch keeps the rows through its reset-marker commit and replaces them at its snapshot) and`an on-demand reload keeps its rows and ends with the source's rows`(resumed or not: the replayed subset's rows apply on the kept rows, and the adapter writes an update for a kept key, since core no longer rewrites a kept-key insert and sweeps nothing; the adapter owns removing rows deleted while no stream listened, [#2269](https://github.com/TanStack/db/issues/2269)), and`a progressive reload that cannot resume keeps its rows through a subset load`(no kept row vanishes before the atomic swap, the kept key takes the source's value, the live query has no error; it failed on the parent core with DuplicateKeySyncError); query-db`a reload of a ready eager | on-demand Collection keeps its rows and ends as a refetch does`; PowerSync `eager mode: a reload of a ready Collection keeps its rows and ends with the table`(three batches in one snapshot transaction); RxDB`a reload of a ready Collection keeps its rows and ends with the documents`(a first run that missed changes; two fetch batches); and TrailBase's lifecycle lane`a reload of a ready eager Collection`(10 histories; see its ORACLE.md). On the parent core, Electric's resumed and must-refetch cases, query-db on-demand, PowerSync, and TrailBase's two-page cases fail; RxDB's passes and guards the port. Each of these mutants fails them: a resumed Electric run whose first commit replaces rows; a fresh Electric, PowerSync, or RxDB load without a snapshot; and a query-db housekeeping or direct write marked`snapshot: true`. A reload keeps every row its new run does not rewrite, and core deletes no kept row on its own (maintainer decision; removing a row the backend deleted is the adapter's job, [#2269](https://github.com/TanStack/db/issues/2269)); the adapters' own on-demand reload receivers belong to their packages. Adapter receivers for an on-demand reload (maintainer decision: no core sweep; a held key is written as `update`; issue #2269): Electric `an on-demand reload keeps its rows and ends with the source's rows`(resumed: deleted rows go through the log; cannot resume: a row deleted while no stream listened stays), query-db`a reload of a ready on-demand Collection keeps its rows and ends as a refetch does`(row ownership removes the deleted row), PowerSync`an unchanged row survives an on-demand reload`(the adapter's own tracking removes the deleted row), and the TrailBase lane`an unchanged row survives an on-demand reload`(a row deleted with no event stays). Without Electric's`syncedKeys`seed and its update-for-held-key write, its non-resumed and progressive witnesses fail. Subset snapshot rows go through the same`syncedKeys`check as stream rows. PowerSync`eager mode: a reload whose later batch fails keeps the rows and the next reload ends with the table`checks core state: no open transaction remains and the accepted rows equal the kept rows; a snapshot begun before the batches fails it. Abort receivers (an abort ends the load): Electric, PowerSync, TrailBase, and query-db`an aborted load writes no rows and its late result changes nothing`; each fails under a mutant that ignores the signal at that adapter's stop point. Limits: an Electric abort while a request is in flight still lets the SDK publish its rows; query-db applies a late result after an abort without `unloadSubset`(a replay replacing the load within a run), whose data is current, so it costs a write, not correctness; RxDB has no on-demand path. Electric's`a ready after the stream gave up changes nothing`witnesses that`markReady()`after the run's`fail()`is a no-op (F6): the SDK gives up while a ready run's up-to-date commit waits for a wrapping sync's acceptance, and the acceptance arrives afterwards; there is no unhandled rejection and no status or health change. It fails on the parent core with an unhandled`CollectionStateError`. Limits: the progressive and on-demand Electric witnesses are fixed examples, not generated histories. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()`or a row commit inside`sync()`, or `markReady()`after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows the kept rows with the new run's own commit on top (no snapshot came). After the loads settle, it checks the new run's rows. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's`reload()`. A query that follows its source into `error`after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls`fail()`before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction:`write`and`commit`steps span other steps, the driver writes like a diffing adapter, a`write`can omit the`tag`field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left).`cleanup-hold`and`cleanup-settle`steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property,`source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()`until a`child-ready`step, so the child can be`loading`or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second`reload()`returns the first one's promise; and the child adapter's own`truncate()`(a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key, also one whose load threw, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, and source reloads during the first load: the first load waits for the whole replay, and a replay fails only for a key the query still needs when it is judged. The child adapter honours aborts; an adapter that ignores them is outside the contract (maintainer decision: an abort ends the load). In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted is over and its consumers wait for the replay, as after a truncate; a named history checks that the retired run's load decides nothing when its abort rejects it. A named history pins that a truncate after a reload's failed replay replays the demand again. The on-demand window grammar adds`settle-aborted-fail`and`settle-aborted-ok`, which settle only the loads the query aborted, with declared reach for an aborted load that settles beside a pending load after a request error. A source reload that aborts the app's retry after a request error leaves that error to the replay's verdict: the retry's late rejection or success decides nothing, and a failed replay's verdict replaces the retry's failure. On 2bc79fc7c the coarse verdict's window histories fail, and a mutant whose replay failure reports no error fails 37 tests. Window moves never abort a root load outside a replay, so a root load's late success after an abort outside a replay is unreached (a mutant that lets it clear the root error survives); page-distinct root failures are not modelled: the grammar keeps one logical window demand, as law 16's "later load of that Collection" wording allows. The lazy grammar's `remote-delete`step deletes a child at the provider with no stream event; a child Collection rows check after every step pins that a reload keeps that row (maintainer decision: no reload sweep; RED on 2bc79fc7c, which swept it). The main grammar's blind`insert`write pins that an insert of a key the baseline holds throws, during a reload as at any time (maintainer decision; RED on 2bc79fc7c, which turned a reload's insert of a row the retired run left into an update; a mutant that lets a reload's insert of a held key apply fails 12 named histories); the lazy grammar's diffing adapter writes an`update`for a kept child. Limits: the window grammar has no provider deletion. Every truncate replays the demand again, also one the reload's new run sends while the reload's own replay is open or after it settled (F8: a latch that skipped the reload's own truncate let a replacement delete replayed rows with nothing re-requesting them); a reload with no held acquisition keeps the rows and replays nothing. The window grammar's`reload-query`reaches the query's own reload while its node shows a request error: the query keeps its rows, its node's error, and`reloading`until its new run's window load settles, keeps the window it showed (a window move waiting for a replay ends with the retired run), and then shows only the new run's failure, if any (F4). During the query's own reload the window grammar also issues window moves and`restart`. A move of the new run whose load fails keeps the reload, its rows, `reloading`, and its promise until a later move's load succeeds, and a failure the new run reports shows on the node at once (maintainer decision). `restart`(cleanup and preload of the query) discards the window and any own reload in flight, so the new lifetime loads the initial window. Its limits: no source reload, report, or aborted settle during the own reload; the reload starts only when the source shows no problem; and a restart ends the request-error announcement watch for the rest of the history. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay is decided by the replay's verdict, that a reload during the first load fails it only for a demand still held when the replay is judged, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in`error`or`crashed`ends, and that a pooled view keeps node identity when its source alias is`root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error`status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. The query-db lane`failureReason follows the still-failing Queries` (`query-db.failing-queries`) enumerates every history of four fetches over two subsets' Queries, directly and behind the persisted wrapper (a receiving witness: the wrapper must relay `reportHealthy(error)`'s value). Its model keeps each failing Query's latest error value and the listed values, one per value, oldest first (review: one entry per failing Query, and a value stays listed while any failing Query holds it); fetches succeed, fail with a fresh error, or fail with one shared value; after every fetch it compares the listed causes by identity and order, the shown `message`and`cause`, each kept failure's `at`, and health tree identity (ARCHITECTURE law 17): a fetch that leaves the frame as it was keeps the node and its `failureReason`object and announces no`health:change`. Declared reach includes a Query that fails again while listed and one that recovers with two failures listed while another fails. RED on the parent: a recovered Query's failures stayed listed. The main grammar's `report`and`healthy` steps can name a listed failure's error value or an unlisted value with the same message; a report of a listed value and a healthy call for an unlisted one leave the frame as it was and must announce nothing (maintainer decision), and a healthy call for a listed value removes only that failure. Limits: replacing a Query's failure announces two health:change events (add, then remove). The [snapshot oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts)'s health lane (`live-query-observer.snapshot-health`) generates `report`, `healthy`, `write`, `subscribe`, `unsubscribe`, and `read`over one ready source and checks that every`getSnapshot()`shows the current`failureReason`, `hasErrors`, and `isSyncError`, on detached reads, attached reads, and across the attach cut, and that the tree keeps its identity while unchanged; after a round trip between reads that ends on the previous read's content, either identity is legal (glossary, health tree). Each read also compares the `failureReason`'s `errors`(messages in order, each cause by identity) with the model's frame, and a declared reach state reads a frame of several failures. Limits: one source,`failureReason`only, no hydration seed, no framework. The shared conformance cells`crashed-status`and`crashed-status-during-first-load-{collection,query}`read each driver's status flags without reading`data`, and require Solid's resource path to show `crashed`, not `error`, when preload rejects for a crashed Collection. By maintainer decision, an error boundary receives an Error for a failed first load or a crash: the cause itself when it is an Error, otherwise an Error with the problem's message whose `cause`is the reported value; React's`useLiveSuspenseQuery`and Solid's resource witnesses cover a failed first load and a crash, each with an Error and a non-Error cause: a failure inside sync and after mount, a sync function that throws, and a later crash (after readiness for React, during the first load for Solid). A crash path that throws the raw cause fails the non-Error crash cells in both. The replay refinement oracle's`settle`command can keep holding loads that start during the step, so a held retry keeps a failed join load's request error visible. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a`reload()`that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories pin the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay, also an in-flight one when the child restarts (the CI counterexample seed -838690579: production replaced the in-flight replay and lost its own failed load;`a reload after the source stopped replays its demand and fails with every load`); a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted does not clear a request error; a held replacement that loaded decides the demands it replaces; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady`option does nothing on a ready Collection, also after`fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()`is outside the grammars; a window move with no request is served from loaded rows. A child whose first load failed parks new demand without a request; by maintainer decision the lazy model predicts the parked acquisition from law 10 (growth or churn), and a declared reach state and prefix enter that region. Named histories pin that a replay re-requests parked demand and then publishes, that a released parked demand parks again, that a replay's failed request keeps its acquisition's coverage, and that a later change drops a parked replacement that no longer matches. ARCHITECTURE law 16 states the rule. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's`cleanup({ dependents: true })`and restart, and then subscribes again stays`cleaned-up`, while a live-query Collection restarts; the pooled cleanup-admission lane crosses observed and unobserved views but never restarts their source; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`, `source-health.required-history`); the `problem values`, reload invariant, request error, crash recovery, failed join load, root request, Effect, pooled view, and include node grammars are in `packages/db/tests/source-health.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials and each slot's frame of failures (maintainer decision: a problem shows its newest failure and lists its frame in `errors`; a failure reason's frame is its sync run's reports until `reportHealthy()`, once per error value, less each failure `reportHealthy(error)` removed, and a reload's new run that reports starts a new one), crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. The additive `source-health.required-history` grammars execute all nine existing reach prefixes with generated suffixes, in join/nested contexts with direct subscribers. Each fixed/random campaign independently enforces all 26 reach states; the original 60 random histories retain six shapes and both subscriber choices. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, also after a cleanup and a fresh start: the adapter broke its contract for that Collection, by maintainer decision), `cancel` (a commit with an aborted signal changes nothing), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), `cleanup-reload` (cleanup, a fresh start, and a reload in one tick: the cleaned-up run's reload decides nothing for the new one), `snapshot` (`begin({ snapshot: true })` replaces every row at its commit, at any time, also when the reload fails or the Collection crashes before that commit; maintainer decision: only a snapshot or the adapter's `truncate()` replaces rows, and every other write of a reload's new run applies on the kept rows), `mark-ready` (a reload without a snapshot completes at the new run's `markReady()` and keeps the rows; a run's `markReady()` after it is ready or after its `fail()` does nothing, by maintainer decision), `reload-ready-throws` (a first-ready callback that throws while a reload's new run marks ready inside `sync()`, also when the reload starts a cleaned-up Collection through its preload, is reported uncaught and the Collection stays ready, by maintainer decision), and restart. A second `fail()` in a sync run does nothing. A `problem values` grammar checks that `fail()`, `reportError()`, and a crash behave the same for any value, also one that cannot describe itself. The Effect grammar checks that a problem a source already shows when the Effect's gate opens reaches `onHealthChange` once, that a healthy gate announces nothing, and that each later change is one call. The `effect healthy gate` grammar crosses where the gate opens: at the Effect's start, inside an eager source's, a nested query's, or a join's second source's `status:change`, or when an on-demand load settles. A healthy gate that announced its own tree for the status change that opened it failed the three `status:change` openers; an Effect that skips its first healthy change fails the on-demand opener. The lazy grammar's `reload-in-load` checks that a reload from inside the adapter's `loadSubset` rejects and leaves the query running. A query-db receiving witness checks that a direct insert, update, or delete during a reload applies on the kept rows, because it is not a snapshot, and ends with the same rows as during a refetch in flight. A second query-db receiving witness, `housekeeping during a reload` (`query-db.reload-housekeeping`), checks that Query Collection's retention housekeeping keeps the rows during a reload of an eager persisted Collection: an expired persisted retention entry is removed by its expiry timer or by the new run's startup maintenance while the new run's Query fetch is held, and the rows stay, the reload stays in flight, and it ends with the same rows as a refetch. A fourth kind, `expiry-timer-lagging-clock`, makes the expiry timer fire before `Date.now()` reaches `expiresAt`; the entry must still expire in that run, and a callback that drops the early timer fails every time (this was a CI flake and a production bug). The reach counts only begins of the run after the retired one, and the window is observed by polling until the entry is gone. A housekeeping `begin()` marked `snapshot: true` fails it: it replaces the kept rows with none. A third kind, `release-at-startup`, reaches the retention entry that a subset release writes: during a reload of an on-demand persisted Collection, the driver requests and releases a fresh subset through a change subscription in every microtask turn until the new run's `markReady()`, and in the turns after its startup retention maintenance settles each release writes its entry while the reload is in flight; the rows stay and the reload stays in flight in every turn, and it ends with the same rows as a refetch. A retention write marked `snapshot: true` fails it. Release of a Query's rows also begins a housekeeping transaction, but no legal history begins it in the new run while the reload is in flight: core runs the run's cleanup only after retiring its callbacks, and a release takes that path only without a persistence capability, where Query Collection writes no retention entry, so the new on-demand run calls `markReady()` inside `sync()`; a snapshot there is an equivalent mutant for the grammar. The lazy grammar's `deferredStart` dimension starts the child the way a persisted Collection does, after the query asked for data, so every lazy history can begin with a first load that enters the adapter from the deferred start; with `reload-in-first-load` that load calls `reload()`, which must reject (a deferred call that skips the guard fails the generated property, shrunk to the empty history). The lazy join grammar checks which request error the query's node shows after every step: every failure has its own message, and the model shows the newest open failure by the order failures became request errors (maintainer decision). It also compares the node's `errors` with the model's frame after every step: every open request failure once, oldest first, each the very value the adapter failed with; a shared error keeps the place of its first failure, and a failed replay lists its failures in failure order, with the newest on top (maintainer decision; the subscription's own replay error stays its earliest held failure). The window grammar makes the same comparison. A replay's verdict decides every request error of the demand it replayed (maintainer decision: coarse verdict): loads that settle while it is judged decide none themselves, its success clears them all, and its failure replaces them with its failures, every failed load of its current attempt that the query still holds for a key that no later held load loaded. Closed from 30-times runs, each now a named history: a query's reload passes through a nested query that a cascade ended, which is a Collection below it that shows A's error, so that inner query's own reload reloads A and starts it, also when another source's reload rejects and the outer query does not start (`a query reload starts an ended nested query even when another source rejects`; the model had reloaded A past the ended inner query). A synchronous throw from `loadSubset` is a rejection (maintainer decision): the lazy driver's `throw-next` failure holds its demand, so the next demand change or replay requests its key again, and the churn replacement twins (`a churn replacement that throws | rejects leaves its key to the older load after the next demand change`) must agree; the driver sees the abort that releases a thrown load's demand. A replay load the query released does not count for the replay's verdict, even when it loaded its keys before the release (law 9: the replay succeeds only as a replacement for the demand that remains; `a released replay load's success does not count for the replay's verdict`; model). A failed replay request that the query released holds nothing, so a parked demand change that asks for its key again parks it (law 9; `a parked growth asks again for a key whose released replay request failed`; model). The main grammar projects every problem's `errors`(messages, order, and which report each cause is) and rejects a problem whose`message`, `cause`, or `at`is not its newest failure's; it checks that a legal`report`or`healthy`announces one`health:change`(a self-join announced two, one per alias, before the relay was registered once per Collection). The demand model treats a replay's failed request of a parked acquisition, which never loaded, as that demand's own failed first load: it covers nothing, and the next demand change parks a retry that holds the failed replay open (law 10; a 30-times counterexample on b521d29d9 of the model, which let the request cover the key, fixed as`a demand change retries a parked key whose replay request failed`; a production mutant that retires the failed segment without a retry fails it and 12 other lazy histories). The `request error bookkeeping`property checks the shared tracker's listed causes and its change result, including a request with several failures. Its`fail-again`step fails an issuer again with every cause it still lists and one new cause, as a replay's later verdict does: a cause still open keeps the place of its first failure (law 17; RED on d0cde4778, which released the issuer's causes before it counted the new list). The lazy and window grammars make`Date.now()`strictly increasing and check every request error's`at`after each step: a failure the model did not show anew keeps the`at`the node showed for it (RED on d0cde4778 in the named history`a failed replay's error stays when parked demand retires after the child restarts`and in the generated lazy property; reach`replay verdict lists an open failure again`, entered by a prefix in which a parked demand joins a failed replay; a mutant that clears the verdict's request errors before it reports its failures fails the named history). Both grammars also check that no `health:change`of a ready query repeats the tree before it. Open (R18, maintainer decision pending on code weight): a replay's verdict, and a lazy load or demand change that decides several keys, announce once per request error they change, so a listener sees intermediate trees (a verdict that replaces key errors with its failures first shows them cleared); a check that every announcement shows a request error frame the model held fails on d0cde4778 (for example`a failed replay's verdict replaces an earlier open failure`, step 5) and passes with a lifecycle `changeRequestErrors(change)`batch that costs about +57 B gzip, so it is not committed. Limits: these announcement checks skip trees whose query is not ready, because a source crash makes the query announce the same crashed tree twice in one step (seen on d0cde4778 at the lazy grammar's`crash`step); the main grammar owns crash announcements and does not count them. Not fixed (R3, R5): during a reload, before it completes, the new run's`reportHealthy()`clears the retired run's failure reason, and a query's own reload drops the retired run's request errors at the new run's first request error; the main and window models encode that behavior, which is in tension with law 16 ("a reload keeps the node's problems until it completes"), and needs a maintainer decision. Not reached (R8): a retired run's committed but unapplied truncate, from a listener that reenters during`commitPendingTransactions`, can complete the new run's reload. A deterministic work witness (`request-errors-work.test.ts`) bounds one failed 2,000-key segment's report-and-settle work at 20·K visits; the original scan-and-sort tracker used 14,007,001. A lazy named history checks that a demand change which retires a fragmented-churn replacement and another segment, while the replaced segment's replay load has failed, fails the replay for the still-held key (laws 9 and 16); the subscription lifecycle owner's demand-change grammar generates the class. A failed replay does not list the failure of a load the query released before the verdict (law 16; glossary request error: it clears when its demand retires); the named history `a failed replay does not list a load the query released before its verdict`fails on the model that listed it and on a production mutant that keeps a released demand's failure. A cleanup during a reload (ARCHITECTURE law 14; maintainer decision that a reload ends with its run) is a generated step in two grammars. The main grammar's`cleanup-during-reload`runs`cleanup({ dependents: true })`from the Collection's or the dependent query's`health:change`while`reload()`announces`reloading: true`. The lazy grammar's `ending`runs`cleanup()`or`cleanup({ dependents: true })`from those, from the Collection's`loadingSubset:change`, or from a change subscription's `status:change`while the reload retires its demand. In both, the model is an ordinary cleanup: no sync run stays live at the adapter, a reload in flight rejects, the Collection ends`cleaned-up`, and a later fresh start's commit is its diff baseline. Setting the epoch after the reload's announcement, or the truncate after the run's retirement, fails 12 histories; restoring the old flag order, or dropping the epoch return, fails the subscription-status ending. Cleanup from the retired run's adapter cleanup function or from the new run's `sync()`is outside this grammar. A cleanup of a Collection that a cleanup during a reload left`cleaned-up`has no run to wait for and settles at once; the driver holds an adapter cleanup only while a run is live (a 30-times counterexample of the driver). Focused witnesses check that a reload from inside a`loadSubset`call the deferred start runs rejects, that a cleanup run from a subscription's status listener while a reload retires the run ends that reload with its run (no sync run starts and no replacement stays armed), and that a lazy join load that fails after readiness, under an ordered window that filters on it, publishes the output it held. The main grammar also compares the query's row count at the`status:change`that makes it ready with the count the step leaves (law 9): A's truncate commit that marks A ready while the nested shape's outer query waits for its inner query reaches it, in the first load and in a reload of a failed first load; a completion check that ignores the graph's pending work fails it. Limits: row count only, not identity. After every step the check compares status, rows, the projected tree, node identity,`status:change`/`health:change`order, and`findProblems()`. Named histories fix a reload's new-run `failureReason`surviving its completion. Focused grammars cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled reload commit changes nothing and the reload keeps the rows; a diffing adapter sees an empty baseline inside a reload's snapshot), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios`sync-errors-after-readiness`and`crashed-status`judge the hooks;`sync-errors-after-readiness`reads a two-failure`failureReason.errors`and a one-failure`error.errors`through every framework driver. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle grammar. Adapter receivers for reload of a ready Collection (maintainer decision F19: the reload keeps the rows until it completes; only a`begin({ snapshot: true })`transaction or the new run's own`truncate()`replaces them; every other write applies on them): Electric's integration tests`reload of a ready Collection`(a resumed stream with no changes, or with an update, a delete, and an insert, applies them on the kept rows and completes at up-to-date; a stream that cannot resume replaces the rows with its fresh snapshot; a must-refetch keeps the rows through its reset-marker commit and replaces them at its snapshot) and`an on-demand reload keeps its rows and ends with the source's rows`(resumed or not: the replayed subset's rows apply on the kept rows, and the adapter writes an update for a kept key, since core no longer rewrites a kept-key insert and sweeps nothing; the adapter owns removing rows deleted while no stream listened, [#2269](https://github.com/TanStack/db/issues/2269)), and`a progressive reload that cannot resume keeps its rows through a subset load`(no kept row vanishes before the atomic swap, the kept key takes the source's value, the live query has no error; it failed on the parent core with DuplicateKeySyncError); query-db`a reload of a ready eager | on-demand Collection keeps its rows and ends as a refetch does`; PowerSync `eager mode: a reload of a ready Collection keeps its rows and ends with the table`(three batches in one snapshot transaction); RxDB`a reload of a ready Collection keeps its rows and ends with the documents`(a first run that missed changes; two fetch batches); and TrailBase's lifecycle grammar`a reload of a ready eager Collection`(10 histories; see its ORACLE.md). On the parent core, Electric's resumed and must-refetch cases, query-db on-demand, PowerSync, and TrailBase's two-page cases fail; RxDB's passes and guards the port. Each of these mutants fails them: a resumed Electric run whose first commit replaces rows; a fresh Electric, PowerSync, or RxDB load without a snapshot; and a query-db housekeeping or direct write marked`snapshot: true`. A reload keeps every row its new run does not rewrite, and core deletes no kept row on its own (maintainer decision; removing a row the backend deleted is the adapter's job, [#2269](https://github.com/TanStack/db/issues/2269)); the adapters' own on-demand reload receivers belong to their packages. Adapter receivers for an on-demand reload (maintainer decision: no core sweep; a held key is written as `update`; issue #2269): Electric `an on-demand reload keeps its rows and ends with the source's rows`(resumed: deleted rows go through the log; cannot resume: a row deleted while no stream listened stays), query-db`a reload of a ready on-demand Collection keeps its rows and ends as a refetch does`(row ownership removes the deleted row), PowerSync`an unchanged row survives an on-demand reload`(the adapter's own tracking removes the deleted row), and the TrailBase grammar`an unchanged row survives an on-demand reload`(a row deleted with no event stays). Without Electric's`syncedKeys`seed and its update-for-held-key write, its non-resumed and progressive witnesses fail. Subset snapshot rows go through the same`syncedKeys`check as stream rows. PowerSync`eager mode: a reload whose later batch fails keeps the rows and the next reload ends with the table`checks core state: no open transaction remains and the accepted rows equal the kept rows; a snapshot begun before the batches fails it. Abort receivers (an abort ends the load): Electric, PowerSync, TrailBase, and query-db`an aborted load writes no rows and its late result changes nothing`; each fails under a mutant that ignores the signal at that adapter's stop point. Limits: an Electric abort while a request is in flight still lets the SDK publish its rows; query-db applies a late result after an abort without `unloadSubset`(a replay replacing the load within a run), whose data is current, so it costs a write, not correctness; RxDB has no on-demand path. Electric's`a ready after the stream gave up changes nothing`witnesses that`markReady()`after the run's`fail()`is a no-op (F6): the SDK gives up while a ready run's up-to-date commit waits for a wrapping sync's acceptance, and the acceptance arrives afterwards; there is no unhandled rejection and no status or health change. It fails on the parent core with an unhandled`CollectionStateError`. Limits: the progressive and on-demand Electric witnesses are fixed examples, not generated histories. A joined-filter grammar checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure grammar checks that. An on-demand reload grammar crosses the new run's first commit (an empty `markReady()`or a row commit inside`sync()`, or `markReady()`after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows the kept rows with the new run's own commit on top (no snapshot came). After the loads settle, it checks the new run's rows. A late-first-load grammar enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's`reload()`. A query that follows its source into `error`after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls`fail()`before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction:`write`and`commit`steps span other steps, the driver writes like a diffing adapter, a`write`can omit the`tag`field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left).`cleanup-hold`and`cleanup-settle`steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property,`source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()`until a`child-ready`step, so the child can be`loading`or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second`reload()`returns the first one's promise; and the child adapter's own`truncate()`(a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key, also one whose load threw, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, and source reloads during the first load: the first load waits for the whole replay, and a replay fails only for a key the query still needs when it is judged. The child adapter honours aborts; an adapter that ignores them is outside the contract (maintainer decision: an abort ends the load). In the on-demand grammar, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted is over and its consumers wait for the replay, as after a truncate; a named history checks that the retired run's load decides nothing when its abort rejects it. A named history checks that a truncate after a reload's failed replay replays the demand again. The on-demand window grammar adds`settle-aborted-fail`and`settle-aborted-ok`, which settle only the loads the query aborted, with declared reach for an aborted load that settles beside a pending load after a request error. A source reload that aborts the app's retry after a request error leaves that error to the replay's verdict: the retry's late rejection or success decides nothing, and a failed replay's verdict replaces the retry's failure. On 2bc79fc7c the coarse verdict's window histories fail, and a mutant whose replay failure reports no error fails 37 tests. Window moves never abort a root load outside a replay, so a root load's late success after an abort outside a replay is unreached (a mutant that lets it clear the root error survives); page-distinct root failures are not modelled: the grammar keeps one logical window demand, as law 16's "later load of that Collection" wording allows. The lazy grammar's `remote-delete`step deletes a child at the provider with no stream event; a child Collection rows check after every step checks that a reload keeps that row (maintainer decision: no reload sweep; RED on 2bc79fc7c, which swept it). The main grammar's blind`insert`write checks that an insert of a key the baseline holds throws, during a reload as at any time (maintainer decision; RED on 2bc79fc7c, which turned a reload's insert of a row the retired run left into an update; a mutant that lets a reload's insert of a held key apply fails 12 named histories); the lazy grammar's diffing adapter writes an`update`for a kept child. Limits: the window grammar has no provider deletion. Every truncate replays the demand again, also one the reload's new run sends while the reload's own replay is open or after it settled (F8: a latch that skipped the reload's own truncate let a replacement delete replayed rows with nothing re-requesting them); a reload with no held acquisition keeps the rows and replays nothing. The window grammar's`reload-query`reaches the query's own reload while its node shows a request error: the query keeps its rows, its node's error, and`reloading`until its new run's window load settles, keeps the window it showed (a window move waiting for a replay ends with the retired run), and then shows only the new run's failure, if any (F4). During the query's own reload the window grammar also issues window moves and`restart`. A move of the new run whose load fails keeps the reload, its rows, `reloading`, and its promise until a later move's load succeeds, and a failure the new run reports shows on the node at once (maintainer decision). `restart`(cleanup and preload of the query) discards the window and any own reload in flight, so the new lifetime loads the initial window. Its limits: no source reload, report, or aborted settle during the own reload; the reload starts only when the source shows no problem; and a restart ends the request-error announcement watch for the rest of the history. Named histories also fix that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay is decided by the replay's verdict, that a reload during the first load fails it only for a demand still held when the replay is judged, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses fix that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in`error`or`crashed`ends, and that a pooled view keeps node identity when its source alias is`root`. Later witnesses fix that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error`status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. The query-db grammar`failureReason follows the still-failing Queries` (`query-db.failing-queries`) enumerates every history of four fetches over two subsets' Queries, directly and behind the persisted wrapper (a receiving witness: the wrapper must relay `reportHealthy(error)`'s value). Its model keeps each failing Query's latest error value and the listed values, one per value, oldest first (review: one entry per failing Query, and a value stays listed while any failing Query holds it); fetches succeed, fail with a fresh error, or fail with one shared value; after every fetch it compares the listed causes by identity and order, the shown `message`and`cause`, each kept failure's `at`, and health tree identity (ARCHITECTURE law 17): a fetch that leaves the frame as it was keeps the node and its `failureReason`object and announces no`health:change`. Declared reach includes a Query that fails again while listed and one that recovers with two failures listed while another fails. RED on the parent: a recovered Query's failures stayed listed. The main grammar's `report`and`healthy` steps can name a listed failure's error value or an unlisted value with the same message; a report of a listed value and a healthy call for an unlisted one leave the frame as it was and must announce nothing (maintainer decision), and a healthy call for a listed value removes only that failure. Limits: replacing a Query's failure announces two health:change events (add, then remove). The [snapshot oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts)'s health grammar (`live-query-observer.snapshot-health`) generates `report`, `healthy`, `write`, `subscribe`, `unsubscribe`, and `read`over one ready source and checks that every`getSnapshot()`shows the current`failureReason`, `hasErrors`, and `isSyncError`, on detached reads, attached reads, and across the attach cut, and that the tree keeps its identity while unchanged; after a round trip between reads that ends on the previous read's content, either identity is legal (glossary, health tree). Each read also compares the `failureReason`'s `errors`(messages in order, each cause by identity) with the model's frame, and a declared reach state reads a frame of several failures. Limits: one source,`failureReason`only, no hydration seed, no framework. The shared conformance cells`crashed-status`and`crashed-status-during-first-load-{collection,query}`read each driver's status flags without reading`data`, and require Solid's resource path to show `crashed`, not `error`, when preload rejects for a crashed Collection. By maintainer decision, an error boundary receives an Error for a failed first load or a crash: the cause itself when it is an Error, otherwise an Error with the problem's message whose `cause`is the reported value; React's`useLiveSuspenseQuery`and Solid's resource witnesses cover a failed first load and a crash, each with an Error and a non-Error cause: a failure inside sync and after mount, a sync function that throws, and a later crash (after readiness for React, during the first load for Solid). A crash path that throws the raw cause fails the non-Error crash cells in both. The replay refinement oracle's`settle`command can keep holding loads that start during the step, so a held retry keeps a failed join load's request error visible. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a`reload()`that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories fix the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay, also an in-flight one when the child restarts (the CI counterexample seed -838690579: production replaced the in-flight replay and lost its own failed load;`a reload after the source stopped replays its demand and fails with every load`); a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted does not clear a request error; a held replacement that loaded decides the demands it replaces; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady`option does nothing on a ready Collection, also after`fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()`is outside the grammars; a window move with no request is served from loaded rows. A child whose first load failed parks new demand without a request; by maintainer decision the lazy model predicts the parked acquisition from law 10 (growth or churn), and a declared reach state and prefix enter that region. Named histories fix that a replay re-requests parked demand and then publishes, that a released parked demand parks again, that a replay's failed request keeps its acquisition's coverage, and that a later change drops a parked replacement that no longer matches. ARCHITECTURE law 16 states the rule. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's`cleanup({ dependents: true })`and restart, and then subscribes again stays`cleaned-up`, while a live-query Collection restarts; the pooled cleanup-admission grammar crosses observed and unobserved views but never restarts their source; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | -| Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | +| Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, adversarial keys, aliases, cycles, and Map/Set live iteration are literate. | | Query DB and observer | Complete | Query-scope row ownership, subset identity and cancellation, failure and recovery, and the per-listener eligibility ledger are literate. | | Query DB SSR dehydration | Fixed QueryClient/Seroval boundary | Successful on-demand entries with a compound predicate, a request signal, and an ordered cursor with a custom comparator retain their row and user metadata through Seroval's reconstructed stream payload and Query Core hydration; request options are absent from the stream, while query functions retain the original values. The TanStack Start router and browser Collection resume path still need a host witness. | -| Ordered acquisition | Complete | Exact demand identity, applied settlement, independent pagination recomputation, request work, lifecycle products, replay authority, source-generation readiness, and transaction-refinement abort boundaries are literate. An initial full-source ordered load shares the synchronous readiness cut ([historical review](oracle-reviews/initial-full-source-sync-readiness.md)). The ordered-lifecycle oracle covers an inexpressible-boundary fallback before first publication when every acquisition returns literal `true`. It also checks a later null-boundary fallback after the first joined window publishes, across literal, Promise, and held-receipt responses, including a held receipt after child demand settles ([review](oracle-reviews/issue-2152-initial-ordered-fallback.md)). The ordered loader state oracle also has a generated request-authority lane (`ordered-loader.request-authority`, fixed and random campaigns). It drives the real loader over a fake subscription through settlements in any order, failures, deferred invalidation and report reactions, ordinary passes and window moves, and began with four laws from a TLA+ model (#2191): a successful repair keeps its acquisitions until a covering read succeeds; quiet loading holds nothing older than its last authoritative read; a superseded request's failure is recovered by a full-source read without an explicit retry; and once quiet, an invalidation and a pass issue a read unless a full-source read covers the source. Scenarios also throw from the first release callback, answer a repair synchronously from installed rows, and let a page's own delivery invalidate ordering; named replays keep the traces they found, each of which failed on main (a throwing release that unwound a resumed repair, a synchronous repair waiting behind a superseded request, and a self-invalidating page whose tie was never retired). On main it found a superseded tie that failed after its repair finished and a newer invalidation's reaction was deferred, which left refills blocked; a named replay keeps that trace. A stale prefix that retires the repair's prefix fails the held law, and a stale request held after the repair finished fails the leak law. The lane also crosses unexpressible boundaries, joined work that defers loading, truncate replay that succeeds or stays pending, optimistic departures, source deletes, the bounded backoff retry, window moves that shrink or widen, plans that read only the full source, and reports inside the loader's own first page. A replay owns the requests issued before its truncate, and when the subscription still holds a full-source lease the settled replay covers the source. These dimensions found three more defects, each kept as a named replay: a repair prefix that settled after a replay started its replacement took the replacement's chain tag and was never retired; a replay counted a full-source read as complete after an explicit retry had released it; and a failure from before a truncate dropped the next repair's staged step. Each of the seven defect replays fails on the loader at the branch's merge base with main. A later law, one request at a time, found that a replaced repair chain's prefix still counted as current and issued a tie beside the replacement's prefix; a chain is now superseded by any newer authority start. The failed-lease law also counts a covering replay, which a named replay reaches. Repeated random campaigns then found a repair request that settled after full-source coverage and stayed held; full coverage now retires it. The held law counts a later prefix that a successful replay reacquired while it was in flight. Four histories from TCP's known error classes followed: a request the provider never answers (a lost segment), a release callback that truncates synchronously (a reentrant reset), a window offset other than zero, and an at-most-once release law with no release while a replay owns the demand unless an explicit retry replaces it or a held full-source lease covers it. They found that the backoff retry's release pass kept releasing failed demands after a release started a truncate; every release pass now stops on a reentrant reset. The oracle now observes epoch moves as calls to `invalidateSourceOrdering()`, so it tells a replacement chain from a chain step exactly. The loader's work slot and debt now move only through a transition table that throws `OrderedLoaderInvariantError` on an illegal move or an incoherent work and debt pair; the oracle records any such error as a broken law, so its generated histories fuzz the table. A covering-lease law (a full-source lease whose read succeeded or whose demand a replay reacquired is never released) found that a full-source transport failing after a covering replay turned complete coverage into an own failure; the table then found that a repair request failing after a replay finished its chain did the same. Mutants that drop each new guard fail. A release callback that runs an ordinary pass synchronously, as a graph run does when the release delivers deletes outside a transaction, found that the pass issued a page while another request was settling, and that request then issued its tie; every release pass now blocks refinement until it unwinds. A dispose command and a release callback that disposes the loader check that a disposed loader issues and releases nothing; mutants that drop the loader's own `active` checks in the release pass and in failure handling survive because dispose resets the cursor, so those checks were removed. TLA+ run 3 (`LoaderTable.tla`) models the table with truncate and replay; its safe rules pass with 9,368,122 distinct states, and each of the four guards from this round, removed alone, produces a counterexample. Reverting any one fix fails its replay and a campaign. The generated histories also stack a second truncate on a pending replay and truncate after a failed replay, each with a reach witness. A lease missing from its map during a release pass is now an invariant violation; no history reaches it. A second high review found that a covering replay did not retire a superseded failed lease from before its truncate, because the failed-lease law exempted every replay-owned request. The law now judges a superseded failure that was live (it failed in the incarnation it was issued in) once a covering replay settles after it, and the replay retires those leases. The subscriber now schedules the replay's graph run in a finally. The replay-refinement oracle is the receiving witness: its generated ordered section (`replay.ordered-window-faults`) drives a real on-demand Collection and an ordered live query through rank moves, held and failed loads, truncates, and unloads that throw without a truncate, while a replay starts, and while it settles. Its settled-window law found that a release throwing after a successful ordered load left the window on old rows, because the builder counted the rejected participant as a failed load (also on main); the loader now marks release errors and the builder publishes. It also fails without the subscriber's finally. The section now also crosses tied ranks (its law compares the window's rank sequence and each shown row's current rank), a peer live query on the same source, offset 1, descending order, and explicit window moves; a move that rejects after a failed source recovery leaves the window, and one rejected only by a release failure still moves. A reported-faults law accepts injected faults as rejected loads, thrown releases, rejected moves, or errors the subscription rethrows from a microtask, and rejects any other escaped error. These found that a window move whose load succeeded was recorded as failed when a release threw, so the window stayed private until another move. A throwing release is an adapter bug, so the loader now reports it uncaught from a microtask and changes nothing else; the reported-faults law rejects a move that a release failure rejects, and the request-authority oracle requires each throwing release to be reported uncaught exactly once (a mutant that swallows the error fails). It also crosses a joined marker source: a LEFT join that keeps marked or unmarked rows, and an inner join. The marker source is eager or on-demand, with marker loads that are held, fail, and truncate. This found that an on-demand joined source whose loads finished without delivering a row left the window on old rows, because nothing ran the graph that the pending joined work held; the subscriber now runs it when a joined source becomes ready. A truncate never aborts a window move: the move waits for any replay that holds publication and then applies (ARCHITECTURE.md), so a move that rejects with `AbortError` during a history is an escaped error. (An earlier version of this entry claimed a named replay reached an aborted-move branch; no history reached it, and the branch, which quietly accepted such a rejection, is gone.) Recovery truncates the marker source too, because a failed marker replay keeps the last complete publication until that source replays. A hostile-mutant pass over the loader's guards found guards that no ordered oracle kills. A differential instrument then ran 3,000 generated request-authority histories through the loader and each mutant and compared every request's shape, outcome, and release. Three guards the code makes redundant are deleted: the backoff timer guard, the explicit-move check in the retry callback, and the `requiresFullSource` check on a report. An ignored-report law now kills two of the mutants that changed public requests: a report issues no authoritative request before any request settled (a request in flight still owes an exact result for its own options) or while the window is inside the loader's own first prefix. Named replays reach both, and the random campaigns judge them in quiet histories. A random ordered-work campaign later found that the first-prefix check was unsound: it took the next installed row as the next source row, but a signalling refetch moved the page's end before the tie the loader had read, so that tie's row stood past the page while a row tied with the new end was never sent, and a later window move skipped it. The loader now accepts the row only when its page read it or when it read the tie of the page's current end. When the end moved, it repairs (see the later maintainer decision below); query-db's first-page witness requires bounded reads only, no full-source read, and an unchanged count of active Queries. The optimistic-window cases that allow but do not require a refill still bound any refill that occurs: an ordered read within the refill bound or a predicate read, never the full source (a mutant that made the growth read unbounded fails them). The ordered-work model now sorts `undefined` ranks with `null` ones, as the comparator does, and its histories generate both; nullish ranks tie in its window and in-flight comparisons. An `undefined` end also matches a loader that attempted no tie; a nullish end is never an expressible cursor, so the loader has started or will start a full-source read, which covers the source either way. A guard against that match survived the generated campaigns as an equivalent mutant and was removed. The ordered-work oracle keeps the trace as a named replay that fails on main, and the request-authority oracle has an unproven-report law (a report over a first page that no row it read proves starts a repair or reads the end's tie), whose replay and fixed campaign fail on main; the broad campaigns reach that law in about one history in 10,000, so an unproven-report campaign (`ordered-loader.unproven-report`, fixed and random) settles the first page, settles the read after it, and reports, over random case parameters and a random suffix, and declares its reach (about a third of 300 histories); `main`'s loader fails both runs. Its fake subscription now reads installed rows as the subscription does, applying a tie's equality, a cursor, and a limit; when its reads ignored the limit, the tie was always the last installed row's, so the first-prefix ignore branch was unreachable. The cost law's per-chain bound, three requests, was an artifact of that fake: it is now a prefix plus a tie and a page per installed row, and it also judges an unindexed window that still needs rows, which grows its prefix instead of reading the full source. The report campaign (`ordered-loader.unproven-report`, 1,000 runs; at 600 a random seed could miss the minimum) now starts each history with one of eight prefixes and declares reach for each report premise: a proven first page, a page that read past a window that shrank, a held full-source read, a head insert that moved the page's end, an owed repair, authoritative work in flight, a release of the older tie that truncates or disposes the loader, and a report to a disposed loader. The unproven-report law requires a bounded repair or the end's tie when every installed rank is expressible; a full-source read no longer satisfies it (a mutant that forced full debt after a report passed before). The cost law also rejects a repeated read: within one invalidation, no prefix, tie, or page repeats more often than the ordering epoch moved. Two laws are stronger: an ignored report, over a proven first page or a held full-source read, issues no request at all, where it used to forbid only authoritative ones; and a report while a repair is owed reads no tie or page before the repair. Deleting the moved-end early return in `revalidateLoadedCoverage`, or its past-window term, fails the first law, and deleting the owed-debt term in `loadMovedEndTie` fails the second; a deletion sweep had found all three surviving every oracle. With that reach, the report path's own `!active` checks were deleted: `dispose()` advances the generation that the stale-run check after the release reads, and a disposed loader issues nothing, which the dispose law judges. A head insert can carry a `-Infinity` rank, and two more laws judge every request: the loader never reads the tie of a rank no cursor can express, and the first finite request after a truncate has no cursor. The fake's `dataNeeded` now counts the window rows not at or before the boundary it is given, where it returned a constant. With those, the stale-run check after the older tie's release fails the reset law when a release truncates the loader and the window then grows (a prefix declares that reach), and removing `loadBoundary`'s own unexpressible-value check fails the unexpressible-tie law. The covered-source early return and the staged-continuation term were deleted: coverage that a full-source read completed holds the work slot, whose check then decides the report, and a staged finite continuation cannot coexist with a proven end, while a staged repair step owes a repair. The ignored-report law's full-source branch no longer needs a quiet history, since such coverage owes nothing, and a prefix reaches a paged loader whose repair fell back to a full-source read; removing the work-slot check then fails it. The unexpressible-end term in `loadMovedEndTie` stays: the unindexed-refetch random campaign found that without it an unindexed loader reads the full source through the boundary fallback where its bounded prefix repair would do, and a named replay of that trace fails without the term. Maintainer decision, later: a report whose page end moved now repairs instead of reading only the new end's tie, to cut shipped bytes; `loadMovedEndTie` and its guards are gone, so their mutants above no longer exist. The laws stay and judge the repair path: the ignored-report, owed-repair, unexpressible-tie, and reset laws all still run, and the unproven-report law accepts a repair. The report campaign drops its two prefixes whose release of the older tie truncated or disposed the loader, since a report no longer releases anything, and the query-db replay of an insert into a top-N list now expects the bounded repair's prefix and the new end's tie. Still open there: the `Object.is` term of the moved-end check. It skips work when the loader already read the end's tie, and it decides differently from its fallback only while other work is in flight, which the ignored-report law's quiet premise excludes. The ordered-work oracle owns that branch instead: peers over a refetching provider stay in its grammar for source filters (#2155 excludes only joined filters), and a proven-report law requires that a report which changes nothing the first query's own page and end tie prove, with the model's next row installed and no installed row deleted or reordered, starts no request for it and leaves it owing no `refetch: true` repair. A campaign fixed to that premise must reach it, and a loader that repairs every report fails both its fixed and random runs. A staged end tie that pending joined work defers is read by the pass after that work finishes; a request-authority named replay reaches it, and a loader that drops the staged tie fails it. The unproven-report law judges that later pass, and a truncate, failure, move, or disposal in between voids what the report owed. A history that starts during the initial preload found that a window move deferred behind a marker replay, which the source truncate then discarded, rejected with that replay's `AbortError`, so the older move's window won (also on main). A deferred move now waits for every replay that holds publication and applies unless one failed or cleanup ended the sync run; a named replay fails without the fix. The ordered-work refetch law also required no full-source read after a signalling refetch for an unindexed plan, which the architecture then let refill a short window from the full source; random seeds found it on main. Maintainer decision on this branch: an underfilled unindexed prefix never reads the full source, because that read is unbounded. It grows instead: each growth asks for twice the last prefix, and it stops once the last growth added no rows. A deletion sweep showed that the older condition's other term, a prefix that reads short, follows from that one. The refetch law now forbids a full-source read after a signalling refetch with no unindexed exception. The named replay of a short unindexed window and the fixed unindexed-refetch campaign (`ordered-work.eager-unindexed-refetch`, fixed and random, with reach on windows the prefix fills and windows it leaves short) failed on the full-source fallback and pass with growth; the full-source mutant still fails them. Growth made a new optimistic history possible: an unindexed one-row window behind a joined or anti-joined filter that installs only the prefix it grew to, whose departure leaves the replacement row missing. Sixteen fixed optimistic-window cases (update and delete, both consumers, acknowledge and rollback) cover it, with a refill bound of twice the installed prefix plus the overlaid row. A loader without the stop rule never reaches a fixed point; a loader that stops whenever the prefix reads short leaves those windows empty after settlement, because the pending departure hides a row from the count. The existing unindexed optimistic cases now allow, but do not require, a request behind a joined filter when the replacement row is installed: growth proves rows only up to its prefix, where the full-source fallback had proven every row (a maintainer-approved expectation change). The proven-report random campaign now declares its reach too. One mutant still changes requests without a law: dropping the optimistic-refill flag turns a refill into a refetching prefix. Six more are equivalent in the fake but stay, because the fake cannot reach their premise or they encode a documented rule: the own-first-page check behind a held sync transaction (the fake never holds one), a truncate moving complete coverage back to held, clearing the pending request when a full-source read starts (the fake never answers a full-source read synchronously), staging a joined `more` continuation, the staged-continuation guard before an unindexed prefix, and the drain re-entry guard (a long synchronous chain would otherwise recurse). An include dimension adds a `toArray` child that reads the marker source for each shown row, and an include-children law requires each shown row's markers to equal the model wherever the window law holds; a mutant that never loads newly demanded child keys fails it in an include-only scenario. A newest-move law requires a superseded window move to resolve only after the newest move settles (a superseded move that fails still rejects with its own error), and the model lets the newest move that resolved set the window; it found that a superseded move resolved as soon as its own load settled, so awaiting it showed neither window. The terminal-error law also allows `error` after a load fails while the query is still loading, the documented initial-load failure; a lazy join source is exempt. Limits: includes are one level deep. It also found that a release failure in lazy joined demand during a window move rejected the move, because the subscription reported it as a load error. A window law now lets the newest move win; it found that two moves deferred by a pending source recovery could resume in the opposite order, so the older request replaced the newer window. `setWindow()` now ignores a deferred call that a newer call superseded. A terminal-error law allows a final `error` only after a lazy join demand load, on either side, failed outside a truncate's recovery; named replays reach that branch. A load belongs to a recovery when it asks again for a demand its source held before the truncate (the same request data), or when the graph starts it while the truncate commits. The first model read a flag set only during the commit, but subscriptions start replay loads in a later microtask, so it counted every replay load as new demand and allowed `error` after any failed replay load; a random campaign found the reverse case (#2224): a marker truncate privately moved the window, the include's new demand started outside any replay because releasing its old demand ended the subscription's replay, and its failure ended the query. The demand controller now starts new coverage before it releases retired coverage, a query subscription opens a replay at every truncate even without demand (a replay that starts no recovery work and reports no loading), and a demand failure that fails its subscription's replay keeps the last publication instead of ending the query once the query has first become ready. The section also generates histories that start during the initial preload, with an initial-readiness law (the preload has settled once loading is quiet), marker loads whose aborted requests reject with `AbortError` as the source contract asks, and settles of the newest held load. These found two older defects on main: an aborted lazy demand request that rejected after its truncate replay succeeded ended the query, and so did a superseded segment that the controller released; the controller now treats an aborted request as obsolete demand, which its replay or newer coverage decides. Named replays keep each trace; reverting each production change fails one of them, and a mutant whose replay rebuilds its request data fails the campaigns' replay-load reach witness. The ordered-lifecycle oracle owns the truncate-work witness: a zero-limit window issues no request and reports no loading at a truncate, while a one-row window reacquires. A retention law then judges law 9 at generated checkpoints: a truncate raises a publication barrier, every load its source starts while the barrier is up joins it, a participant's failure fails the replay while its logical demand is held, and while any barrier is up the query shows exactly the rows it showed when the first one rose. It runs after every command and once loading is quiet. A failed-replay campaign (`replay.ordered-failed-replay`, fixed and random) starts each history by holding loads, truncating a source, optionally starting demand, and failing a held load, with a branch that fails an include demand while its marker replay still loads; it declares four reach states with `createOracleReach` (a judged failed replay, a judged held replay, a demand started after the commit that fails while its replay loads, and the include form of that demand). A replay participant now also counts as recovery for the terminal-error law, so a demand that starts after the commit while its replay is open is judged in generated histories, not only by a named replay. A mutant whose failed replay releases the publication gate fails both failed-replay campaigns and the broad campaigns; a mutant that ends the query when such a demand fails fails both failed-replay campaigns; the branch's production files reverted to `main` fail both. A preload-recovery campaign (`replay.ordered-preload-recovery`, fixed and random) starts every history during the initial preload over on-demand markers and declares two more states: lazy demand in a replay that fails before the first ready, and a window move that waits behind a replay a newer truncate discards. A builder whose deferred move a discarded replay rejects, and one that keeps a failure before the first ready non-fatal, each failed only a named replay before; both now fail the fixed campaign, whose seed kills both (each dies in about half of 200-run seeds, so the random campaign kills each about half the time). Building the law found three model errors, each corrected from ARCHITECTURE.md: a failure is scoped to its logical demand, so a retired failed demand and the demand that replaces it publish when the replacement succeeds; a replay completes when its last participant's rows apply, so demand that a deferred window move starts afterwards does not join it; and an ordered tie request (a `where` on rank) is not lazy demand, which the terminal-error law had treated it as, so a failed tie request had excused a terminal error. A query's subscriptions to one collection now share one replay barrier (ARCHITECTURE.md, "Replay participants and failure"): a probe found that in a query that joins and includes the marker source, a marker truncate retired the include's only demand privately, its emptied replay closed while the join's replay still loaded, and include demand the graph then started failed outside every replay and ended the query. A sibling-demand branch of the failed-replay campaign declares that state; the branch's code before the fix fails the named replay and both campaigns at `convergence: terminal error`, and removing either sibling hold (at replay completion, or for an emptied replay) fails all three. The random failed-replay campaign then found the other half: one subscription's replay failed, the other's finished once it settled, and demand that a rank move then started on the finished subscription failed and ended the query, because the subscriber judged the failure by its own subscription. The subscriber now asks whether a replay of the source's collection failed; a named replay (`a demand that fails after the sibling subscription's replay failed`), a `failedSiblingDemandFailures` reach in the sibling branch (which now may move a row and fail the oldest held load), and both campaigns at 400 runs fail at `convergence: terminal error` on the per-subscription rule. A design that judged a failure against any failed replay of the query, across collections, used to survive, because the convergence check runs after an explicit window move, which retries a failed row demand. A strict terminal-error law now requires a failed lazy demand outside every recovery to end the query (ARCHITECTURE.md, error 228), skipping only failures the model cannot place: after a load threw, and, with a peer, a load that joined a replay. Merge with #2257: its review (CF01, [record](oracle-reviews/2026-10-26-pr-2257.md)) judged a new keyed request that a truncate commit starts as terminal, and called non-fatal recovery of that demand a future contract. This PR adopts that contract by maintainer decision. The merged owner keeps CF01's initiating-request receipt check as a second account of every terminal error, at each history cut and at convergence, plus its named replays and the `replay.subscription-demand-origin` and `replay.keyed-request-failures` campaigns, with their two reach-state pairs measured on this owner's model. Both demand origins, new demand during a marker commit and a settled demand that the replay asks for again, must stay nonterminal after the first ready. An always-fatal `failDemand` mutant fails 15 tests, including both ported campaigns; a never-fatal mutant fails the strict law in 8. CF01's duplicate of the #2224 named replay is folded into this owner's existing one. Merge with #2168 (ARCHITECTURE.md law 16): after initial-query readiness no load failure ends a live query, and a failed lazy demand is a request error on the query's node. That supersedes the strict terminal-error law, the error-228 rule, and the always-fatal and never-fatal mutant results above for a query that has been ready. The replay owner now allows a terminal error only after a load failed while the query was still loading, judged at each history cut and at convergence, and it adopts #2168's request-error witness (`requestErrors`); the excusals for thrown and unplaced failures went with the strict law. #2168's design already starts new demand before it releases retired coverage, and it treats a synchronous `loadSubset` throw as a rejection whose owner's release unloads it (the subscription oracles pin that unload), so the adapter-release law now counts a thrown load as acquired. It lacked the shared barrier, the failure hold behind a sibling's failed replay, and the deferred window move's wait, which the merge ports onto it. Without the deferred-move loop, 8 tests fail, #2221 trace 2 among them; without `failReplacement`, the two-failures named replay fails; without the sibling wait, that replay and both failed-replay campaigns fail. A deferred move rejects when a reload ends the sync run of the query or of a source, which the source-health oracle requires. The window-request reset went: a move can no longer throw, and deleting it failed nothing. The replay that a truncate opened without demand went too: under law 16 its demand's failure no longer ends the query, and its named replay passes on #2168's subscription. Review of the merge (2026-10-28): the replay owner gained a request-error account. After initial-query readiness, each failed lazy demand load that is still owed must appear among the query node's open request failures, by its injected error object, at every history cut, and no request error may stay after recovery replays every source (authority: ARCHITECTURE.md law 16, law 17, and the glossary's request error). A failure is met when its demand retires, a later load of the source covers its keys, or a later replay of the source completes; a failure inside a replay owes nothing until every participant settled. A terminal error now needs a load that failed while that same query was loading, so a peer's initial failure no longer excuses `live`. A thrown load joins a replay that no participant joined yet, and the failed-sibling branch now generates a held retry. `requestErrorChecks` reaches the declared minimum of 5 in each failed-replay campaign. Mutants: demand outcomes that never report an error fail 9 tests at the per-cut check; a successful replay that keeps its request errors fails 22 at the stale-error check. Limits and open items: the account shares one replay across a query's subscriptions to a source, so it cannot judge one subscription's request error while another load of that source is held. The reviewer's finding that `failReplacement`'s replay, which has no recorded failure, completes when every demand that joined it settles is closed as a law revision by maintainer decision: the hold ends when every demand load that joins it loads, a later replay of the source succeeds, or the subscription's last demand retires (ARCHITECTURE.md, "Replay participants and failure"). The query retries the failed demand at once and that retry joins the hold, so the hold cannot publish without the failed demand's rows. The replay oracle judges each subscription's request-error verdict by the subscription that a load names, and its failed-replay campaigns declare the premise (a subscription's load succeeds while that subscription's failure is still owed; 30 fixed and 24 random histories at the normal budget). No live-query history violates the request-error or retention law there; a mutant that completes such a hold when any participant settles fails both failed-replay campaigns at the retention law. A subscription-level `it.fails` test that asserted the earlier, stricter wording (no joined demand may end the hold) was removed with the revision. Limit: the oracle drives the hold only through live-query histories, where the retry always joins; a direct `Collection.subscribeChanges` caller that never retries is outside the live-query contract. Which failure a deferred window move rejects with, when its replay has several, is not specified: `main` rejected with the replay's earliest failure, and this branch rejects with `lastSubsetError`; by maintainer decision the move rejects with its replay's earliest failure that still decides it (law 17 decides which count: a retired demand's, a retried request's, and a replaced demand's failures do not). An identity law now judges deferred moves that start before any failure of the replays they wait on; on the parent code both failed-replay campaigns and the named replay `a window move behind a replay with two failed loads` fail with "replay error identity", and the deferred-move loop that rethrows a non-abort replay error passes. The request-error account decides failures per key: a later load of the source that asks for some of a failure's keys decides them, and keys it no longer asks for left the demand. The held-retry branch then found, at three times the run budget, a marker change publishing over a window that the model retained after a failed marker replay (`hold, truncateMarkers, settle ok, settle ok, settle fail newest, mark 3 off`, ascending, offset 1, limit 1, includes over on-demand markers). It was a model defect: the query retries a failed demand at once, and when that retry loads the failed keys the failure no longer counts (ARCHITECTURE.md law 17), so the replay succeeds and publishes. The model now clears a replay failure when a later load of the replay loads its keys. The replay oracle passes at the normal budget and three times at three times it, and production without `failReplacement` still fails its named replay at the retention law. A move whose `setWindow` throws synchronously after the request generation increments is reachable only through invariant errors, which probes for a throwing subscriber, a crashed source, and a throwing `select` did not reach. An aborted request's rejection and the failure of a segment that newer coverage released are not failures. That design now fails both failed-replay campaigns. An independent oracle review then narrowed several judgments. The terminal-error excuse after a synchronous throw applies only to the thrown error itself, ending the query after the step that threw; the throw's own step, and every other failure, are judged strictly (a per-history flag had excused every later failure). A load that fails outside every barrier after a failed demand of its source retired is unplaced, because the failed replay may still hold through the subscription's other demands, which the adapter cannot attribute. Limit: with a peer, the strict law accepts either query ending, since the adapter boundary cannot tell which query's subscription issued a load; attributing each failure to its query's subscription would close that. The window check now also requires every shown row to be eligible and to appear once, so a tie cannot hide a row the join rejects. A synchronous-throw dimension (`markerThrows` arms the next marker load to throw while it starts) found that a load throwing inside an open replay ended the query: the subscription dropped the demand, so its failure did not fail the replay, and the subscriber treated the throw as fatal. The subscription now hands the replay a rejected participant for such a throw and keeps the demand, which a later replay retries; named replays (`a replay load that throws while it starts`, `a demand whose load throws inside its open replay`), a `thrownReplayDemands` reach in a thrown-demand branch, and both failed-replay campaigns (now 600 runs) fail on the parent code, and a mutant that later unloads the never-acquired load fails the adapter-release check. Model corrections, each from the adapter-boundary rule that a thrown load acquires nothing: a thrown load joins a replay only if it still has a participant or a failure; an armed throw expires before the drain; and a thrown demand's retirement is invisible, so the retention law skips the marker barrier from a throw to the end of its episode (a later barrier in the episode inherits the ambiguity, which also corrects the existing release-throw case), and a failure that joins an already failed or ambiguous barrier may end the query. Maintainer decision: a synchronous throw inside an open replay fails that replay as a rejection does, so the subscription replay oracle's `new request throws` case and five `live-query-collection` cases, which expected a thrown new demand to roll back, now expect the rejection outcome. Limits: a design that dropped a thrown demand and let its replay succeed passes wherever only retention could see it; after a history command follows a throw, a terminal error is excused, because the model cannot see whether that command retired the thrown demand and with it its failed replay (a demand that waited on that replay then fails outside it). The named replays judge the throw's own outcome strictly. The failed-replay random campaign at five times its run count then found a retention counterexample with no throw, on the parent code too: a join and an include of one marker source; one subscription's replay fails, the other's finishes, and a row move starts demand on it whose load fails, which the shared barrier kept from ending the query; a marker change then retired the first replay's failed demand, which discarded that replay and published an empty window, because the second failure held nothing. That failure now fails its own subscription's replacement, which holds until a later replay succeeds or the subscription's last demand retires. A named replay of the trace fails without the hold, and a `partialFailureRetirements` reach (a failed demand retires while another failure still holds the barrier) is declared in the failed-replay campaign; the campaigns themselves do not fail without the hold, so the named replay is the witness. A window move whose load throws while it starts counts as a settled, failed move in the model (it used to stay pending, so the newest-move law misjudged the older move), with a `thrownMoves` reach. The broad random campaign at five times its run count then found an older move whose settlement resolved with itself: the newer move threw after it bumped the request generation and before it replaced the latest request, so the older move's settlement returned its own promise, a chaining cycle that escaped as a TypeError. A call now clears the latest request when it bumps the generation, so no older move can wait on itself; a named replay fails without that. The same assignment covers a move deferred behind a replay, which had the same shape (an independent review found it can hang forever when a newer move throws between the replay settling and the deferred move resuming); that microtask gap is outside the grammar, which flushes between commands, so only the shared mechanism, not that path, has a witness. The ordered-work oracle also keeps the trace from #2221 as a named replay: before prefix growth the loader read the full source twice after a signalling refetch past a NaN boundary; with growth it reads none. Limits: the adapter boundary cannot tell which query issued a load, so the retention law and the participant rule skip histories with a peer. The retention law judges barriers that rise after the query first became ready; before that the initial-readiness law governs. It skips a barrier where a release threw while the replay started, which may fail that replay. Core now reports a throwing `unloadSubset` uncaught and fails nothing with it; the subscription lifecycle, replay, Effect disposal, includes-temporal, and subset-error oracles check the uncaught report, once, with the adapter's error. The request-authority oracle now also generates requests that throw while they start, the last history only TLA+ covered, with an error-identity law: a failed request's participant reports that request's own error. It found that a superseded failure whose recovery read threw at its start reported the start error instead (review finding 6). The state oracle compares every current failed request's participant reason with its own originating Error by identity. Release cleanup cannot replace that reason, even when the thrown value's message starts with “release ”. A bounded 16-case checker calibration crosses index availability, widths 1/2, actual throwing release absent/present, and ordinary/full-source plans. It feeds three wrong participant reasons into the same model/production-driver/comparison; the original prefix waiver and an actual-release-only waiver fail at that check. The existing fixed/random 10,000 authority histories declare and enforce current-request-error reach and actual cleanup-fault/current-request-error overlap. Scope ends at the loader participant and separate uncaught-cleanup observations; it adds no provider/framework or intermediate-publication promise. The state owner's shared error-identity calibration and paired generated campaigns enforce these checks. A cost law then checks, after loading is quiet and a bounded repair is available, that one invalidation reads no full source and issues at most one chain per ordering epoch move (in the fake, a prefix, a tie, and a refill); a mutant that always repairs with a full-source read fails it. The replay-refinement section's status law requires a quiet live query and its peer to stay ready when no load failed. Limits: whether a release runs the graph at once or defers it to a transaction belongs to the scheduler, and the oracle generates both forms without checking the scheduler's choice; the fake's rows do not move except for the unexpressible boundary; publication holds stay with other tests; the laws do not judge requests issued before a truncate; a hung authoritative request suspends the leak law for its unfinished chain; a recovery start that throws during a superseded failure is rare in the campaigns, so a named replay owns it. | +| Ordered acquisition | Complete | Exact demand identity, applied settlement, independent pagination recomputation, request work, lifecycle products, replay authority, source-generation readiness, and transaction-refinement abort boundaries are literate. An initial full-source ordered load shares the synchronous readiness cut ([historical review](oracle-reviews/initial-full-source-sync-readiness.md)). The ordered-lifecycle oracle covers an inexpressible-boundary fallback before first publication when every acquisition returns literal `true`. It also checks a later null-boundary fallback after the first joined window publishes, across literal, Promise, and held-receipt responses, including a held receipt after child demand settles ([review](oracle-reviews/issue-2152-initial-ordered-fallback.md)). The ordered loader state oracle also has a generated request-authority grammar (`ordered-loader.request-authority`, fixed and random campaigns). It drives the real loader over a fake subscription through settlements in any order, failures, deferred invalidation and report reactions, ordinary passes and window moves, and began with four laws from a TLA+ model (#2191): a successful repair keeps its acquisitions until a covering read succeeds; quiet loading holds nothing older than its last authoritative read; a superseded request's failure is recovered by a full-source read without an explicit retry; and once quiet, an invalidation and a pass issue a read unless a full-source read covers the source. Scenarios also throw from the first release callback, answer a repair synchronously from installed rows, and let a page's own delivery invalidate ordering; named fixed histories keep the traces they found, each of which failed on main (a throwing release that unwound a resumed repair, a synchronous repair waiting behind a superseded request, and a self-invalidating page whose tie was never retired). On main it found a superseded tie that failed after its repair finished and a newer invalidation's reaction was deferred, which left refills blocked; a named fixed history keeps that trace. A stale prefix that retires the repair's prefix fails the held law, and a stale request held after the repair finished fails the leak law. The grammar also crosses unexpressible boundaries, joined work that defers loading, truncate replay that succeeds or stays pending, optimistic departures, source deletes, the bounded backoff retry, window moves that shrink or widen, plans that read only the full source, and reports inside the loader's own first page. A replay owns the requests issued before its truncate, and when the subscription still holds a full-source lease the settled replay covers the source. These dimensions found three more defects, each kept as a named fixed history: a repair prefix that settled after a replay started its replacement took the replacement's chain tag and was never retired; a replay counted a full-source read as complete after an explicit retry had released it; and a failure from before a truncate dropped the next repair's staged step. Each of the seven defect replays fails on the loader at the branch's merge base with main. A later law, one request at a time, found that a replaced repair chain's prefix still counted as current and issued a tie beside the replacement's prefix; a chain is now superseded by any newer authority start. The failed-lease law also counts a covering replay, which a named fixed history reaches. Repeated random campaigns then found a repair request that settled after full-source coverage and stayed held; full coverage now retires it. The held law counts a later prefix that a successful replay reacquired while it was in flight. Four histories from TCP's known error classes followed: a request the provider never answers (a lost segment), a release callback that truncates synchronously (a reentrant reset), a window offset other than zero, and an at-most-once release law with no release while a replay owns the demand unless an explicit retry replaces it or a held full-source lease covers it. They found that the backoff retry's release pass kept releasing failed demands after a release started a truncate; every release pass now stops on a reentrant reset. The oracle now observes epoch moves as calls to `invalidateSourceOrdering()`, so it tells a replacement chain from a chain step exactly. The loader's work slot and debt now move only through a transition table that throws `OrderedLoaderInvariantError` on an illegal move or an incoherent work and debt pair; the oracle records any such error as a broken law, so its generated histories fuzz the table. A covering-lease law (a full-source lease whose read succeeded or whose demand a replay reacquired is never released) found that a full-source transport failing after a covering replay turned complete coverage into an own failure; the table then found that a repair request failing after a replay finished its chain did the same. Mutants that drop each new guard fail. A release callback that runs an ordinary pass synchronously, as a graph run does when the release delivers deletes outside a transaction, found that the pass issued a page while another request was settling, and that request then issued its tie; every release pass now blocks refinement until it unwinds. A dispose command and a release callback that disposes the loader check that a disposed loader issues and releases nothing; mutants that drop the loader's own `active` checks in the release pass and in failure handling survive because dispose resets the cursor, so those checks were removed. TLA+ run 3 (`LoaderTable.tla`) models the table with truncate and replay; its safe rules pass with 9,368,122 distinct states, and each of the four guards from this round, removed alone, produces a counterexample. Reverting any one fix fails its replay and a campaign. The generated histories also stack a second truncate on a pending replay and truncate after a failed replay, each with reach. A lease missing from its map during a release pass is now an invariant violation; no history reaches it. A second high review found that a covering replay did not retire a superseded failed lease from before its truncate, because the failed-lease law exempted every replay-owned request. The law now judges a superseded failure that was live (it failed in the incarnation it was issued in) once a covering replay settles after it, and the replay retires those leases. The subscriber now schedules the replay's graph run in a finally. The replay-refinement oracle is the receiving witness: its generated ordered section (`replay.ordered-window-faults`) drives a real on-demand Collection and an ordered live query through rank moves, held and failed loads, truncates, and unloads that throw without a truncate, while a replay starts, and while it settles. Its settled-window law found that a release throwing after a successful ordered load left the window on old rows, because the builder counted the rejected participant as a failed load (also on main); the loader now marks release errors and the builder publishes. It also fails without the subscriber's finally. The section now also crosses tied ranks (its law compares the window's rank sequence and each shown row's current rank), a peer live query on the same source, offset 1, descending order, and explicit window moves; a move that rejects after a failed source recovery leaves the window, and one rejected only by a release failure still moves. A reported-faults law accepts injected faults as rejected loads, thrown releases, rejected moves, or errors the subscription rethrows from a microtask, and rejects any other escaped error. These found that a window move whose load succeeded was recorded as failed when a release threw, so the window stayed private until another move. A throwing release is an adapter bug, so the loader now reports it uncaught from a microtask and changes nothing else; the reported-faults law rejects a move that a release failure rejects, and the request-authority oracle requires each throwing release to be reported uncaught exactly once (a mutant that swallows the error fails). It also crosses a joined marker source: a LEFT join that keeps marked or unmarked rows, and an inner join. The marker source is eager or on-demand, with marker loads that are held, fail, and truncate. This found that an on-demand joined source whose loads finished without delivering a row left the window on old rows, because nothing ran the graph that the pending joined work held; the subscriber now runs it when a joined source becomes ready. A truncate never aborts a window move: the move waits for any replay that holds publication and then applies (ARCHITECTURE.md), so a move that rejects with `AbortError` during a history is an escaped error. (An earlier version of this entry claimed a named fixed history reached an aborted-move branch; no history reached it, and the branch, which quietly accepted such a rejection, is gone.) Recovery truncates the marker source too, because a failed marker replay keeps the last complete publication until that source replays. A mutant-mutant pass over the loader's guards found guards that no ordered oracle kills. A differential instrument then ran 3,000 generated request-authority histories through the loader and each mutant and compared every request's shape, outcome, and release. Three guards the code makes redundant are deleted: the backoff timer guard, the explicit-move check in the retry callback, and the `requiresFullSource` check on a report. An ignored-report law now kills two of the mutants that changed public requests: a report issues no authoritative request before any request settled (a request in flight still owes an exact result for its own options) or while the window is inside the loader's own first prefix. Named replays reach both, and the random campaigns judge them in quiet histories. A random ordered-work campaign later found that the first-prefix check was unsound: it took the next installed row as the next source row, but a signalling refetch moved the page's end before the tie the loader had read, so that tie's row stood past the page while a row tied with the new end was never sent, and a later window move skipped it. The loader now accepts the row only when its page read it or when it read the tie of the page's current end. When the end moved, it repairs (see the later maintainer decision below); query-db's first-page witness requires bounded reads only, no full-source read, and an unchanged count of active Queries. The optimistic-window cases that allow but do not require a refill still bound any refill that occurs: an ordered read within the refill bound or a predicate read, never the full source (a mutant that made the growth read unbounded fails them). The ordered-work model now sorts `undefined` ranks with `null` ones, as the comparator does, and its histories generate both; nullish ranks tie in its window and in-flight comparisons. An `undefined` end also matches a loader that attempted no tie; a nullish end is never an expressible cursor, so the loader has started or will start a full-source read, which covers the source either way. A guard against that match survived the generated campaigns as an equivalent mutant and was removed. The ordered-work oracle keeps the trace as a named fixed history that fails on main, and the request-authority oracle has an unproven-report law (a report over a first page that no row it read proves starts a repair or reads the end's tie), whose replay and fixed campaign fail on main; the broad campaigns reach that law in about one history in 10,000, so an unproven-report campaign (`ordered-loader.unproven-report`, fixed and random) settles the first page, settles the read after it, and reports, over random case parameters and a random suffix, and declares its reach (about a third of 300 histories); `main`'s loader fails both runs. Its fake subscription now reads installed rows as the subscription does, applying a tie's equality, a cursor, and a limit; when its reads ignored the limit, the tie was always the last installed row's, so the first-prefix ignore branch was unreachable. The cost law's per-chain bound, three requests, was an artifact of that fake: it is now a prefix plus a tie and a page per installed row, and it also judges an unindexed window that still needs rows, which grows its prefix instead of reading the full source. The report campaign (`ordered-loader.unproven-report`, 1,000 runs; at 600 a random seed could miss the minimum) now starts each history with one of eight prefixes and declares reach for each report premise: a proven first page, a page that read past a window that shrank, a held full-source read, a head insert that moved the page's end, an owed repair, authoritative work in flight, a release of the older tie that truncates or disposes the loader, and a report to a disposed loader. The unproven-report law requires a bounded repair or the end's tie when every installed rank is expressible; a full-source read no longer satisfies it (a mutant that forced full debt after a report passed before). The cost law also rejects a repeated read: within one invalidation, no prefix, tie, or page repeats more often than the ordering epoch moved. Two laws are stronger: an ignored report, over a proven first page or a held full-source read, issues no request at all, where it used to forbid only authoritative ones; and a report while a repair is owed reads no tie or page before the repair. Deleting the moved-end early return in `revalidateLoadedCoverage`, or its past-window term, fails the first law, and deleting the owed-debt term in `loadMovedEndTie` fails the second; a deletion sweep had found all three surviving every oracle. With that reach, the report path's own `!active` checks were deleted: `dispose()` advances the generation that the stale-run check after the release reads, and a disposed loader issues nothing, which the dispose law judges. A head insert can carry a `-Infinity` rank, and two more laws judge every request: the loader never reads the tie of a rank no cursor can express, and the first finite request after a truncate has no cursor. The fake's `dataNeeded` now counts the window rows not at or before the boundary it is given, where it returned a constant. With those, the stale-run check after the older tie's release fails the reset law when a release truncates the loader and the window then grows (a prefix declares that reach), and removing `loadBoundary`'s own unexpressible-value check fails the unexpressible-tie law. The covered-source early return and the staged-continuation term were deleted: coverage that a full-source read completed holds the work slot, whose check then decides the report, and a staged finite continuation cannot coexist with a proven end, while a staged repair step owes a repair. The ignored-report law's full-source branch no longer needs a quiet history, since such coverage owes nothing, and a prefix reaches a paged loader whose repair fell back to a full-source read; removing the work-slot check then fails it. The unexpressible-end term in `loadMovedEndTie` stays: the unindexed-refetch random campaign found that without it an unindexed loader reads the full source through the boundary fallback where its bounded prefix repair would do, and a named fixed history of that trace fails without the term. Maintainer decision, later: a report whose page end moved now repairs instead of reading only the new end's tie, to cut shipped bytes; `loadMovedEndTie` and its guards are gone, so their mutants above no longer exist. The laws stay and judge the repair path: the ignored-report, owed-repair, unexpressible-tie, and reset laws all still run, and the unproven-report law accepts a repair. The report campaign drops its two prefixes whose release of the older tie truncated or disposed the loader, since a report no longer releases anything, and the query-db replay of an insert into a top-N list now expects the bounded repair's prefix and the new end's tie. Still open there: the `Object.is` term of the moved-end check. It skips work when the loader already read the end's tie, and it decides differently from its fallback only while other work is in flight, which the ignored-report law's quiet premise excludes. The ordered-work oracle owns that branch instead: peers over a refetching provider stay in its grammar for source filters (#2155 excludes only joined filters), and a proven-report law requires that a report which changes nothing the first query's own page and end tie prove, with the model's next row installed and no installed row deleted or reordered, starts no request for it and leaves it owing no `refetch: true` repair. A campaign fixed to that premise must reach it, and a loader that repairs every report fails both its fixed and random runs. A staged end tie that pending joined work defers is read by the pass after that work finishes; a request-authority named fixed history reaches it, and a loader that drops the staged tie fails it. The unproven-report law judges that later pass, and a truncate, failure, move, or disposal in between voids what the report owed. A history that starts during the initial preload found that a window move deferred behind a marker replay, which the source truncate then discarded, rejected with that replay's `AbortError`, so the older move's window won (also on main). A deferred move now waits for every replay that holds publication and applies unless one failed or cleanup ended the sync run; a named fixed history fails without the fix. The ordered-work refetch law also required no full-source read after a signalling refetch for an unindexed plan, which the architecture then let refill a short window from the full source; random seeds found it on main. Maintainer decision on this branch: an underfilled unindexed prefix never reads the full source, because that read is unbounded. It grows instead: each growth asks for twice the last prefix, and it stops once the last growth added no rows. A deletion sweep showed that the older condition's other term, a prefix that reads short, follows from that one. The refetch law now forbids a full-source read after a signalling refetch with no unindexed exception. The named fixed history of a short unindexed window and the fixed unindexed-refetch campaign (`ordered-work.eager-unindexed-refetch`, fixed and random, with reach on windows the prefix fills and windows it leaves short) failed on the full-source fallback and pass with growth; the full-source mutant still fails them. Growth made a new optimistic history possible: an unindexed one-row window behind a joined or anti-joined filter that installs only the prefix it grew to, whose departure leaves the replacement row missing. Sixteen fixed optimistic-window cases (update and delete, both consumers, acknowledge and rollback) cover it, with a refill bound of twice the installed prefix plus the overlaid row. A loader without the stop rule never reaches a fixed point; a loader that stops whenever the prefix reads short leaves those windows empty after settlement, because the pending departure hides a row from the count. The existing unindexed optimistic cases now allow, but do not require, a request behind a joined filter when the replacement row is installed: growth proves rows only up to its prefix, where the full-source fallback had proven every row (a maintainer-approved expectation change). The proven-report random campaign now declares its reach too. One mutant still changes requests without a law: dropping the optimistic-refill flag turns a refill into a refetching prefix. Six more are equivalent in the fake but stay, because the fake cannot reach their premise or they encode a documented rule: the own-first-page check behind a held sync transaction (the fake never holds one), a truncate moving complete coverage back to held, clearing the pending request when a full-source read starts (the fake never answers a full-source read synchronously), staging a joined `more` continuation, the staged-continuation guard before an unindexed prefix, and the drain re-entry guard (a long synchronous chain would otherwise recurse). An include dimension adds a `toArray` child that reads the marker source for each shown row, and an include-children law requires each shown row's markers to equal the model wherever the window law holds; a mutant that never loads newly demanded child keys fails it in an include-only scenario. A newest-move law requires a superseded window move to resolve only after the newest move settles (a superseded move that fails still rejects with its own error), and the model lets the newest move that resolved set the window; it found that a superseded move resolved as soon as its own load settled, so awaiting it showed neither window. The terminal-error law also allows `error` after a load fails while the query is still loading, the documented initial-load failure; a lazy join source is exempt. Limits: includes are one level deep. It also found that a release failure in lazy joined demand during a window move rejected the move, because the subscription reported it as a load error. A window law now lets the newest move win; it found that two moves deferred by a pending source recovery could resume in the opposite order, so the older request replaced the newer window. `setWindow()` now ignores a deferred call that a newer call superseded. A terminal-error law allows a final `error` only after a lazy join demand load, on either side, failed outside a truncate's recovery; named fixed histories reach that branch. A load belongs to a recovery when it asks again for a demand its source held before the truncate (the same request data), or when the graph starts it while the truncate commits. The first model read a flag set only during the commit, but subscriptions start replay loads in a later microtask, so it counted every replay load as new demand and allowed `error` after any failed replay load; a random campaign found the reverse case (#2224): a marker truncate privately moved the window, the include's new demand started outside any replay because releasing its old demand ended the subscription's replay, and its failure ended the query. The demand controller now starts new coverage before it releases retired coverage, a query subscription opens a replay at every truncate even without demand (a replay that starts no recovery work and reports no loading), and a demand failure that fails its subscription's replay keeps the last publication instead of ending the query once the query has first become ready. The section also generates histories that start during the initial preload, with an initial-readiness law (the preload has settled once loading is quiet), marker loads whose aborted requests reject with `AbortError` as the source contract asks, and settles of the newest held load. These found two older defects on main: an aborted lazy demand request that rejected after its truncate replay succeeded ended the query, and so did a superseded segment that the controller released; the controller now treats an aborted request as obsolete demand, which its replay or newer coverage decides. Named replays keep each trace; reverting each production change fails one of them, and a mutant whose replay rebuilds its request data fails the campaigns' replay-load reach witness. The ordered-lifecycle oracle owns the truncate-work witness: a zero-limit window issues no request and reports no loading at a truncate, while a one-row window reacquires. A retention law then judges law 9 at generated checkpoints: a truncate raises a publication barrier, every load its source starts while the barrier is up joins it, a participant's failure fails the replay while its logical demand is held, and while any barrier is up the query shows exactly the rows it showed when the first one rose. It runs after every command and once loading is quiet. A failed-replay campaign (`replay.ordered-failed-replay`, fixed and random) starts each history by holding loads, truncating a source, optionally starting demand, and failing a held load, with a branch that fails an include demand while its marker replay still loads; it declares four reach states with `createOracleReach` (a judged failed replay, a judged held replay, a demand started after the commit that fails while its replay loads, and the include form of that demand). A replay participant now also counts as recovery for the terminal-error law, so a demand that starts after the commit while its replay is open is judged in generated histories, not only by a named fixed history. A mutant whose failed replay releases the publication gate fails both failed-replay campaigns and the broad campaigns; a mutant that ends the query when such a demand fails fails both failed-replay campaigns; the branch's production files reverted to `main` fail both. A preload-recovery campaign (`replay.ordered-preload-recovery`, fixed and random) starts every history during the initial preload over on-demand markers and declares two more states: lazy demand in a replay that fails before the first ready, and a window move that waits behind a replay a newer truncate discards. A builder whose deferred move a discarded replay rejects, and one that keeps a failure before the first ready non-fatal, each failed only a named fixed history before; both now fail the fixed campaign, whose seed kills both (each dies in about half of 200-run seeds, so the random campaign kills each about half the time). Building the law found three model errors, each corrected from ARCHITECTURE.md: a failure is scoped to its logical demand, so a retired failed demand and the demand that replaces it publish when the replacement succeeds; a replay completes when its last participant's rows apply, so demand that a deferred window move starts afterwards does not join it; and an ordered tie request (a `where` on rank) is not lazy demand, which the terminal-error law had treated it as, so a failed tie request had excused a terminal error. A query's subscriptions to one collection now share one replay barrier (ARCHITECTURE.md, "Replay participants and failure"): a probe found that in a query that joins and includes the marker source, a marker truncate retired the include's only demand privately, its emptied replay closed while the join's replay still loaded, and include demand the graph then started failed outside every replay and ended the query. A sibling-demand branch of the failed-replay campaign declares that state; the branch's code before the fix fails the named fixed history and both campaigns at `convergence: terminal error`, and removing either sibling hold (at replay completion, or for an emptied replay) fails all three. The random failed-replay campaign then found the other half: one subscription's replay failed, the other's finished once it settled, and demand that a rank move then started on the finished subscription failed and ended the query, because the subscriber judged the failure by its own subscription. The subscriber now asks whether a replay of the source's collection failed; a named fixed history (`a demand that fails after the sibling subscription's replay failed`), a `failedSiblingDemandFailures` reach in the sibling branch (which now may move a row and fail the oldest held load), and both campaigns at 400 runs fail at `convergence: terminal error` on the per-subscription rule. A design that judged a failure against any failed replay of the query, across collections, used to survive, because the convergence check runs after an explicit window move, which retries a failed row demand. A strict terminal-error law now requires a failed lazy demand outside every recovery to end the query (ARCHITECTURE.md, error 228), skipping only failures the model cannot place: after a load threw, and, with a peer, a load that joined a replay. Merge with #2257: its review (CF01, [record](oracle-reviews/2026-10-26-pr-2257.md)) judged a new keyed request that a truncate commit starts as terminal, and called non-fatal recovery of that demand a future contract. This PR adopts that contract by maintainer decision. The merged owner keeps CF01's initiating-request receipt check as a second account of every terminal error, at each history cut and at convergence, plus its named fixed histories and the `replay.subscription-demand-origin` and `replay.keyed-request-failures` campaigns, with their two reach-state pairs measured on this owner's model. Both demand origins, new demand during a marker commit and a settled demand that the replay asks for again, must stay nonterminal after the first ready. An always-fatal `failDemand` mutant fails 15 tests, including both ported campaigns; a never-fatal mutant fails the strict law in 8. CF01's duplicate of the #2224 named fixed history is folded into this owner's existing one. Merge with #2168 (ARCHITECTURE.md law 16): after initial-query readiness no load failure ends a live query, and a failed lazy demand is a request error on the query's node. That supersedes the strict terminal-error law, the error-228 rule, and the always-fatal and never-fatal mutant results above for a query that has been ready. The replay owner now allows a terminal error only after a load failed while the query was still loading, judged at each history cut and at convergence, and it adopts #2168's request-error witness (`requestErrors`); the excusals for thrown and unplaced failures went with the strict law. #2168's design already starts new demand before it releases retired coverage, and it treats a synchronous `loadSubset` throw as a rejection whose owner's release unloads it (the subscription oracles fix that unload), so the adapter-release law now counts a thrown load as acquired. It lacked the shared barrier, the failure hold behind a sibling's failed replay, and the deferred window move's wait, which the merge ports onto it. Without the deferred-move loop, 8 tests fail, #2221 trace 2 among them; without `failReplacement`, the two-failures named fixed history fails; without the sibling wait, that replay and both failed-replay campaigns fail. A deferred move rejects when a reload ends the sync run of the query or of a source, which the source-health oracle requires. The window-request reset went: a move can no longer throw, and deleting it failed nothing. The replay that a truncate opened without demand went too: under law 16 its demand's failure no longer ends the query, and its named fixed history passes on #2168's subscription. Review of the merge (2026-10-28): the replay owner gained a request-error account. After initial-query readiness, each failed lazy demand load that is still owed must appear among the query node's open request failures, by its injected error object, at every history cut, and no request error may stay after recovery replays every source (authority: ARCHITECTURE.md law 16, law 17, and the glossary's request error). A failure is met when its demand retires, a later load of the source covers its keys, or a later replay of the source completes; a failure inside a replay owes nothing until every participant settled. A terminal error now needs a load that failed while that same query was loading, so a peer's initial failure no longer excuses `live`. A thrown load joins a replay that no participant joined yet, and the failed-sibling branch now generates a held retry. `requestErrorChecks` reaches the declared minimum of 5 in each failed-replay campaign. Mutants: demand outcomes that never report an error fail 9 tests at the per-cut check; a successful replay that keeps its request errors fails 22 at the stale-error check. Limits and open items: the account shares one replay across a query's subscriptions to a source, so it cannot judge one subscription's request error while another load of that source is held. The reviewer's finding that `failReplacement`'s replay, which has no recorded failure, completes when every demand that joined it settles is closed as a law revision by maintainer decision: the hold ends when every demand load that joins it loads, a later replay of the source succeeds, or the subscription's last demand retires (ARCHITECTURE.md, "Replay participants and failure"). The query retries the failed demand at once and that retry joins the hold, so the hold cannot publish without the failed demand's rows. The replay oracle judges each subscription's request-error verdict by the subscription that a load names, and its failed-replay campaigns declare the premise (a subscription's load succeeds while that subscription's failure is still owed; 30 fixed and 24 random histories at the normal budget). No live-query history violates the request-error or retention law there; a mutant that completes such a hold when any participant settles fails both failed-replay campaigns at the retention law. A subscription-level `it.fails` test that asserted the earlier, stricter wording (no joined demand may end the hold) was removed with the revision. Limit: the oracle drives the hold only through live-query histories, where the retry always joins; a direct `Collection.subscribeChanges` caller that never retries is outside the live-query contract. Which failure a deferred window move rejects with, when its replay has several, is not specified: `main` rejected with the replay's earliest failure, and this branch rejects with `lastSubsetError`; by maintainer decision the move rejects with its replay's earliest failure that still decides it (law 17 decides which count: a retired demand's, a retried request's, and a replaced demand's failures do not). An identity law now judges deferred moves that start before any failure of the replays they wait on; on the parent code both failed-replay campaigns and the named fixed history `a window move behind a replay with two failed loads` fail with "replay error identity", and the deferred-move loop that rethrows a non-abort replay error passes. The request-error account decides failures per key: a later load of the source that asks for some of a failure's keys decides them, and keys it no longer asks for left the demand. The held-retry branch then found, at three times the run budget, a marker change publishing over a window that the model retained after a failed marker replay (`hold, truncateMarkers, settle ok, settle ok, settle fail newest, mark 3 off`, ascending, offset 1, limit 1, includes over on-demand markers). It was a model defect: the query retries a failed demand at once, and when that retry loads the failed keys the failure no longer counts (ARCHITECTURE.md law 17), so the replay succeeds and publishes. The model now clears a replay failure when a later load of the replay loads its keys. The replay oracle passes at the normal budget and three times at three times it, and production without `failReplacement` still fails its named fixed history at the retention law. A move whose `setWindow` throws synchronously after the request generation increments is reachable only through invariant errors, which probes for a throwing subscriber, a crashed source, and a throwing `select` did not reach. An aborted request's rejection and the failure of a segment that newer coverage released are not failures. That design now fails both failed-replay campaigns. An independent oracle review then narrowed several judgments. The terminal-error excuse after a synchronous throw applies only to the thrown error itself, ending the query after the step that threw; the throw's own step, and every other failure, are judged strictly (a per-history flag had excused every later failure). A load that fails outside every barrier after a failed demand of its source retired is unplaced, because the failed replay may still hold through the subscription's other demands, which the adapter cannot attribute. Limit: with a peer, the strict law accepts either query ending, since the adapter boundary cannot tell which query's subscription issued a load; attributing each failure to its query's subscription would close that. The window check now also requires every shown row to be eligible and to appear once, so a tie cannot hide a row the join rejects. A synchronous-throw dimension (`markerThrows` arms the next marker load to throw while it starts) found that a load throwing inside an open replay ended the query: the subscription dropped the demand, so its failure did not fail the replay, and the subscriber treated the throw as fatal. The subscription now hands the replay a rejected participant for such a throw and keeps the demand, which a later replay retries; named fixed histories (`a replay load that throws while it starts`, `a demand whose load throws inside its open replay`), a `thrownReplayDemands` reach in a thrown-demand branch, and both failed-replay campaigns (now 600 runs) fail on the parent code, and a mutant that later unloads the never-acquired load fails the adapter-release check. Model corrections, each from the adapter-boundary rule that a thrown load acquires nothing: a thrown load joins a replay only if it still has a participant or a failure; an armed throw expires before the drain; and a thrown demand's retirement is invisible, so the retention law skips the marker barrier from a throw to the end of its episode (a later barrier in the episode inherits the ambiguity, which also corrects the existing release-throw case), and a failure that joins an already failed or ambiguous barrier may end the query. Maintainer decision: a synchronous throw inside an open replay fails that replay as a rejection does, so the subscription replay oracle's `new request throws` case and five `live-query-collection` cases, which expected a thrown new demand to roll back, now expect the rejection outcome. Limits: a design that dropped a thrown demand and let its replay succeed passes wherever only retention could see it; after a history command follows a throw, a terminal error is excused, because the model cannot see whether that command retired the thrown demand and with it its failed replay (a demand that waited on that replay then fails outside it). The named fixed histories judge the throw's own outcome strictly. The failed-replay random campaign at five times its run count then found a retention counterexample with no throw, on the parent code too: a join and an include of one marker source; one subscription's replay fails, the other's finishes, and a row move starts demand on it whose load fails, which the shared barrier kept from ending the query; a marker change then retired the first replay's failed demand, which discarded that replay and published an empty window, because the second failure held nothing. That failure now fails its own subscription's replacement, which holds until a later replay succeeds or the subscription's last demand retires. A named fixed history of the trace fails without the hold, and a `partialFailureRetirements` reach (a failed demand retires while another failure still holds the barrier) is declared in the failed-replay campaign; the campaigns themselves do not fail without the hold, so the named fixed history is the witness. A window move whose load throws while it starts counts as a settled, failed move in the model (it used to stay pending, so the newest-move law misjudged the older move), with a `thrownMoves` reach. The broad random campaign at five times its run count then found an older move whose settlement resolved with itself: the newer move threw after it bumped the request generation and before it replaced the latest request, so the older move's settlement returned its own promise, a chaining cycle that escaped as a TypeError. A call now clears the latest request when it bumps the generation, so no older move can wait on itself; a named fixed history fails without that. The same assignment covers a move deferred behind a replay, which had the same shape (an independent review found it can hang forever when a newer move throws between the replay settling and the deferred move resuming); that microtask limit is outside the grammar, which flushes between commands, so only the shared mechanism, not that path, has a witness. The ordered-work oracle also keeps the trace from #2221 as a named fixed history: before prefix growth the loader read the full source twice after a signalling refetch past a NaN boundary; with growth it reads none. Limits: the adapter boundary cannot tell which query issued a load, so the retention law and the participant rule skip histories with a peer. The retention law judges barriers that rise after the query first became ready; before that the initial-readiness law governs. It skips a barrier where a release threw while the replay started, which may fail that replay. Core now reports a throwing `unloadSubset` uncaught and fails nothing with it; the subscription lifecycle, replay, Effect disposal, includes-temporal, and subset-error oracles check the uncaught report, once, with the adapter's error. The request-authority oracle now also generates requests that throw while they start, the last history only TLA+ covered, with an error-identity law: a failed request's participant reports that request's own error. It found that a superseded failure whose recovery read threw at its start reported the start error instead (review finding 6). The state oracle compares every current failed request's participant reason with its own originating Error by identity. Release cleanup cannot replace that reason, even when the thrown value's message starts with “release ”. A bounded 16-case checker calibration crosses index availability, widths 1/2, actual throwing release absent/present, and ordinary/full-source plans. It feeds three wrong participant reasons into the same model/production-driver/comparison; the original prefix waiver and an actual-release-only waiver fail at that check. The existing fixed/random 10,000 authority histories declare and enforce current-request-error reach and actual cleanup-fault/current-request-error overlap. Scope ends at the loader participant and separate uncaught-cleanup observations; it adds no provider/framework or intermediate-publication promise. The state owner's shared error-identity calibration and paired generated campaigns enforce these checks. A cost law then checks, after loading is quiet and a bounded repair is available, that one invalidation reads no full source and issues at most one chain per ordering epoch move (in the fake, a prefix, a tie, and a refill); a mutant that always repairs with a full-source read fails it. The replay-refinement section's status law requires a quiet live query and its peer to stay ready when no load failed. Limits: whether a release runs the graph at once or defers it to a transaction belongs to the scheduler, and the oracle generates both forms without checking the scheduler's choice; the fake's rows do not move except for the unexpressible boundary; publication holds stay with other tests; the laws do not judge requests issued before a truncate; a hung authoritative request suspends the leak law for its unfinished chain; a recovery start that throws during a superseded failure is rare in the campaigns, so a named fixed history owns it. | | Indexed predicate filtering | Complete for bounded dotted-path collision | An independent row filter checks direct predicates and public callback predicates for a dotted scalar field beside a nested path, with and without an index. Selected-field ordering checks the same distinction through `$selected`. | | WHERE predicate publication | Complete for bounded predicate and sync-transaction grammar | The contract, Kleene reference evaluator, snapshot and change-history grammar, three subscriber consumers plus direct snapshot, and per-commit key-set refinement check are literate. A fixed same-key update checks live-row and direct-subscriber payloads; a focused descriptor boundary checks stored-row prefilter safety. Generated cleanup and restart histories for filtered subscribers remain open. | @@ -576,18 +576,18 @@ that test identifiers must copy production's private data structures. | Grouped result keys | [Grouped result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/group-result-key-oracle.property.test.ts) | A grouped live-query Collection gives each group one key of the Collection key type, a string or a number, whatever the group value. Maintainer decision (2026-10-27): grouped keys match the declared key type rather than widening it to nullish keys. Group values come from strings, numbers, `NaN`, `null`, `undefined`, booleans, a bigint, strings that print like those values, and strings in the reserved `\u0000tanstack-db:` namespace; histories move, delete, and insert rows. After preload and each synced change, the (group value, count) pairs equal an independent partition by type and value, every key is a string or number, no two groups share a key, and a group that survives a change keeps its key. Reach (minimum 5, per campaign of 80): a nullish group published (32 to 38), a group value published beside a string that prints like it (18 to 21), and a key kept across a change (49 to 53). RED on the parent commit: keys `undefined`, `null`, `false`, and `10n`. Mutants that encode with `String(value)` (lookalike keys collide) or that encode only nullish values (boolean keys) fail. Encoding only the internal D2 key is equivalent in this domain, because the Collection keys rows by the public group key. A focused witness in `collection.test.ts` checks that `sync.write` rejects `undefined`, `null`, boolean, and bigint keys before staging them, and that a later valid write commits alone. A sync adapter that writes such a key breaks its contract, so this is a fail-fast invariant witness under AGENTS.md ("Separate Valid Edge Cases from Contract Contradictions"), not a generated owner; SortedMap never receives these keys. Not covered: Dates, `-0`, objects, and multi-field groups, whose values the aggregate select oracle owns; real sync adapters, since the source is a controlled mock. | | D2 Index storage | Complete for bounded prefix grammar | The contract, plain-`Map` multiset model, prefixed and unprefixed value grammar, `Index` driver, and per-addition `get`/`has` check are literate. Compaction, presence tracking, and structural payloads remain outside this owner. | | Pooled live queries | Complete for bounded eq-filter grammar | The contract, independent `eq` model, live-query Collection second formulation, sync/optimistic/mount/cleanup-restart grammar, observer driver, and per-step refinement check are literate. On-demand and persisted sources, `DbClient`, Suspense, and every clause beyond `eq` conjuncts keep the live-query Collection. | -| Live-query deferred acquisition | Complete for bounded synchronous grammar | The law, independent model, history grammar over source states, shapes, and depth, real-Collection driver, and per-step refinement check are literate. Pooled views and DbClient stream preloads have their own blocks. The grammar also adds a peer consumer starting the source and a source truncate before the first subscriber; pinned blocks cover a subscriber that outlives cleanup, reads that wait for readiness, and release of two independent descriptor sources when one start fails. Asynchronous sources, several live-query consumers, and general failure histories stay open. | +| Live-query deferred acquisition | Complete for bounded synchronous grammar | The law, independent model, history grammar over source states, shapes, and depth, real-Collection driver, and per-step refinement check are literate. Pooled views and DbClient stream preloads have their own blocks. The grammar also adds a peer consumer starting the source and a source truncate before the first subscriber; fixed blocks cover a subscriber that outlives cleanup, reads that wait for readiness, and release of two independent descriptor sources when one start fails. Asynchronous sources, several live-query consumers, and general failure histories stay open. | | Flat-row change tracking | Complete for bounded flat-row grammar | Flat and proxy trackers and an independent change model run the same generated callbacks. Nested values fall back to the proxy, which its own oracles own. | -| Production error messages | `packages/db/tests/production-error-messages-oracle.test.ts` | Every public error class keeps its development message for frozen sample inputs, and its coded production line shows the JSON of each shown input, even for hostile inputs. Every plain `Error`, `TypeError`, `RangeError`, or `AggregateError` site in `packages/db/src` keeps its frozen development template, has a code, and passes each showable interpolated expression or one of its sub-expressions; the TypeScript checker keeps plain objects, such as rows, out of production lines. A projection of an object, such as `Object.keys(handlers)`, is not required. Every `console` call whose argument holds library text is coded, development-only with its text frozen per guarded region, or a development-only decoration of a value. Text that reaches the console through a parameter, such as `setErrorState(message)`, is coded where it is written; the census cannot follow it. `scripts/test-production-errors.mjs` checks that a production bundle drops the full text and every development-only hint, in ESM and CommonJS. Errors built from a caller's value stay outside this owner. | +| Production error messages | `packages/db/tests/production-error-messages-oracle.test.ts` | Every public error class keeps its development message for frozen sample inputs, and its coded production line shows the JSON of each shown input, even for adversarial inputs. Every plain `Error`, `TypeError`, `RangeError`, or `AggregateError` site in `packages/db/src` keeps its frozen development template, has a code, and passes each showable interpolated expression or one of its sub-expressions; the TypeScript checker keeps plain objects, such as rows, out of production lines. A projection of an object, such as `Object.keys(handlers)`, is not required. Every `console` call whose argument holds library text is coded, development-only with its text frozen per guarded region, or a development-only decoration of a value. Text that reaches the console through a parameter, such as `setErrorState(message)`, is coded where it is written; the census cannot follow it. `scripts/test-production-errors.mjs` checks that a production bundle drops the full text and every development-only hint, in ESM and CommonJS. Errors built from a caller's value stay outside this owner. | | Lazy target path identity | Focused compiler boundary | A same-source union/coalesce witness keeps dotted and nested demand paths distinct during target deduplication. | | Correlated include path identity | Focused public route-context witnesses | One-level and nested includes keep dotted and nested parent paths, including ancestor aliases, distinct. Conditional result paths receive separate routes. Fixed fixtures cover initial reads and selected source updates; other recursive source forms and arbitrary path segments remain outside this witness. | -| Alias scope identity | Generated cross-scope alpha-renaming and finite exact-output refinement | Optimizer copies, wraps, and collapses keep `SourceId`; captured refs retain lexical bindings under ancestor shadowing; ancestor/descendant source declarations need distinct builders while sibling placements may reuse one; public aliases remain visible. The bounded paths and remaining gaps are in the owner row. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds finite self-join, joined-context, and captured whole-row witnesses. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) covers absent RIGHT/FULL sides and captured ancestor join operands. | +| Alias scope identity | Generated cross-scope alpha-renaming and finite exact-output refinement | Optimizer copies, wraps, and collapses keep `SourceId`; captured refs retain lexical bindings under ancestor shadowing; ancestor/descendant source declarations need distinct builders while sibling placements may reuse one; public aliases remain visible. The bounded paths and remaining limits are in the owner row. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds finite self-join, joined-context, and captured whole-row witnesses. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) covers absent RIGHT/FULL sides and captured ancestor join operands. | | Join equality and cold acquisition | Complete | Independent cold relational recomputation, acquisition evidence, established equality domains, replacements, and scan/index routes are literate. | | Opaque backend pagination | Complete | The full-relation value model plus opaque token, cache generation, publication, browser acquisition, and live-window integration owners are literate. | | Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | | PowerSync | Fixed tests only | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are covered by fixed tests in `correctness.test.ts`; no oracle owns them (tracked in #2264). | | SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared-handle driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | -| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. A segment requires a phase-write fault before a queued peer on every run. Mutants that let a retried transaction move behind its peers, keep running after a storage fault, or stop the queue at a permanent failure fail it; the previous fault-free oracle passed all three. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per lane for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight pins enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. A constructed history requires a phase-write fault before a queued peer on every run. Mutants that let a retried transaction move behind its peers, keep running after a storage fault, or stop the queue at a permanent failure fail it; the previous fault-free oracle passed all three. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per campaign for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight fixed histories enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | | Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | | Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. | | Mixed-kind ordered prefixes | Bounded | Eager BasicIndex and BTreeIndex paths compare ascending and descending direct and live-query windows with an unindexed scan across mixed value kinds and paired source changes. One retained on-demand BTree history checks settled windows after a number/bigint transition, a finite request for a missing row, and a cursor request after index eligibility changes. Other provider paging histories remain open. | @@ -638,7 +638,7 @@ cancellation fixtures now create logical demand before explicit resumption; render-owned queued loads remain covered by the DbClient fixture. These finite extensions do not establish arbitrary async/provider schedules or new framework error policy. The [follow-up audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) -records original RED, hostile controls, and the unchanged rejection decision. +records original RED, mutant controls, and the unchanged rejection decision. A subsequent current-head summary exposed cleanup during ownership transfer. The deferred-acquisition owner adds 192 surviving-owner histories crossing both @@ -794,7 +794,7 @@ updates are observed. The primary includes owner supplies an independent active-interval model: retained routes share one ready facade, retired handles stay ready/empty, and a new interval gets a new handle. This assumes no facade-local optimistic mutations. Every shape declares active empty readiness -and nonempty child receiving cuts. Six lifetime and four inline-value hostile +and nonempty child receiving cuts. Six lifetime and four inline-value mutant controls reject wrong observations. See [chunk 5](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). Functional callback-input observations have a separate receiving boundary in `virtual-row-fields-input-oracle.test.ts`, sharing the existing source metadata @@ -907,7 +907,7 @@ every TS version, arbitrary extra-property structural assignment, or the internal semantics of trusted PendingMutation/LoadSubsetOptions carriers. The [type-preservation audit](oracle-reviews/pr-2259-persisted-option-types.md) and `scripts/calibrate-persisted-option-types.mjs` preserve parent RED, GREEN, -legacy-suite survival, hostile-mutant evidence, and runnable overlay recipes. +legacy-suite survival, mutant-mutant evidence, and runnable overlay recipes. The readonly-row overlay strips user-property readonly modifiers from get's return while retaining readonly virtual fields. The prior grammar accepted it; the modifier histories reject it at the shared exact published-row comparison. @@ -942,7 +942,7 @@ A reusable recursive type signature checks values, exact keys, required/optional and readonly modifiers per union member, without rejecting duplicate equivalent union members. Public-source imports compile through the actual TypeScript compiler; all diagnostics retain their -file, span and code. Seven hostile controls challenge value enrichment, +file, span and code. Seven mutant controls challenge value enrichment, nullability, one-branch readonly loss, nested any/metadata/modifiers and Date metadata any. Their rejection requires failed type-law constraints, not syntax or setup errors. The normal test:oracles command selects this owner. @@ -1058,7 +1058,7 @@ at offset 0. Rows must equal a relational model. Preload may load only the children of the shown rows' groups, and a step only those of rows that entered the window and were not shown before it. A static filter creates static child demand before the -query runs only when no enclosing query is windowed or a `findOne`, and either the query is neither windowed nor a `findOne`, or it is a `findOne` that shows one row for one named correlation value, with no `offset`, and offset 0 at start if a window was requested before start. Pinned witnesses show each case +query runs only when no enclosing query is windowed or a `findOne`, and either the query is neither windowed nor a `findOne`, or it is a `findOne` that shows one row for one named correlation value, with no `offset`, and offset 0 at start if a window was requested before start. Fixed histories show each case the rule closes: a window requested before start (12 child rows for a bound of 2 before the rule), grandchild demand below a windowed parent, a `findOne` with `limit(0)`, and a `findOne` moved past its one named value before start; a @@ -1069,7 +1069,7 @@ acquisition attempt at once. A focused test requires `setWindow` to throw its child acquisition attempt beside its parent's. On main, a late page delivered 120 child rows for a bound of 9. Routers that add the row after the window fail the generated campaigns; routers that add every row in an edge tie -group fail a pinned collapse and the detail page; a child that takes over +group fail a fixed collapse and the detail page; a child that takes over `setWindow` fails the rows law. Focused tests require a window inside a `from` or joined subquery not to answer `setWindow`. @@ -1084,9 +1084,9 @@ write. The generated campaigns skip only its work bound. In the fixed campaign (60 runs, seed 2137), the work bound checks 285 of 290 checkpoints strictly: preload 60 of 60, parent inserts 45 of 45, parent deletes 34 of 35, rank moves 43 of 43, child inserts 36 of 36, child deletes 37 of 37, backward window moves -18 of 18, and forward window moves 12 of 16. Six replays pin the known traces +18 of 18, and forward window moves 12 of 16. Six fixed histories cover the known traces and allow exactly the one extra child row each shows; with no overshoot -allowed, each fails. Four pinned forward moves (local and on-demand children, +allowed, each fails. Four fixed forward moves (local and on-demand children, with and without `distinct`) allow none. Each generated campaign counts the forward moves its own runs check strictly and fails if it counts none. @@ -1095,7 +1095,7 @@ refill checkpoints need the #2151 fix (holding include demand until the ordered acquisition completes) before the work bound can check them; includes inherited from a `from` subquery and inputs to `fn.select` still route from every row before the window, and need generated forms under the work bound; the -edge tie group router is caught only by pinned cases, and needs a generated tie +edge tie group router is caught only by fixed cases, and needs a generated tie at the window edge on a checkpoint without a refill; a child compile error after the parent's `orderBy` still leaves the parent's auto-index (owner: compiler tests), and needs a focused witness that the index does not outlive a failed @@ -1274,7 +1274,7 @@ The offline scheduler owner (`packages/offline-transactions/tests/KeyScheduler-o checks FIFO eligibility and retry order at the scheduler boundary, as the `oracleProperty` `offline.key-scheduler-fifo-ledger`. Before its port the generator drew histories of at most 12 commands (fast-check's default array -size) with a biased selector, so 4 of 150 histories per lane started any +size) with a biased selector, so 4 of 150 histories per campaign started any transaction and 1 retried; generation now draws 1 to 50 unbiased commands, and about a quarter of histories start work and a tenth retry. It does not observe the executor's awaited outbox update between `updateTransaction` and @@ -1314,7 +1314,7 @@ preserves its earlier executable revision. The records the revised storage-failure law, RED/GREEN witnesses, and limits. The guarded replay owner (`packages/db/tests/oracle-replay.test.ts`) now checks that named pagination and index campaigns execute only the requested replay -lane. An unrelated failing fixed campaign no longer runs first. The +campaign. An unrelated failing fixed campaign no longer runs first. The [prep-PR review record](oracle-reviews/2026-09-30-prep-pr-followup.md) records these four review repairs at their exact executable revision. @@ -1328,7 +1328,7 @@ does not award oracle credit for a filename alone. | ------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [#1831](https://github.com/TanStack/db/pull/1831) | Fixed tests; no oracle owner (tracked in #2264) | PowerSync's `packages/powersync-db-collection/tests/correctness.test.ts` (fixed tests; no oracle owner (tracked in #2264)) crosses real PowerSync receipts and native SQLite behavior. It runs in the package test campaign; portable declarations retain compiler authority. | | [#1832](https://github.com/TanStack/db/pull/1832) | Equivalent specialized authority | `packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts` and `packages/electric-db-collection/e2e/subset-sql-acceptance.e2e.test.ts` run through the package's real-provider `test:e2e` campaign. Compiler unit tests are collateral, not substitutes for either service boundary. | -| [#1833](https://github.com/TanStack/db/pull/1833) | Explicit oracle | `packages/db/tests/query/pagination-oracle.property.test.ts` owns inherited collection collation, actual `item2`/`item10` order, exact request options, hostile lexical/numeric controls, and both scan and auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | +| [#1833](https://github.com/TanStack/db/pull/1833) | Explicit oracle | `packages/db/tests/query/pagination-oracle.property.test.ts` owns inherited collection collation, actual `item2`/`item10` order, exact request options, mutant lexical/numeric controls, and both scan and auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | | [#1834](https://github.com/TanStack/db/pull/1834) | Explicit oracle | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` owns join/predicate equality equivalence across the established value domains, binary/string and nullish controls, replacement histories, raw lazy demand, and scan/auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | | [#1835](https://github.com/TanStack/db/pull/1835) | Explicit oracle | The existing `packages/db/tests/collection-state-retention-oracle.property.test.ts` and `packages/db/tests/optimistic-transaction-oracle.property.test.ts` owners cover separate collection-state and transaction-history laws. Both were already registered in `@tanstack/db`'s `test:oracles` campaign; focused storage/local-only tests remain collateral. | | [#1837](https://github.com/TanStack/db/pull/1837) | Explicit oracle | The offline scheduler, leadership replay, and serializer owners cover selective replay retirement, durable per-ID settlement, stale-read fencing, lifecycle recovery, native scalar encoding, and prior wire compatibility. They run in the package test campaign. Every generated campaign in these owners uses the shared `TANSTACK_DB_ORACLE_{SEED,PATH,PROPERTY}` replay interface, with one property per campaign (`offline.fifo-retry`, `offline.leadership.*`, `offline.serializer.*`, `offline.settlement.*`). | @@ -1467,7 +1467,7 @@ claim. Fixed/fresh campaigns retain per-runtime/path reach and direct replay; this does not establish streaming Suspense, actual browser hydration, arbitrary provider delivery or every React version. Client commit and abandonment remain with the window receiving owner. [SSR effect evidence](oracle-reviews/2026-10-30-react-ssr-effects.md) -records pre-edit RED, repaired GREEN and hostile rejection. +records pre-edit RED, repaired GREEN and mutant rejection. The shared admission owner also defines runtime ready/error outcomes, browser explicit preparation and defined/undefined causes. Its receiving drivers are @@ -1745,7 +1745,7 @@ server/local behavior has independent function refs. On unchanged production, 640 admission assertions fail and 1,526 tests pass, including six controls. Failures remain open until the deferred production repair. The shared immutable bound-options checker now receives Preact's six native histories as well as -React's 12; six hostile in-place mutations are rejected. Svelte's actual +React's 12; six mutant in-place mutations are rejected. Svelte's actual client-backed effect receives all 144 cached-option histories. All three native selectors pass. Portable first claims/failure/consumption are no longer merely proposed coverage. Remaining cache limits at that chunk were first registration @@ -1834,7 +1834,7 @@ starts when a render containing one never commits. These bounded checks do not compare union or include rows under separate clients, or concurrent render schedules. The original standalone Query remains the reusable two-client plan; rebinding a previously prepared snapshot to a different client is not claimed. -`packages/db/tests/query/collection-ref-compatibility.test.ts` pins the +`packages/db/tests/query/collection-ref-compatibility.test.ts` fixes the exported `IR.CollectionRef` shape for concrete Collections: `collection` is an enumerable, writable own field that a spread copies. It also checks that an unbound descriptor uses `IR.DescriptorRef` with no `collection` field. A bounded @@ -1845,57 +1845,57 @@ generic visitor behavior outside the named collectors. | Surface | Primary executable owners | Independent judgment and important limit | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [fractional-index window moves](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts) | Independent ordered relations and cumulative signed output. Each generated incrementalization campaign sums the checker's delivery counts and requires one atomic delivery per checkpoint and more split deliveries than an unsplit run makes; a checker that stops splitting fails all six campaigns. Most ordered steps edit the previous world by one removal or insertion, and a reconstruction check counts promotions of retained rows over the executed fixed campaign (25 global and 21 per parity at 100 runs; independent worlds gave 2 and 2). A type test pins `join(other, type)`'s nullable pair sides for every join type. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. The fractional-index test checks an empty output message for a window move after all rows retract; it does not prove downstream lazy acquisition. The [distinct incrementalization extension](../../packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts) recomputes unique immutable `{id: string, value: integer}` selections across real hash collisions, with two unequal collision members and a constructively verified noncolliding anchor. Its shared [model and grammar](../../packages/db-ivm/tests/distinct-incrementalization-oracle.ts) vary collision-member order, atomic versus separate insertion, duplicate support, partial and last removal, replacement, cancellation, and reinsertion, with a noncolliding anchor. The existing incrementalization checker compares net weighted deltas and retained rows after every logical batch in atomic and legal unit-split D2 deliveries, for selected-value and default-tuple distinct. The [public receiving witness](../../packages/db/tests/query/distinct-collision-oracle.test.ts) checks the same law and histories after every applied source transaction through ordered and unordered live-query Collections; the [reported seeded pair](../../packages/db/tests/query/distinct-reported-collision.test.ts) replays that shared judgment. Each campaign declares and measures collision coexistence, duplicate support, partial removal, last removal, and reinsertion separately for its receiving path. The original hash-only operator, equality buckets with shared output keys, and counts capped at one all fail value comparisons. These checks do not promise representative choice for unselected fields, intermediate source-transaction publications, framework render scheduling, arbitrary payload types, or collision freedom of the digest. The [issue 2306 audit](oracle-reviews/2026-10-30-distinct-collisions.md) records RED, GREEN, mutants, bounds, and byte cost. The wider representative and includes receiving lanes are recorded under [Distinct collision review extension](#distinct-collision-review-extension-2026-10-30). Other hash-based operators remain outside this distinct repair; the Index structural-collision trace is recorded there with its owner. Algebra does not specify client readiness. The BTree/Map oracle checks point operations, neighbor pairs, and full, partial, boundary, exclusive-high, and empty range scans for integer keys and node sizes 4–8. It does not check balance or space, and it assumes consistent comparators ([code-weight BTree review](oracle-reviews/code-weight-btree-trim.md)). The generated groupBy law compares JSON values; a pinned contract test in `packages/db-ivm/tests/operators/groupBy.test.ts` records that after a delete, `min` and `max` return a value hash-equal to a remaining member, not necessarily the remaining instance (`-0` for `0`, an equal Date), which is why the query compiler keeps its own exact-value keys. It checks both insertion orders, reads the group's row by integrating output multiplicities, deletes the minimum and the maximum, and shows that an aggregate input that tells the rows apart returns the remaining instance, for -0/0 in both directions and for both `min` and `max`. The contract holds while every member has positive multiplicity. It pins the current behavior separately: the delete's retraction is the last record a shared hash entry gets, so the deleted instance comes back in either insertion order, for -0/0 in either direction and for an equal Date. For -0/0 and equal Dates the contract assertions only show hash equality, which is all the contract promises. Open: the incrementalization law's value policy compares JSON and clones by `structuredClone`, so no generated history sees -0 or Date identity; a value-identity policy with -0/0 and equal-time Dates would generalize this pinned contract. | -| Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. Observe raw promised order; fresh queries do not establish continuous publication safety. The temporal owner's source-readiness lane enumerates every legal order of delivery, settlement, source readiness, and one later change for a source whose Collection readiness lags its settled subsets. It checks live-query readiness, preload settlement, and visible rows for direct, single-result, ordered-window, include-child, lazy-join, and eager-root sources; `queryOnce` results for the direct and eager-root shapes; `toArrayWhenReady()` and `stateWhenReady()` results for a read started after rows are visible but before readiness, over the direct, single-result, and eager-root shapes (and the lazy join for `toArrayWhenReady()`); and Effect `skipInitial` deltas for the direct, lazy-join, and eager-root shapes, and every on-demand cell of every consumer is crossed with a truncate or (join and include) a parent rekey before settlement. Each on-demand cell also compares its consumer with a metamorphic twin over a source that is ready from its start, which does not rely on the model's eager/on-demand rule. A live-query observer test checks that a hydration seed hands off at subset settlement, and an Electric test pins that a ready progressive query's rows update only at the full sync's swap. Effects over an ordered window or an include have no lane. It rejects both a design that waits for every source's Collection readiness and one that ignores source readiness. Electric live-query tests are the receiving witnesses for a real progressive source and an on-demand source that is ready before its subset loads; a React Suspense test checks that Suspense and `isReady` release together. Other framework adapters have no Suspense witness. | -| Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [GC and persisting transactions](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-transaction-oracle.property.test.ts), [GC retention boundaries](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.property.test.ts), [shared GC model](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.ts), [GC mutation projection](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-mutation-oracle.property.test.ts), [native idle receiver](https://github.com/TanStack/db/tree/main/packages/db/e2e/collection-gc), [initial GC review evidence](oracle-reviews/pr-2258-gc-retention.md), [second GC review and decisions](oracle-reviews/pr-2258-gc-retention-round2.md), [interaction and mutation receiving evidence](oracle-reviews/pr-2258-gc-retention-round3.md), [GC checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.test.ts), [Electric confirmation receiver](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Every bounded change-event history, and about half of each generated campaign, runs again on a local-only Collection that ends with `reload()`: it must resolve with the Collection `ready`, the change-message mirror of valid messages must equal the public rows, the public rows must equal the history's kept rows (maintainer decision: a local-only reload keeps the user's writes, and `initialData` loads only into a Collection that starts empty), and a later write must reach both. The adapter that re-inserted `initialData` in each run failed 210 histories (a crash when a kept row changed, or a mix of kept rows and `initialData`); a reset to `initialData` fails 344, and loading `initialData` whenever the Collection is empty fails 45 (a history that deleted every row). Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A ready callback that throws while entering sync surfaces when the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). The GC property (`collection-lifecycle.gc-persisting-transaction`) checks on a fake clock that automatic garbage collection retires a Collection only after `gcTime` with no subscriber and no persisting transaction, that the delay restarts when the last transaction completes or fails, and that explicit `cleanup()` still retires it while a transaction persists. Histories run over two Collections, with inserts through one Collection's `onInsert` or through a manually committed transaction that touches both. After each subscribe, unsubscribe, insert, settlement, and clock step it compares each Collection's `cleaned-up` status and each persisting row's visibility with an independent deadline model. `main` before the fix, a hold that never restarts the delay, a restart only on completion, and a restart only for the first Collection a transaction touched each fail it. The companion `collection-lifecycle.gc-retention-boundaries` property separates elapsed eligibility from host idle delivery. Its shared reference model checks the absolute 50ms startup/preload handoff, real last unsubscribe, pending versus persisting transactions, delayed commit, success/failure/rollback, surviving siblings, disabled GC, and old idle work after renewal. Generated configured delays are 1, 10, 49, 50, 51, and 100ms; observations include immediately before and at the independent deadline. Eager sync and write-started sync both run. A Chromium receiving driver uses the same model/checker with native idle delivery and cancellation and controlled elapsed timers. It records the actual budget core reads and checks public status at core delivery and a usable native observation opportunity. Exact wall-clock cleanup latency is not promised. Paced mutations, offline transaction restoration, live-query Collections, and actual Electric `awaitTxId` plus provider delivery are not generated by these GC owners. The boundary grammar now generates repeated warm preloads over queued idle and persisting holds, successful and failed cold preloads as first or last releases, both sibling settlement orders, and pending drafts held by subscriptions. Held adapter cleanup crosses settlement with surviving pending or persisting siblings, both before and after cleanup settlement. Exact key comparisons reject leftover settled rows and cleanup resurrection; tracked transaction identities still match unsettled transactions. The 250-history fixed and random campaigns require all 33 declared states. Alongside the 15 focused lanes, constructive interaction histories combine up to seven transactions and three subscribers, delayed commit, three persisting siblings, non-FIFO settlement and preload between releases. Automatic GC also starts held or rejecting adapter cleanup, checks synchronous row discard before adapter settlement, and rejects pending-sibling resurrection. The shared model additionally receives direct updates/deletes, shared update/delete and cancelled insert/delete ownership with a surviving peer update through `collection-lifecycle.gc-mutation-shapes`. Its independent source/intent projection checks exact keys and values across completion/failure/rollback, both Collection orientations and subscriber states; each 150-history campaign requires all 11 declared states. The maintainer chose first-usable-idle retirement as a fixture scheduling assumption, not a public liveness guarantee. The maintainer also chose caller-owned pending draft visibility: keep a subscription; restart after GC does not promise draft restoration. The boundary grammar therefore does not establish post-GC draft restoration, paced/offline restoration, or arbitrary cleanup/source-recovery histories. A real Electric/Postgres receiving driver now crosses gcTime 10/100ms with absent/last-unsubscribed owners, holds an assigned uncommitted txid across the unheld deadline, and observes pending caller, persisting phase, optimistic row, genuine confirmation, successful settlement, synced row, renewed GC and idle delivery. Its 12 paths retain four one-write cases and add eight two-write cases with separate real SQL transactions and both local settlement orders. A genuine acknowledgement releases only one hold; the other SQL transaction remains open beyond the first-release deadline. It shares the model/checker and rejects no-hold and first-release runtime mutants. Native host and real provider are witnessed separately; their combined browser/provider schedule, three-or-more provider writers, provider failure/recovery and persistence wrappers remain outside these bounded receiving histories. The Collection lifecycle GC owners still use fresh disjoint optimistic keys. Before claiming the combined same-key rollback-cascade or confirming-sync-queue law with GC, this owner needs a generated receiving history that crosses those semantic owners at retention and cleanup checkpoints. The history owner's demand-change grammar (`demandChangeLifecycleHistoryArbitrary`, a fixed seed and the random campaign `subscription-lifecycle.demand-change`) adds a third demand, a `replace` command (`replaceSnapshots`: a held replacement whose own load did not fail covers the owners it replaces, law 10), and a `change` command that retires several owners through one `releaseSnapshot(...wheres)` call; the model judges an open replay once after the whole change (law 9). Declared reach: a change during a replay, a replacement covering a failed owner, and a change whose verdict between releases would differ. Per-release judging, first-release-alone, and batching only two-owner changes each fail it. A covered owner's failed replay load reports `loadSubset:error` whatever its settlement order (the model's existing reporting rule; suppressing the last settler fails it), and a prompt cancellation of a covered owner settles the replay as any settlement does. An aborted held replacement covers nothing: the abort ended its load before it applied (maintainer decision: an abort ends the load; law 10), so its replaced owner's failure keeps the replay failed (two 30-times counterexamples, now `keeps a replay failed when a replacement aborted after its replaced owner failed` and its peer-loaded twin; RED on the first simplification, which counted the aborted replacement as covering; a mutant that drops the check fails both). The controller always aborts and releases a segment together, so it never makes that case. An abort ends the load (maintainer decision): the history model's `abort`, and a truncate that replaces an attempt, stop that attempt's gating at once in both `manual` and `reject` cancellation; an owner whose attempt the abort ended before it settled owes the replay nothing, and a rejection after the abort decides nothing (a failure settled before the abort stays). The publication model finishes a replacement at an abort, and the replay oracle's newer attempt drops the older attempts' pending loads. A synchronous throw from `loadSubset` is a rejection (maintainer decision): an acquisition that core releases, also in the acquisition-cell and replay witnesses. RED on 2bc79fc7c: 18 lifecycle tests, 1 refinement test. Mutants that fail: a replay that waits for an aborted load (14), an aborted rejection reported as `loadSubset:error` (19), an aborted load that still holds readiness (17). | +| Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [fractional-index window moves](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts) | Independent ordered relations and cumulative signed output. Each generated incrementalization campaign sums the checker's delivery counts and requires one atomic delivery per checkpoint and more split deliveries than an unsplit run makes; a checker that stops splitting fails all six campaigns. Most ordered steps edit the previous world by one removal or insertion, and a reconstruction check counts promotions of retained rows over the executed fixed campaign (25 global and 21 per parity at 100 runs; independent worlds gave 2 and 2). A type test fixes `join(other, type)`'s nullable pair sides for every join type. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. The fractional-index test checks an empty output message for a window move after all rows retract; it does not prove downstream lazy acquisition. The [distinct incrementalization extension](../../packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts) recomputes unique immutable `{id: string, value: integer}` selections across real hash collisions, with two unequal collision members and a constructively verified noncolliding anchor. Its shared [model and grammar](../../packages/db-ivm/tests/distinct-incrementalization-oracle.ts) vary collision-member order, atomic versus separate insertion, duplicate support, partial and last removal, replacement, cancellation, and reinsertion, with a noncolliding anchor. The existing incrementalization checker compares net weighted deltas and retained rows after every logical batch in atomic and legal unit-split D2 deliveries, for selected-value and default-tuple distinct. The [public receiving witness](../../packages/db/tests/query/distinct-collision-oracle.test.ts) checks the same law and histories after every applied source transaction through ordered and unordered live-query Collections; the [reported seeded pair](../../packages/db/tests/query/distinct-reported-collision.test.ts) replays that shared judgment. Each campaign declares and measures collision coexistence, duplicate support, partial removal, last removal, and reinsertion separately for its receiving path. The original hash-only operator, equality buckets with shared output keys, and counts capped at one all fail value comparisons. These checks do not promise representative choice for unselected fields, intermediate source-transaction publications, framework render scheduling, arbitrary payload types, or collision freedom of the digest. The [issue 2306 audit](oracle-reviews/2026-10-30-distinct-collisions.md) records RED, GREEN, mutants, bounds, and byte cost. The wider representative and includes receiving grammars are recorded under [Distinct collision review extension](#distinct-collision-review-extension-2026-10-30). Other hash-based operators remain outside this distinct repair; the Index structural-collision trace is recorded there with its owner. Algebra does not specify client readiness. The BTree/Map oracle checks point operations, neighbor pairs, and full, partial, boundary, exclusive-high, and empty range scans for integer keys and node sizes 4–8. It does not check balance or space, and it assumes consistent comparators ([code-weight BTree review](oracle-reviews/code-weight-btree-trim.md)). The generated groupBy law compares JSON values; a fixed contract test in `packages/db-ivm/tests/operators/groupBy.test.ts` records that after a delete, `min` and `max` return a value hash-equal to a remaining member, not necessarily the remaining instance (`-0` for `0`, an equal Date), which is why the query compiler keeps its own exact-value keys. It checks both insertion orders, reads the group's row by integrating output multiplicities, deletes the minimum and the maximum, and shows that an aggregate input that tells the rows apart returns the remaining instance, for -0/0 in both directions and for both `min` and `max`. The contract holds while every member has positive multiplicity. It records the current behavior separately: the delete's retraction is the last record a shared hash entry gets, so the deleted instance comes back in either insertion order, for -0/0 in either direction and for an equal Date. For -0/0 and equal Dates the contract assertions only show hash equality, which is all the contract promises. Open: the incrementalization law's value policy compares JSON and clones by `structuredClone`, so no generated history sees -0 or Date identity; a value-identity policy with -0/0 and equal-time Dates would generalize this fixed contract. | +| Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. Observe raw promised order; fresh queries do not establish continuous publication safety. The temporal owner's source-readiness grammar enumerates every legal order of delivery, settlement, source readiness, and one later change for a source whose Collection readiness lags its settled subsets. It checks live-query readiness, preload settlement, and visible rows for direct, single-result, ordered-window, include-child, lazy-join, and eager-root sources; `queryOnce` results for the direct and eager-root shapes; `toArrayWhenReady()` and `stateWhenReady()` results for a read started after rows are visible but before readiness, over the direct, single-result, and eager-root shapes (and the lazy join for `toArrayWhenReady()`); and Effect `skipInitial` deltas for the direct, lazy-join, and eager-root shapes, and every on-demand cell of every consumer is crossed with a truncate or (join and include) a parent rekey before settlement. Each on-demand cell also compares its consumer with a metamorphic twin over a source that is ready from its start, which does not rely on the model's eager/on-demand rule. A live-query observer test checks that a hydration seed hands off at subset settlement, and an Electric test checks that a ready progressive query's rows update only at the full sync's swap. Effects over an ordered window or an include have no grammar. It rejects both a design that waits for every source's Collection readiness and one that ignores source readiness. Electric live-query tests are the receiving witnesses for a real progressive source and an on-demand source that is ready before its subset loads; a React Suspense test checks that Suspense and `isReady` release together. Other framework adapters have no Suspense witness. | +| Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [GC and persisting transactions](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-transaction-oracle.property.test.ts), [GC retention boundaries](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.property.test.ts), [shared GC model](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.ts), [GC mutation projection](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-mutation-oracle.property.test.ts), [native idle receiver](https://github.com/TanStack/db/tree/main/packages/db/e2e/collection-gc), [initial GC review evidence](oracle-reviews/pr-2258-gc-retention.md), [second GC review and decisions](oracle-reviews/pr-2258-gc-retention-round2.md), [interaction and mutation receiving evidence](oracle-reviews/pr-2258-gc-retention-round3.md), [GC checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.test.ts), [Electric confirmation receiver](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Every bounded change-event history, and about half of each generated campaign, runs again on a local-only Collection that ends with `reload()`: it must resolve with the Collection `ready`, the change-message mirror of valid messages must equal the public rows, the public rows must equal the history's kept rows (maintainer decision: a local-only reload keeps the user's writes, and `initialData` loads only into a Collection that starts empty), and a later write must reach both. The adapter that re-inserted `initialData` in each run failed 210 histories (a crash when a kept row changed, or a mix of kept rows and `initialData`); a reset to `initialData` fails 344, and loading `initialData` whenever the Collection is empty fails 45 (a history that deleted every row). Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A ready callback that throws while entering sync surfaces when the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). The GC property (`collection-lifecycle.gc-persisting-transaction`) checks on a fake clock that automatic garbage collection retires a Collection only after `gcTime` with no subscriber and no persisting transaction, that the delay restarts when the last transaction completes or fails, and that explicit `cleanup()` still retires it while a transaction persists. Histories run over two Collections, with inserts through one Collection's `onInsert` or through a manually committed transaction that touches both. After each subscribe, unsubscribe, insert, settlement, and clock step it compares each Collection's `cleaned-up` status and each persisting row's visibility with an independent deadline model. `main` before the fix, a hold that never restarts the delay, a restart only on completion, and a restart only for the first Collection a transaction touched each fail it. The companion `collection-lifecycle.gc-retention-boundaries` property separates elapsed eligibility from host idle delivery. Its shared reference model checks the absolute 50ms startup/preload handoff, real last unsubscribe, pending versus persisting transactions, delayed commit, success/failure/rollback, surviving siblings, disabled GC, and old idle work after renewal. Generated configured delays are 1, 10, 49, 50, 51, and 100ms; observations include immediately before and at the independent deadline. Eager sync and write-started sync both run. A Chromium receiving driver uses the same model/checker with native idle delivery and cancellation and controlled elapsed timers. It records the actual budget core reads and checks public status at core delivery and a usable native observation opportunity. Exact wall-clock cleanup latency is not promised. Paced mutations, offline transaction restoration, live-query Collections, and actual Electric `awaitTxId` plus provider delivery are not generated by these GC owners. The boundary grammar now generates repeated warm preloads over queued idle and persisting holds, successful and failed cold preloads as first or last releases, both sibling settlement orders, and pending drafts held by subscriptions. Held adapter cleanup crosses settlement with surviving pending or persisting siblings, both before and after cleanup settlement. Exact key comparisons reject leftover settled rows and cleanup resurrection; tracked transaction identities still match unsettled transactions. The 250-history fixed and random campaigns require all 33 declared states. Alongside the 15 focused grammar, constructive interaction histories combine up to seven transactions and three subscribers, delayed commit, three persisting siblings, non-FIFO settlement and preload between releases. Automatic GC also starts held or rejecting adapter cleanup, checks synchronous row discard before adapter settlement, and rejects pending-sibling resurrection. The shared model additionally receives direct updates/deletes, shared update/delete and cancelled insert/delete ownership with a surviving peer update through `collection-lifecycle.gc-mutation-shapes`. Its independent source/intent projection checks exact keys and values across completion/failure/rollback, both Collection orientations and subscriber states; each 150-history campaign requires all 11 declared states. The maintainer chose first-usable-idle retirement as a fixture scheduling assumption, not a public liveness guarantee. The maintainer also chose caller-owned pending draft visibility: keep a subscription; restart after GC does not promise draft restoration. The boundary grammar therefore does not establish post-GC draft restoration, paced/offline restoration, or arbitrary cleanup/source-recovery histories. A real Electric/Postgres receiving driver now crosses gcTime 10/100ms with absent/last-unsubscribed owners, holds an assigned uncommitted txid across the unheld deadline, and observes pending caller, persisting phase, optimistic row, genuine confirmation, successful settlement, synced row, renewed GC and idle delivery. Its 12 paths retain four one-write cases and add eight two-write cases with separate real SQL transactions and both local settlement orders. A genuine acknowledgement releases only one hold; the other SQL transaction remains open beyond the first-release deadline. It shares the model/checker and rejects no-hold and first-release runtime mutants. Native host and real provider are witnessed separately; their combined browser/provider schedule, three-or-more provider writers, provider failure/recovery and persistence wrappers remain outside these bounded receiving histories. The Collection lifecycle GC owners still use fresh disjoint optimistic keys. Before claiming the combined same-key rollback-cascade or confirming-sync-queue law with GC, this owner needs a generated receiving history that crosses those semantic owners at retention and cleanup checkpoints. The history owner's demand-change grammar (`demandChangeLifecycleHistoryArbitrary`, a fixed seed and the random campaign `subscription-lifecycle.demand-change`) adds a third demand, a `replace` command (`replaceSnapshots`: a held replacement whose own load did not fail covers the owners it replaces, law 10), and a `change` command that retires several owners through one `releaseSnapshot(...wheres)` call; the model judges an open replay once after the whole change (law 9). Declared reach: a change during a replay, a replacement covering a failed owner, and a change whose verdict between releases would differ. Per-release judging, first-release-alone, and batching only two-owner changes each fail it. A covered owner's failed replay load reports `loadSubset:error` whatever its settlement order (the model's existing reporting rule; suppressing the last settler fails it), and a prompt cancellation of a covered owner settles the replay as any settlement does. An aborted held replacement covers nothing: the abort ended its load before it applied (maintainer decision: an abort ends the load; law 10), so its replaced owner's failure keeps the replay failed (two 30-times counterexamples, now `keeps a replay failed when a replacement aborted after its replaced owner failed` and its peer-loaded twin; RED on the first simplification, which counted the aborted replacement as covering; a mutant that drops the check fails both). The controller always aborts and releases a segment together, so it never makes that case. An abort ends the load (maintainer decision): the history model's `abort`, and a truncate that replaces an attempt, stop that attempt's gating at once in both `manual` and `reject` cancellation; an owner whose attempt the abort ended before it settled owes the replay nothing, and a rejection after the abort decides nothing (a failure settled before the abort stays). The publication model finishes a replacement at an abort, and the replay oracle's newer attempt drops the older attempts' pending loads. A synchronous throw from `loadSubset` is a rejection (maintainer decision): an acquisition that core releases, also in the acquisition-cell and replay witnesses. RED on 2bc79fc7c: 18 lifecycle tests, 1 refinement test. Mutants that fail: a replay that waits for an aborted load (14), an aborted rejection reported as `loadSubset:error` (19), an aborted load that still holds readiness (17). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | | React paced-mutation render cuts | [render-cut oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx), [PR #2170 review](oracle-reviews/pr-2170-react-paced-render-cuts.md) | `usePacedMutations` publishes callbacks only from committed renders and does so before descendant layout effects, while callback-only commits preserve mutate identity. An independent committed-document model crosses ordinary and StrictMode schedules with mutation before or after a suspended candidate, mutation after the candidate commits, and delayed or immediate mutation from a descendant layout effect. It compares ordered callback traces and public rows at the mutation cut, the trace after persistence, and mutate identity at the commit cut. Render-time publication, layout-effect-only publication, and split insertion/layout publication mutants fail at the intended comparisons. A commit between transaction admission and persistence, arbitrary React schedules, server delivery, and other frameworks remain outside this receiving owner. | -| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [core property](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.property.test.ts) (`optimistic-history.core`), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | `optimistic-history.core` runs the core grammar through `oracleProperty`: the history resolves to a plan of concrete actions, the driver records each checkpoint, and the model owes allowed cuts, rows, outcomes, receipts, and tracked transactions, judged as named laws; the former settlement-boundary regressions are its pins, and the seven observation mutants are calibrations, each rejected by its named law. Its grammar does not yet generate pending manual edits, external completion, rejected handlers, reentrant drains, partial updates, ordered operations, or schema defaults; those dimensions are still covered only by pinned histories in the owners that drive `runOptimisticHistory` (open: move them into the grammar). Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | -| oracleProperty framework | [self-oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle.test.ts) (`self-oracle.framework`), [synthetic properties](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle-fixture.ts) | `packages/db/tests/oracle-property.ts` promises its authors registration, verdicts, shrinking, membership, reach, calibration, determinism, timeouts, replay, and rerun commands. The self-oracle writes synthetic properties (histories of one to three integers), runs them under real Vitest in a child process, and compares each test's state and failure with an independent model that reads only the synthetic driver's log of the histories each test ran. It probes a replay of every failing lane (other tests must stand aside, the replay must start from the counterexample and reproduce it) and a rerun of each property's first violating pin or history (the printed `-t` command must run exactly that test). Eighteen pins replay the framework's regressions and witness each state its laws are about; lanes count those states. Maintainer decisions of 2026-11-03: a calibration skips a candidate whose clean trace fails; within one history a driver error decides first, then a law violation, then a missed segment state; a generated property's determinism test also runs three generated histories. Seventeen framework mutants, including every survivor class from the three reviews, are rejected. Limits: one rerun probe per property; the model relies on fast-check running a lane's segment histories first; `forwardCut`, `withinTurns`, `resolve` errors, and the gap report's observation hook have no law here; a run takes about two minutes. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | +| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [core property](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.property.test.ts) (`optimistic-history.core`), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | `optimistic-history.core` runs the core grammar through `oracleProperty`: the history resolves to a plan of concrete actions, the driver records each checkpoint, and the model owes allowed cuts, rows, outcomes, receipts, and tracked transactions, judged as named laws; the former settlement-boundary regressions are its fixed histories, and the seven observation mutants are calibrations, each rejected by its named law. Its grammar does not yet generate pending manual edits, external completion, rejected handlers, reentrant drains, partial updates, ordered operations, or schema defaults; those dimensions are still covered only by fixed histories in the owners that drive `runOptimisticHistory` (open: move them into the grammar). Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction grammar also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| oracleProperty framework | [self-oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle.test.ts) (`self-oracle.framework`), [synthetic properties](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle-fixture.ts) | `packages/db/tests/oracle-property.ts` promises its authors registration, verdicts, shrinking, membership, reach, calibration, determinism, timeouts, replay, and rerun commands. The self-oracle writes synthetic properties (histories of one to three integers), runs them under real Vitest in a child process, and compares each test's state and failure with an independent model that reads only the synthetic driver's log of the histories each test ran. It probes a replay of every failing campaign (other tests must stand aside, the replay must start from the counterexample and reproduce it) and a rerun of each property's first violating fixed history or history (the printed `-t` command must run exactly that test). Eighteen fixed histories replay the framework's regressions and witness each state its laws are about; campaigns count those states. Maintainer decisions of 2026-11-03: a calibration skips a candidate whose clean trace fails; within one history a driver error decides first, then a law violation, then a missed constructed-history state; a generated property's determinism test also runs three generated histories. Seventeen framework mutants, including every survivor class from the three reviews, are rejected. Limits: one rerun probe per property; the model relies on fast-check running a grammar's constructed histories first; `forwardCut`, `withinTurns`, `resolve` errors, and the gap report's observation hook have no law here; a run takes about two minutes. | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial keys-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; fixed histories require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner fixes which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, and delete through handler parameter and mutation-alias access. All calls explicitly refetch; former `refetch`/`clearError` coordinates remain only as historical replay labels, preserving all 12 identity and 12 mutation-overlap executions. The ordinary [health scenarios](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.test.ts) observe exact public causes and settlement. A held refetch retains the current public error. A failed initial load, including a failed initial Collection application, remains a loading error for that sync run regardless of later fetch results. After readiness, a pending retry retains the prior failure reason; failure reports the later cause, and successful result application clears it. Query retry attempts and an older held success are crossed with newer failures, including reuse of one Error value in one clock tick. These scenarios supplement the oracle; they do not claim removed utility fields or error counts. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session) | -| Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. The `live-query-observer.hydration-terminal` property (`oracleProperty`, in the path-replay registry) generates histories of up to 10 steps that cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart. Its 11 declared states are model states (a status while the seed is active, attached or detached; attach to a failure; cleanup after a failure ended the seed; restart under the seed). Eight segments enter them, so every fixed and random lane reaches every state, and half of each lane's histories start with a segment; each lane prints its reach counts. Pins replay the crash, fail-then-cleanup, and crash-before-attach traces in both modes. The check judges laws `view` (status, flags, claimed rows) and `announcement` (last-read snapshot, granular rebuilt rows). The migration from the hand-built campaign kept every mutant kill and every gap-report outcome on `src/live-query-observer.ts` (seed 42); the old prefixed histories ran up to 13 steps. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests. | +| Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. The `live-query-observer.hydration-terminal` property (`oracleProperty`, in the path-replay registry) generates histories of up to 10 steps that cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart. Its 11 declared states are model states (a status while the seed is active, attached or detached; attach to a failure; cleanup after a failure ended the seed; restart under the seed). Eight constructed histories enter them, so every fixed and random campaign reaches every state, and half of each grammar's histories start with a constructed history; each grammar prints its reach counts. Fixed histories replay the crash, fail-then-cleanup, and crash-before-attach traces in both modes. The check judges laws `view` (status, flags, claimed rows) and `announcement` (last-read snapshot, granular rebuilt rows). The migration from the hand-built campaign kept every mutant kill and every gap-report outcome on `src/live-query-observer.ts` (seed 42); the old prefixed histories ran up to 13 steps. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | -| Query DB ordered refetch | `packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts` | Issue #2136: after preload, and after `utils.refetch()` or a Query invalidation settles, an on-demand Query Collection behind a LEFT-joined, ordered, limited live query must show the window over the current backend rows. The design under test: query-db reports `invalidateLoadedSubsets()` when an ordered page ends earlier in the order than before, including when its keys stay the same, or when its keys change and it has a later page. Each ordered loader runs its bounded prefix repair. A persisting optimistic update or delete can hide the visible order change that otherwise starts repair. The `query-db.refetch-held-page-end` property constructs that same-key earlier-end overlap through the real QueryClient and core receiver, with 24 fixed and 24 random histories (reach minimum 24 each), numeric finite cursor pages, static/function keys, one/two order terms, limits 1–3, offsets 0–1, varied row IDs, and explicit refetch/Query invalidation. Each history requires the same-key earlier end on the deleted held boundary, a fresh gap between the two answers, a delivered report, and a subsequent real core repair acquisition. Forty-eight structural histories cross widths/offsets with delete/update/unrelated-delete/no hold and earlier/later ends. Six delete-gap width/offset states each require structural reach. Label-only edits are negative report controls. The [nine-item review](oracle-reviews/2026-11-02-query-refetch-nine-item-review.md) records the strengthened classifier, experiments, portable commands, and finite limits. The exact failing CI history replays the shared model and comparison. Both the original implementation and the restored equal-key guard fail the public source-prefix comparison after release. See [the held-page-end review](oracle-reviews/2026-11-02-query-refetch-held-page-end.md) for exact source bindings, evidence, and limits. Its model is a pure top-k of marked backend rows. A fixed and a random campaign generate rounds of marker flips (which serve continuation pages), backend changes while the provider lags, a second live query (`observe`) whose own demand can load a page's new row first, optional Query invalidation with further changes, and a refetch, across one or two order terms, a numeric or locale-collated string order, limits 1–3, offsets 0–1, and a static or function Query key. With an unchanged backend, a repeated refetch must serve no more rows, start at most one first-page request, and report nothing. A refetch or invalidation must never start a new full-source request. A coverage law runs at the same checkpoints: every backend row from the start of the order through the window's last row (every row, when the window is short) must be in the source Collection with its current values, so a latent gap fails before a marker exposes it; a named replay of the issue trace with the moved row unmarked passes the window law and fails only the coverage law when Query DB never reports. The grammar can hold an optimistic update across a refetch, with the checkpoint moved to its release, and a premise-directed campaign under a hold (other rows observed, a row moved ahead of the page's end, a new row further ahead, a write to the last-sorting observed row) requires a report during a hold in every case and fails a skip that reads rows held back behind the transaction; the general campaigns did not. The general grammar holds optimistic deletes as well as updates. The generated campaigns declare reach (minimum 3) for a lagging refetch after a continuation, one during a hold, and a refetch that reported while the window was full, where the loader decides between ignoring the report and repairing. A named replay of a refetch during a held delete left the window short after release, also with this PR's report and overlap changes reverted, until the fix that resumes a continuation after a synchronous answer; no window is checked during a hold, where #2148 and #2169 remain open. Reports (`loadedSubsets:invalidated`) are bounded: a step reports at most once, and only when an ordered reanswer changes key membership or moves its end earlier, so unrelated value changes do not justify reports; named replays require no report when a one-page window, or a window's last page, only loses a row, and a sync-boundary replay requires a report when a page ending on a row without an order value has its end move earlier (on the earlier gate, which read that page as empty, it reported nothing), and another requires a report when the provider returns a one-page window's rows in reverse order (the earlier gate took the last array row as the page's end). When a page's end moves earlier, Query DB reports. A loader whose window lies inside its own first page skips the repair only while its page read past the window or it read the tie of the current end; after an insert into a top-N list moves the end, a named replay requires the loader to prove the window again with bounded reads, no full-source read, and an unchanged Query count. A filtered one-page window whose page end moves past an unseen row must still repair (found by the random campaign; a loader that always skips fails it, the f9 tie replay, and the ordered-work oracle's signalling replays). The skip also refuses while the source holds accepted sync rows back: a replay refetches while an optimistic update persists, so the report arrives before the refetched rows are visible, and a skip that reads those older rows fails it. The ordered loader state oracle checks that a prefix read issued before an ordering invalidation, which settles after the bounded repair, does not retire the repair's prefix; the repair-first order failed before that fix. A `loadSubset` call that acquires a released, invalidated page can report, because Query then refetches that page on its own. A failed repair shows only the last window or the right one. Named replays cover the issue trace after a refetch and after an invalidation, with a page-capping provider, with another live query loading the page's new row first, with an empty later page, with a page whose end moves earlier while a tie request holds its old last row, a second refetch after an invalidation, a repair failure, and a provider that honors neither cursor nor offset. Never signalling, the old overlap-to-full-source rule, ignoring empty later pages, reporting on any page change, removing the repair loop guard, and reporting once per page each fail it by assertion. A replay makes a page's application fail and checks the window shows its last result or the right one, then the next refetch restores it; recording a page before its rows are accepted cannot be distinguished, because the failed attempt reports on its own. Open: the report laws bound reports but do not require each one; a page whose end moves earlier reports even with unique order values, where no row can be missing; it does not check whether a repair waits for in-flight work; the generated campaigns use an uncapped cursor provider only (an offset-only provider and a page-capping provider each hit a loader defect that reproduces on main: #2182, #2183); the generated campaigns do not check the window after a marker flip (a named replay checks the trace where the loader stopped refilling after a joined-side removal, which the repair-retirement fix below closed); a failed repair still recovers through the loader's full-source retry; interval, focus, and reconnect refetches share the invalidation path but are not driven; NaN order values have no executable witness; overlapping `refetch: true` acquisitions on one Query hash are counted, so the first to finish does not clear the loop guard for the other, but no witness separates that from a plain set; a Query refetch that joins a running `refetch: true` acquisition on the same hash does not report, and no witness reaches it (#2185); follower tabs of a multi-tab persisted collection never receive the report (#2174). The offset-only and page-capping loader defects have `it.fails` witnesses in the oracle. A finished bounded repair used to discard its boundary when its retirement made Query DB remove a row the loader had sent; the loader then restarted paging with a smaller first page that could not retire the repair's prefix, so every later refetch read both. A named replay found by the random campaign (at four times the run count) and an ordered loader state witness fail on that, and the fix also closed the joined-side refill defect. At four times the run count, the random campaign found a short window that stopped paging before the last row of the order: a report's repair released continuation Queries, and Query DB answered their reacquisition synchronously from its cache, so the graph turn inside each request asked for more while the loader was still requesting and nothing resumed the staged continuation. Only the coverage law failed. A named replay and an ordered loader state witness (a synchronous answer whose writes the graph already processed must continue the chain) fail on that, and pass with the fix. The loader side of the report also has a second owner: the ordered-work oracle's `refetch-signal` provider re-runs each served request alone and reports when any answer changed, across that oracle's generated plans, and a signalling refetch must start no full-source request unless a bounded page in that step ended on a null or NaN rank, which takes the documented full-source fallback (a random seed found that case). Never signalling and the old overlap-to-full-source rule each fail it. It excludes a peer live query, which can keep a short window after a refetch (#2155). The persisted-collection oracle checks that a source's report reaches the Collection through the managed cache-generation wrapper and that a retired source session's report is dropped. Remaining work for this class is tracked in #2186. | -| Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md), [eager ordered-window repair review](oracle-reviews/perf-ordered-window-repair.md), [reversed index reads](https://github.com/TanStack/db/blob/main/packages/db/tests/reverse-index-oracle.property.test.ts) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. The ordered-work oracle also checks an eager indexed window over generated upserts and deletes: the rows equal the filtered, sorted model, and each change delivers a bounded number of source rows; an unbounded or always-full repair read fails it. Its filter dimension adds a function filter and a LEFT-joined filter, and a campaign fixed to the joined premise checks that a window refilled past a distant row the query already received holds the first eligible rows. Joined filters also run as anti-joins, change eligibility through the marker or the root row's join key, and drive an Effect beside the Collection. A full-join filter keeps every root row and adds a null-ranked rootless row for each marker no root row matches; on main a bounded root prefix showed markers whose root rows were not yet loaded as unmatched rows, and restoring the old inner-or-right full-source rule fails eight tests. Null placement is a generated dimension, with the index built to match, and a query that ignores it fails six tests. A full-join campaign has few or no filler rows, a matched null-ranked root row and at least one orphan marker in every case, so its windows reach the null group where rootless rows and null-ranked root rows tie; a reconstruction check samples exactly the cases its fixed-seed run executes and requires such windows (19 rootless and 7 mixed null-group windows of 40 at one times the run count), and the Effect runs beside the Collection. Full joins read their full source, so only the rows law covers them; paging them under the old join rule fails the window law. Pinned cases cover a null-ranked root row past a prefix that ends in the null group. Paging a nulls-last full join passed this oracle but was not adopted: it cost more per delete when the window reached the null group, which no work law here measures, and #2130 tracks full-join paging. Joined filters can match a row through several markers, and the model keeps one result row per matched pair; a model that collapses them fails. The eager harness also runs an on-demand source whose provider follows the LoadSubsetOptions contract (offset only without a cursor, the applied receipt returned). Its test provider combines a sync adapter and its provider (glossary model alignment rule 2); a held request is a physical acquisition whose provider answer is delayed, a served range is the prefix and boundary one answer establishes (a model abstraction), and the peer is a second live-query Collection. The provider sorts by the requested order and keeps its own ledger of sent rows. A forwarding provider sends live changes only for rows it sent or rows that match a served subset, and its served rows obey the work bound; a loader that asks for 100 extra rows fails it. A refetching provider, like query-db-collection, sends nothing until a refetch. The refetch keeps every served ordered range whole, as the LoadSubset contract requires: it sends every row from the start of the order through the last order value a limited page returned, and rows in no range leave; the rows law over the model applies once no change is pending. After every step on either provider, the Collection must hold exactly the rows in the provider's ledger, and the query and the Effect must show their window over those rows; a mutant that drops marker removals fails this law at steps 0 and 1 of the fixed refetching campaigns, earlier than the model's rows law, and in one more campaign. A holding provider answers each load only at an `answer` step, from the rows at that moment, so live changes arrive while loads are in flight. While a load is in flight, the query and its peer either keep the window they showed at the previous checkpoint or show distinct ledger rows that pass the query, in its order; the Effect is not checked then. That is weaker than the architecture's barrier rule, under which a repair or window move keeps the last public snapshot until it settles: a barrier law fails today on on-demand joined plans, where a visible-delete repair and a held `setWindow` publish before they settle (#2131; the law and traces are kept for that fix). The provider records a request as served only once it answers it, and an abort (or an adapter unload) of a held request follows `LoadSubsetOptions.signal`: a cooperative provider installs none of its rows and its load fulfills at once, while a provider that cannot cancel in-flight work (`late` cancellation, generated) answers it anyway and its load fulfills after those rows become visible. A `release-peer` step cleans up the peer while its loads are held, which aborts them; each request counts once although its acquisition release both aborts and unloads it. The fixed held campaign reaches 5 aborted held requests and 1 late answer, and its reconstruction check requires at least 3 cooperative cancellations and 1 late answer. A held load rejects when its applied receipt rejects, and the step then fails, because no signal or cleanup rejects a receipt in these histories; that branch is unwitnessed. The held campaign pins a forwarding provider, so `release-peer` never reaches a refetching provider; that branch is unwitnessed. A request whose signal is already aborted when the provider receives it is never reached; that branch is unwitnessed. The held campaign uses a forwarding provider and an indexed source filter or a joined filter, deletes three visible rows and then upserts an eligible row with an id no other command uses; its reconstruction check runs in the same test as the fixed-seed campaign and reads its per-case counts: 35 of 40 cases issue a held request, and every one of them changes rows while it is in flight. Loading must reach a fixed point: past a request budget the provider rejects and records it, and the initial load, every step and the end of each history assert the record is clear (a loader that re-requests a prefix it already holds can loop without reaching the provider, which this cannot see). A bounded step makes at most four provider requests, and each consumer's initial proven-prefix load is bounded separately, in total transfers (a repeated request for the same prefix counts) and in distinct rows (at most the source's size); a loader that asks for 100 extra rows on every page fails that bound and the initial-load work law. Loader mutants that never resume after a boundary request, or that ignore a load request while another load is pending, fail it; a mutant that drops a window move blocked behind a pending load still survives, even with the settlement check on held moves; whether it is equivalent is open. Open (#2148): on an on-demand source with a joined or anti-joined filter, an optimistic delete of the visible row leaves the window short until the delete persists, because the next row is never loaded. On an on-demand source with a source filter, and on an eager source with any filter, the same delete refills the window correctly; these are the contrast cases. Owner: this oracle. Needed witness: the optimistic-delete dimension (kept as a patch for #2148) in the grammar, plus a pinned replay of the anti-join trace. A refetch that re-runs each page alone, as query-db-collection does today, leaves a row that moved between two pages unloaded; random seeds found that trace, a named replay fails on it, and a query-db receiving witness reproduces it (#2136 tracks the provider fix). An Electric-style provider, which skips updates to rows it never sent, is not modeled; a mocked-stream witness shows a row moving into a served ordered range never arriving under the default replica mode (#2138). At one times the run count, the campaigns reach 87 refetch steps: 39 change the window, 6 remove a visible row, and 13 lead to a further provider request. A random seed found a refetch that empties a joined window, after which the loader refills it over several page requests and publishes `[]`, one row, then the final rows; a named replay keeps that trace for #2131. No incorrect final window has appeared on that path, and no loader mutant tried so far is caught only by it; the work law does not cover it. For an on-demand joined or anti-joined filter, the initial load must not request the whole source when the first window's worth of source rows in query order all pass the join and no boundary value is null or NaN; 35 executed cases meet that premise, and a compiler that reads the whole source for every joined filter fails eight tests on this law alone. The rows served by that initial load stay within the prefix, its boundary ties and a refill, for each consumer. Later steps of those plans have no work bound. An unindexed join filter over it left the window empty on main, until the plan counted the rows it still needs; restoring the old guard fails the campaigns and a pinned replay. A step that changes the visible rows must publish, and its subscriber starts with every visible key, so a removal-only change reaches it; mutants that drop update-only or removal-only publications fail. A step may publish one intermediate window per provider request it made, and only its last publication must show the final window, because a repair or refill can publish after each round trip (#2131); the content of those intermediate windows is unchecked. The model keeps its own copy of the markers, and full-join histories can insert the root rows of orphan markers. A premise-directed campaign inserts a fresh row and then changes an initial row's eligibility alone, so the row leaves the window only through the join; it and a named replay fail when a window row past the loader's settled boundary counts toward a full window. One case in four adds a second live query over the same sources with a larger window, and its rows law holds at every checkpoint; the work laws do not apply to those cases. #2155: a second live-query Collection over a refetching on-demand provider kept a short joined window after a refetch removed a tied row (the peer showed ranks `[0, 0]` where the model shows `[0, 0, 100]`). The loader fix that resumes a staged continuation after a synchronous answer the graph already processed closed that trace. The pinned replay of that indexed trace now expects the right window. Still open: an unindexed peer over a refetching provider keeps a short joined window (the peer shows `[100]` where the model shows `[100, 101]`), which also fails without that fix; each campaign therefore still excludes the peer and refetching pairing, as its last map. Owner: this oracle. Needed witness: a pinned replay of the unindexed trace flipped to pass, and the pairing restored to the grammar. Limits: Effects are checked for rows after each step, not for publication counts or positions. Histories move the first query's window with `setWindow` (150 moves at one times the run count, 56 on demand), and its rows law and ledger law then follow the moved window; the Effect and the peer keep theirs. On a holding provider every window-move promise must have fulfilled after the final drain. A refetching provider models query-db's unload: it drops the unloaded request and removes rows that no remaining request's last answer holds. The per-step work law applies only to forwarding, immediate, single-consumer cases: it skips refetching, holding and peer cases and move steps; a two-term indexed move scanned the source four times, which no law here bounds. Multi-term joined plans stay outside this harness, on both paths; on the on-demand path, the provider's transfer is bounded only for indexed single-term source-predicate plans. Joined windows compare order values, so a wrong row tied with the expected one passes; a window row tied with the boundary counts as proven before its tie request settles, which no witness exercises. A boundary that cannot advance past a window row would repeat one page per new key; reading the boundary's range locally should always reach that row, but no witness shows it. Joined ties are compared as any valid first rows; whether source-filter ties may be fixed to the source key is open. The same grammar includes NaN and null ranks and a second-order term, and bounds both rows read and rows delivered, including the initial load and index refills; a scan law limits each change to one pass over the source. A pinned case checks that an indexed descending window places nulls first, and a lifecycle witness checks that an eager full-source window is ready with its rows at creation when its source answers synchronously. The initial fallback cells check indexed and unindexed locale strings with distinct and tied values, plus a null boundary after a warm ordered request. The warm null-boundary case crosses preload-started and constructor-started live-query sync runs: the former completes at the synchronous cut, while the latter resumes deferred on-demand acquisition asynchronously and publishes after settlement. They require an unfiltered full-source request, no public row at fallback entry, and the expected row and status at the immediate readiness check. A Promise fallback stays loading at that checkpoint. A React receiving witness checks an eager locale-string `findOne()` at the first layout commit. A Query DB receiving witness checks warm cached subsets: both raw adapter calls return literal `true` with the source rows visible at each return, and the live query is immediately ready without a fetch. The same fallback cells observe subscriber changes from before the first source write: literal-true and Promise fallbacks each publish one nonempty final-window insert after the fallback settles, and the callback sees the complete row. Promise cells show no nonempty event at the immediate cut; a duplicate-event fault fails all eight cells. Other adapters and a real on-demand React first commit remain unwitnessed. Full-source work after initial publication and repair remain outside this synchronous guarantee. A separate post-publication oracle crosses marker deletion and retargeting with literal, Promise, and held-applied-receipt fallback responses. It also settles the child demand before holding the receipt, so the public live-query loading flag isolates the root fallback. The model checks final rows and net events, while event replay checks every callback snapshot; a retained or empty intermediate window is allowed. A loader fault that ignores the pending receipt fails at the held loading-flag check. Two literal-true cleanup-failure histories require that the replacement still publishes and the adapter error reaches the public query status without making the unrelated marker commit throw; the provider's applied receipts are recorded and compared outside provider callbacks. The Promise case checks eventual coherence on an asynchronous callback path, without claiming a separate timing cut. Four held-Promise histories apply fallback writes, change another join marker before provider settlement, and compare each callback and the final winner; an injected stale extra publication fails the event comparison. Other marker actions and overlapping root-source changes remain open; the provider has no later write or rejection, so this schedule does not establish when the loader claims full-source authority. The reversed-index oracle checks every reversed read's order and filter work for both null placements. The retirement-invalidation lane (`ordered-loader.retirement-invalidation`) composes a constructive invalidation prefix, up to four settled answers that let the repair chain complete, and generated command suffixes with index availability, limit 1/2, joined deferral, needed rows, release errors, reentrant truncate and disposal, reentrant ordinary passes, retry timers, offset 0/1, and full-source plans. Its declared `completed-repair-release` reach counts only an invalidating release observed while a successful repair-chain answer settles and issues no further chain step; an instrumented run matched the production retirement path in 66 of 72 such histories, and the rest owed nothing more after a late disposal or full coverage; an ablation that invalidates only on other releases passes the older `release-invalidation` reach and fails this one. Only an on-demand source answers a release with an adapter unload, so neither this lane nor the broad `ordered-loader.request-authority` arbitrary pairs `releaseInvalidates` with a synchronous repair answer, which reads an eager source whose releases bypass the adapter (`CollectionSyncManager.unloadSubset`). The broad arbitrary weights `releaseInvalidates` 5:1 like the other release reactions, because an invalidating release leaves the quiet report laws. The directed lane also places the invalidation after the callback's disposal, reentrant pass, and truncate (`late-release-invalidation`). Broad overlap states require 20 histories each, and the rarer full-source-plan overlap requires 5. An invalidating unload also writes graph input, so the driver's drain runs one more loader pass before it judges progress, as the live-query Collection and Effect graph loops do; without that pass, a repair that finishes inside a loader pass after joined work clears was misjudged as owing no further read. Its independent request ledger records issue and invalidation events on one clock. It requires an authoritative start after the callback's invalidation for a live finite repair after retry/drain; complete full-source coverage absorbs invalidation, cursor reset transfers pre-reset work to replay, disposal ends ownership, and unanswered authoritative work retains its existing no-timeout limit. Each normal broad campaign measures callback overlap with release error, truncate, disposal, pass reentry, offset one, timer-enabled scenarios, full-source plans, and multiple successor chains. A separate `ordered-loader.retirement-receiver` property runs real live-query Collection and Effect consumers, and a live-query Collection whose `setWindow` to the other offset starts in the same turn as the ordering write. It varies indexed/unindexed sources, limit 1/2, offset 0/1, literal/Promise adapter answers, a held newer replacement answer, and the unload's change: moving the repaired prefix, deleting its rows before the boundary, deleting the boundary row, or deleting a prefix row that ties with it, each of which owes a replacement, or evicting a sent row past the repair's boundary, including a row that ties with the boundary but lies outside the prefix, which keeps that boundary and starts no read. A tie row sorts before or after the boundary row by key, as live-query Collections order ties. A same-turn window move may repair either window's prefix, so that kind keeps only the move and row 1's eviction; prefix deletes and ties run on the other kinds. Each consumer has separate declared adapter-unload and held-answer reach, and delete, boundary-row delete, tie delete, tie eviction, and eviction unloads have their own reach; each grammar runs 600 histories. A loader that skipped every retirement-time delete fails the delete histories at the progress relation, one that treated every sent-row delete as new information fails the eviction histories, one that treated every boundary tie as eviction fails the boundary-row and tie deletes, one that treated every boundary tie as new information fails the tie evictions, and one that kept only the boundary row's key fails the tie deletes. The loader keeps the keys of the settled prefix rows that tie with its boundary row. The focused `keeps a repair's boundary when its retirement removes a row the loader sent` test remains as a supplemental example. An instrumented run placed every invalidating receiver unload inside retirement (200 of 200 per campaign). Following Atomic window publication, the window move stays pending while the replacement is held and settles with its chosen window visible; a mutant whose subset loads stop joining the active window operation fails only those move comparisons. Held-answer reach is recorded only after the provider answer is blocked and the prior-window and no-publication comparisons pass. The provider moves its repaired prefix behind a still-uninstalled row during unload and supplies no later external graph input. The shared progress relation checks that an authoritative start follows the invalidation and precedes the first replacement publication. Independent full-provider sorting checks final rows; held answers retain the previous complete public window and publish no callbacks, and completion publishes one complete replacement. Fixed replay witnesses force the held-answer premise for each consumer. An executed public-method call probe observed reentrant `loadMore` inside every sampled unload for both consumers, with no production-state reads. Original debt-clearing and deliberately delayed-one-pass mutants fail the progress/publication judgments; start observations before the invalidation or at publication fail calibration. This evidence does not require recursive synchronous dispatch: deferred replacement remains legal while the public gate holds. The replay-continuation lane (`ordered-loader.replay-continuation`) follows ARCHITECTURE.md, Ordered requests/continuation/recovery: a pre-reset request belongs to truncate replay and cannot revive its discarded cursor; a graph continuation survives bookkeeping until a graph pass consumes it or cursor reset discards it. `continuationOwed` is model-only classification provenance, not product completion state. The unchanged handoff/reset grammar and exact shared-model replay reject consuming that provenance during replay bookkeeping. This is a harness correction; it supplies no production resource-leak claim. Its `deferred-refill` state is reached only when a synchronous repair answer meets a release-triggered truncate (6 of 96 inputs). An eager source's release never reaches the adapter (`CollectionSubscription.createSubsetAcquisitionRecord`), so production cannot produce that pairing, and the lane calibrates continuation provenance without a production-reach claim. The broad request-authority arbitrary pairs synchronous repair answers with release faults, truncates, disposals, and reentrant passes in the same over-approximating way; those histories pass and support no production-reach claim either. The random handoff campaign runs 400 histories so that state stays above its minimum. Limits: the real receiver uses distinct finite numeric ranks, ascending root order, one source and consumer, one qualifying callback invalidation, no failure/truncate/disposal inside the real unload callback, one window move per history and no failed replacement under a move, and no provider-specific network. Its on-demand source settles every repair answer asynchronously in the loader, even a literal adapter answer; the loader's synchronous repair settlement needs an eager source, which has no adapter unload to invalidate. The controlled grammar measures the combined lifecycle cases; it does not transfer all such combinations across the public receiver boundary. No claim about obsolete later cursor-demand retirement (#2223) or arbitrary callback composition follows. Existing run budgets remain unchanged. Random samples do not establish bounded exhaustiveness. The controlled and receiving grammars have distinct registered replay properties, so each seed and shrink path selects only its own driver. The no-leak calibration supplies a retained-lease observation to the same request-ledger comparison after an unmodified run of the same covering-repair history; it reads no private member names. | +| Query DB ordered refetch | `packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts` | Issue #2136: after preload, and after `utils.refetch()` or a Query invalidation settles, an on-demand Query Collection behind a LEFT-joined, ordered, limited live query must show the window over the current backend rows. The design under test: query-db reports `invalidateLoadedSubsets()` when an ordered page ends earlier in the order than before, including when its keys stay the same, or when its keys change and it has a later page. Each ordered loader runs its bounded prefix repair. A persisting optimistic update or delete can hide the visible order change that otherwise starts repair. The `query-db.refetch-held-page-end` property constructs that same-key earlier-end overlap through the real QueryClient and core receiver, with 24 fixed and 24 random histories (reach minimum 24 each), numeric finite cursor pages, static/function keys, one/two order terms, limits 1–3, offsets 0–1, varied row IDs, and explicit refetch/Query invalidation. Each history requires the same-key earlier end on the deleted held boundary, a fresh gap between the two answers, a delivered report, and a subsequent real core repair acquisition. Forty-eight structural histories cross widths/offsets with delete/update/unrelated-delete/no hold and earlier/later ends. Six delete-gap width/offset states each require structural reach. Label-only edits are negative report controls. The [nine-item review](oracle-reviews/2026-11-02-query-refetch-nine-item-review.md) records the strengthened classifier, experiments, portable commands, and finite limits. The exact failing CI history replays the shared model and comparison. Both the original implementation and the restored equal-key guard fail the public source-prefix comparison after release. See [the held-page-end review](oracle-reviews/2026-11-02-query-refetch-held-page-end.md) for exact source bindings, evidence, and limits. Its model is a pure top-k of marked backend rows. A fixed and a random campaign generate rounds of marker flips (which serve continuation pages), backend changes while the provider lags, a second live query (`observe`) whose own demand can load a page's new row first, optional Query invalidation with further changes, and a refetch, across one or two order terms, a numeric or locale-collated string order, limits 1–3, offsets 0–1, and a static or function Query key. With an unchanged backend, a repeated refetch must serve no more rows, start at most one first-page request, and report nothing. A refetch or invalidation must never start a new full-source request. A coverage law runs at the same checkpoints: every backend row from the start of the order through the window's last row (every row, when the window is short) must be in the source Collection with its current values, so a latent gap fails before a marker exposes it; a named fixed history of the issue trace with the moved row unmarked passes the window law and fails only the coverage law when Query DB never reports. The grammar can hold an optimistic update across a refetch, with the checkpoint moved to its release, and a premise-directed campaign under a hold (other rows observed, a row moved ahead of the page's end, a new row further ahead, a write to the last-sorting observed row) requires a report during a hold in every case and fails a skip that reads rows held back behind the transaction; the general campaigns did not. The general grammar holds optimistic deletes as well as updates. The generated campaigns declare reach (minimum 3) for a lagging refetch after a continuation, one during a hold, and a refetch that reported while the window was full, where the loader decides between ignoring the report and repairing. A named fixed history of a refetch during a held delete left the window short after release, also with this PR's report and overlap changes reverted, until the fix that resumes a continuation after a synchronous answer; no window is checked during a hold, where #2148 and #2169 remain open. Reports (`loadedSubsets:invalidated`) are bounded: a step reports at most once, and only when an ordered reanswer changes key membership or moves its end earlier, so unrelated value changes do not justify reports; named fixed histories require no report when a one-page window, or a window's last page, only loses a row, and a sync-boundary replay requires a report when a page ending on a row without an order value has its end move earlier (on the earlier gate, which read that page as empty, it reported nothing), and another requires a report when the provider returns a one-page window's rows in reverse order (the earlier gate took the last array row as the page's end). When a page's end moves earlier, Query DB reports. A loader whose window lies inside its own first page skips the repair only while its page read past the window or it read the tie of the current end; after an insert into a top-N list moves the end, a named fixed history requires the loader to prove the window again with bounded reads, no full-source read, and an unchanged Query count. A filtered one-page window whose page end moves past an unseen row must still repair (found by the random campaign; a loader that always skips fails it, the f9 tie replay, and the ordered-work oracle's signalling replays). The skip also refuses while the source holds accepted sync rows back: a replay refetches while an optimistic update persists, so the report arrives before the refetched rows are visible, and a skip that reads those older rows fails it. The ordered loader state oracle checks that a prefix read issued before an ordering invalidation, which settles after the bounded repair, does not retire the repair's prefix; the repair-first order failed before that fix. A `loadSubset` call that acquires a released, invalidated page can report, because Query then refetches that page on its own. A failed repair shows only the last window or the right one. Named replays cover the issue trace after a refetch and after an invalidation, with a page-capping provider, with another live query loading the page's new row first, with an empty later page, with a page whose end moves earlier while a tie request holds its old last row, a second refetch after an invalidation, a repair failure, and a provider that honors neither cursor nor offset. Never signalling, the old overlap-to-full-source rule, ignoring empty later pages, reporting on any page change, removing the repair loop guard, and reporting once per page each fail it by assertion. A replay makes a page's application fail and checks the window shows its last result or the right one, then the next refetch restores it; recording a page before its rows are accepted cannot be distinguished, because the failed attempt reports on its own. Open: the report laws bound reports but do not require each one; a page whose end moves earlier reports even with unique order values, where no row can be missing; it does not check whether a repair waits for in-flight work; the generated campaigns use an uncapped cursor provider only (an offset-only provider and a page-capping provider each hit a loader defect that reproduces on main: #2182, #2183); the generated campaigns do not check the window after a marker flip (a named fixed history checks the trace where the loader stopped refilling after a joined-side removal, which the repair-retirement fix below closed); a failed repair still recovers through the loader's full-source retry; interval, focus, and reconnect refetches share the invalidation path but are not driven; NaN order values have no executable witness; overlapping `refetch: true` acquisitions on one Query hash are counted, so the first to finish does not clear the loop guard for the other, but no witness separates that from a plain set; a Query refetch that joins a running `refetch: true` acquisition on the same hash does not report, and no witness reaches it (#2185); follower tabs of a multi-tab persisted collection never receive the report (#2174). The offset-only and page-capping loader defects have `it.fails` witnesses in the oracle. A finished bounded repair used to discard its boundary when its retirement made Query DB remove a row the loader had sent; the loader then restarted paging with a smaller first page that could not retire the repair's prefix, so every later refetch read both. A named fixed history found by the random campaign (at four times the run count) and an ordered loader state witness fail on that, and the fix also closed the joined-side refill defect. At four times the run count, the random campaign found a short window that stopped paging before the last row of the order: a report's repair released continuation Queries, and Query DB answered their reacquisition synchronously from its cache, so the graph turn inside each request asked for more while the loader was still requesting and nothing resumed the staged continuation. Only the coverage law failed. A named fixed history and an ordered loader state witness (a synchronous answer whose writes the graph already processed must continue the chain) fail on that, and pass with the fix. The loader side of the report also has a second owner: the ordered-work oracle's `refetch-signal` provider re-runs each served request alone and reports when any answer changed, across that oracle's generated plans, and a signalling refetch must start no full-source request unless a bounded page in that step ended on a null or NaN rank, which takes the documented full-source fallback (a random seed found that case). Never signalling and the old overlap-to-full-source rule each fail it. It excludes a peer live query, which can keep a short window after a refetch (#2155). The persisted-collection oracle checks that a source's report reaches the Collection through the managed cache-generation wrapper and that a retired source session's report is dropped. Remaining work for this class is tracked in #2186. | +| Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md), [eager ordered-window repair review](oracle-reviews/perf-ordered-window-repair.md), [reversed index reads](https://github.com/TanStack/db/blob/main/packages/db/tests/reverse-index-oracle.property.test.ts) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. The ordered-work oracle also checks an eager indexed window over generated upserts and deletes: the rows equal the filtered, sorted model, and each change delivers a bounded number of source rows; an unbounded or always-full repair read fails it. Its filter dimension adds a function filter and a LEFT-joined filter, and a campaign fixed to the joined premise checks that a window refilled past a distant row the query already received holds the first eligible rows. Joined filters also run as anti-joins, change eligibility through the marker or the root row's join key, and drive an Effect beside the Collection. A full-join filter keeps every root row and adds a null-ranked rootless row for each marker no root row matches; on main a bounded root prefix showed markers whose root rows were not yet loaded as unmatched rows, and restoring the old inner-or-right full-source rule fails eight tests. Null placement is a generated dimension, with the index built to match, and a query that ignores it fails six tests. A full-join campaign has few or no filler rows, a matched null-ranked root row and at least one orphan marker in every case, so its windows reach the null group where rootless rows and null-ranked root rows tie; a reconstruction check samples exactly the cases its fixed-seed run executes and requires such windows (19 rootless and 7 mixed null-group windows of 40 at one times the run count), and the Effect runs beside the Collection. Full joins read their full source, so only the rows law covers them; paging them under the old join rule fails the window law. Fixed cases cover a null-ranked root row past a prefix that ends in the null group. Paging a nulls-last full join passed this oracle but was not adopted: it cost more per delete when the window reached the null group, which no work law here measures, and #2130 tracks full-join paging. Joined filters can match a row through several markers, and the model keeps one result row per matched pair; a model that collapses them fails. The eager harness also runs an on-demand source whose provider follows the LoadSubsetOptions contract (offset only without a cursor, the applied receipt returned). Its test provider combines a sync adapter and its provider (glossary model alignment rule 2); a held request is a physical acquisition whose provider answer is delayed, a served range is the prefix and boundary one answer establishes (a model abstraction), and the peer is a second live-query Collection. The provider sorts by the requested order and keeps its own ledger of sent rows. A forwarding provider sends live changes only for rows it sent or rows that match a served subset, and its served rows obey the work bound; a loader that asks for 100 extra rows fails it. A refetching provider, like query-db-collection, sends nothing until a refetch. The refetch keeps every served ordered range whole, as the LoadSubset contract requires: it sends every row from the start of the order through the last order value a limited page returned, and rows in no range leave; the rows law over the model applies once no change is pending. After every step on either provider, the Collection must hold exactly the rows in the provider's ledger, and the query and the Effect must show their window over those rows; a mutant that drops marker removals fails this law at steps 0 and 1 of the fixed refetching campaigns, earlier than the model's rows law, and in one more campaign. A holding provider answers each load only at an `answer` step, from the rows at that moment, so live changes arrive while loads are in flight. While a load is in flight, the query and its peer either keep the window they showed at the previous checkpoint or show distinct ledger rows that pass the query, in its order; the Effect is not checked then. That is weaker than the architecture's barrier rule, under which a repair or window move keeps the last public snapshot until it settles: a barrier law fails today on on-demand joined plans, where a visible-delete repair and a held `setWindow` publish before they settle (#2131; the law and traces are kept for that fix). The provider records a request as served only once it answers it, and an abort (or an adapter unload) of a held request follows `LoadSubsetOptions.signal`: a cooperative provider installs none of its rows and its load fulfills at once, while a provider that cannot cancel in-flight work (`late` cancellation, generated) answers it anyway and its load fulfills after those rows become visible. A `release-peer` step cleans up the peer while its loads are held, which aborts them; each request counts once although its acquisition release both aborts and unloads it. The fixed held campaign reaches 5 aborted held requests and 1 late answer, and its reconstruction check requires at least 3 cooperative cancellations and 1 late answer. A held load rejects when its applied receipt rejects, and the step then fails, because no signal or cleanup rejects a receipt in these histories; that branch is unwitnessed. The held campaign fixes a forwarding provider, so `release-peer` never reaches a refetching provider; that branch is unwitnessed. A request whose signal is already aborted when the provider receives it is never reached; that branch is unwitnessed. The held campaign uses a forwarding provider and an indexed source filter or a joined filter, deletes three visible rows and then upserts an eligible row with an id no other command uses; its reconstruction check runs in the same test as the fixed-seed campaign and reads its per-case counts: 35 of 40 cases issue a held request, and every one of them changes rows while it is in flight. Loading must reach a fixed point: past a request budget the provider rejects and records it, and the initial load, every step and the end of each history assert the record is clear (a loader that re-requests a prefix it already holds can loop without reaching the provider, which this cannot see). A bounded step makes at most four provider requests, and each consumer's initial proven-prefix load is bounded separately, in total transfers (a repeated request for the same prefix counts) and in distinct rows (at most the source's size); a loader that asks for 100 extra rows on every page fails that bound and the initial-load work law. Loader mutants that never resume after a boundary request, or that ignore a load request while another load is pending, fail it; a mutant that drops a window move blocked behind a pending load still survives, even with the settlement check on held moves; whether it is equivalent is open. Open (#2148): on an on-demand source with a joined or anti-joined filter, an optimistic delete of the visible row leaves the window short until the delete persists, because the next row is never loaded. On an on-demand source with a source filter, and on an eager source with any filter, the same delete refills the window correctly; these are the contrast cases. Owner: this oracle. Needed witness: the optimistic-delete dimension (kept as a patch for #2148) in the grammar, plus a fixed histories of the anti-join trace. A refetch that re-runs each page alone, as query-db-collection does today, leaves a row that moved between two pages unloaded; random seeds found that trace, a named fixed history fails on it, and a query-db receiving witness reproduces it (#2136 tracks the provider fix). An Electric-style provider, which skips updates to rows it never sent, is not modeled; a mocked-stream witness shows a row moving into a served ordered range never arriving under the default replica mode (#2138). At one times the run count, the campaigns reach 87 refetch steps: 39 change the window, 6 remove a visible row, and 13 lead to a further provider request. A random seed found a refetch that empties a joined window, after which the loader refills it over several page requests and publishes `[]`, one row, then the final rows; a named fixed history keeps that trace for #2131. No incorrect final window has appeared on that path, and no loader mutant tried so far is caught only by it; the work law does not cover it. For an on-demand joined or anti-joined filter, the initial load must not request the whole source when the first window's worth of source rows in query order all pass the join and no boundary value is null or NaN; 35 executed cases meet that premise, and a compiler that reads the whole source for every joined filter fails eight tests on this law alone. The rows served by that initial load stay within the prefix, its boundary ties and a refill, for each consumer. Later steps of those plans have no work bound. An unindexed join filter over it left the window empty on main, until the plan counted the rows it still needs; restoring the old guard fails the campaigns and a fixed histories. A step that changes the visible rows must publish, and its subscriber starts with every visible key, so a removal-only change reaches it; mutants that drop update-only or removal-only publications fail. A step may publish one intermediate window per provider request it made, and only its last publication must show the final window, because a repair or refill can publish after each round trip (#2131); the content of those intermediate windows is unchecked. The model keeps its own copy of the markers, and full-join histories can insert the root rows of orphan markers. A premise-directed campaign inserts a fresh row and then changes an initial row's eligibility alone, so the row leaves the window only through the join; it and a named fixed history fail when a window row past the loader's settled boundary counts toward a full window. One case in four adds a second live query over the same sources with a larger window, and its rows law holds at every checkpoint; the work laws do not apply to those cases. #2155: a second live-query Collection over a refetching on-demand provider kept a short joined window after a refetch removed a tied row (the peer showed ranks `[0, 0]` where the model shows `[0, 0, 100]`). The loader fix that resumes a staged continuation after a synchronous answer the graph already processed closed that trace. The fixed histories of that indexed trace now expects the right window. Still open: an unindexed peer over a refetching provider keeps a short joined window (the peer shows `[100]` where the model shows `[100, 101]`), which also fails without that fix; each campaign therefore still excludes the peer and refetching pairing, as its last map. Owner: this oracle. Needed witness: a fixed histories of the unindexed trace flipped to pass, and the pairing restored to the grammar. Limits: Effects are checked for rows after each step, not for publication counts or positions. Histories move the first query's window with `setWindow` (150 moves at one times the run count, 56 on demand), and its rows law and ledger law then follow the moved window; the Effect and the peer keep theirs. On a holding provider every window-move promise must have fulfilled after the final drain. A refetching provider models query-db's unload: it drops the unloaded request and removes rows that no remaining request's last answer holds. The per-step work law applies only to forwarding, immediate, single-consumer cases: it skips refetching, holding and peer cases and move steps; a two-term indexed move scanned the source four times, which no law here bounds. Multi-term joined plans stay outside this harness, on both paths; on the on-demand path, the provider's transfer is bounded only for indexed single-term source-predicate plans. Joined windows compare order values, so a wrong row tied with the expected one passes; a window row tied with the boundary counts as proven before its tie request settles, which no witness exercises. A boundary that cannot advance past a window row would repeat one page per new key; reading the boundary's range locally should always reach that row, but no witness shows it. Joined ties are compared as any valid first rows; whether source-filter ties may be fixed to the source key is open. The same grammar includes NaN and null ranks and a second-order term, and bounds both rows read and rows delivered, including the initial load and index refills; a scan law limits each change to one pass over the source. A fixed case checks that an indexed descending window places nulls first, and a lifecycle witness checks that an eager full-source window is ready with its rows at creation when its source answers synchronously. The initial fallback cells check indexed and unindexed locale strings with distinct and tied values, plus a null boundary after a warm ordered request. The warm null-boundary case crosses preload-started and constructor-started live-query sync runs: the former completes at the synchronous cut, while the latter resumes deferred on-demand acquisition asynchronously and publishes after settlement. They require an unfiltered full-source request, no public row at fallback entry, and the expected row and status at the immediate readiness check. A Promise fallback stays loading at that checkpoint. A React receiving witness checks an eager locale-string `findOne()` at the first layout commit. A Query DB receiving witness checks warm cached subsets: both raw adapter calls return literal `true` with the source rows visible at each return, and the live query is immediately ready without a fetch. The same fallback cells observe subscriber changes from before the first source write: literal-true and Promise fallbacks each publish one nonempty final-window insert after the fallback settles, and the callback sees the complete row. Promise cells show no nonempty event at the immediate cut; a duplicate-event fault fails all eight cells. Other adapters and a real on-demand React first commit remain unwitnessed. Full-source work after initial publication and repair remain outside this synchronous guarantee. A separate post-publication oracle crosses marker deletion and retargeting with literal, Promise, and held-applied-receipt fallback responses. It also settles the child demand before holding the receipt, so the public live-query loading flag isolates the root fallback. The model checks final rows and net events, while event replay checks every callback snapshot; a retained or empty intermediate window is allowed. A loader fault that ignores the pending receipt fails at the held loading-flag check. Two literal-true cleanup-failure histories require that the replacement still publishes and the adapter error reaches the public query status without making the unrelated marker commit throw; the provider's applied receipts are recorded and compared outside provider callbacks. The Promise case checks eventual coherence on an asynchronous callback path, without claiming a separate timing cut. Four held-Promise histories apply fallback writes, change another join marker before provider settlement, and compare each callback and the final winner; an injected stale extra publication fails the event comparison. Other marker actions and overlapping root-source changes remain open; the provider has no later write or rejection, so this schedule does not establish when the loader claims full-source authority. The reversed-index oracle checks every reversed read's order and filter work for both null placements. The retirement-invalidation grammar (`ordered-loader.retirement-invalidation`) composes a constructive invalidation prefix, up to four settled answers that let the repair chain complete, and generated command suffixes with index availability, limit 1/2, joined deferral, needed rows, release errors, reentrant truncate and disposal, reentrant ordinary passes, retry timers, offset 0/1, and full-source plans. Its declared `completed-repair-release` reach counts only an invalidating release observed while a successful repair-chain answer settles and issues no further chain step; an instrumented run matched the production retirement path in 66 of 72 such histories, and the rest owed nothing more after a late disposal or full coverage; an ablation that invalidates only on other releases passes the older `release-invalidation` reach and fails this one. Only an on-demand source answers a release with an adapter unload, so neither this grammar nor the broad `ordered-loader.request-authority` arbitrary pairs `releaseInvalidates` with a synchronous repair answer, which reads an eager source whose releases bypass the adapter (`CollectionSyncManager.unloadSubset`). The broad arbitrary weights `releaseInvalidates` 5:1 like the other release reactions, because an invalidating release leaves the quiet report laws. The directed grammar also places the invalidation after the callback's disposal, reentrant pass, and truncate (`late-release-invalidation`). Broad overlap states require 20 histories each, and the rarer full-source-plan overlap requires 5. An invalidating unload also writes graph input, so the driver's drain runs one more loader pass before it judges progress, as the live-query Collection and Effect graph loops do; without that pass, a repair that finishes inside a loader pass after joined work clears was misjudged as owing no further read. Its independent request ledger records issue and invalidation events on one clock. It requires an authoritative start after the callback's invalidation for a live finite repair after retry/drain; complete full-source coverage absorbs invalidation, cursor reset transfers pre-reset work to replay, disposal ends ownership, and unanswered authoritative work retains its existing no-timeout limit. Each normal broad campaign measures callback overlap with release error, truncate, disposal, pass reentry, offset one, timer-enabled scenarios, full-source plans, and multiple successor chains. A separate `ordered-loader.retirement-receiver` property runs real live-query Collection and Effect consumers, and a live-query Collection whose `setWindow` to the other offset starts in the same turn as the ordering write. It varies indexed/unindexed sources, limit 1/2, offset 0/1, literal/Promise adapter answers, a held newer replacement answer, and the unload's change: moving the repaired prefix, deleting its rows before the boundary, deleting the boundary row, or deleting a prefix row that ties with it, each of which owes a replacement, or evicting a sent row past the repair's boundary, including a row that ties with the boundary but lies outside the prefix, which keeps that boundary and starts no read. A tie row sorts before or after the boundary row by key, as live-query Collections order ties. A same-turn window move may repair either window's prefix, so that kind keeps only the move and row 1's eviction; prefix deletes and ties run on the other kinds. Each consumer has separate declared adapter-unload and held-answer reach, and delete, boundary-row delete, tie delete, tie eviction, and eviction unloads have their own reach; each grammar runs 600 histories. A loader that skipped every retirement-time delete fails the delete histories at the progress relation, one that treated every sent-row delete as new information fails the eviction histories, one that treated every boundary tie as eviction fails the boundary-row and tie deletes, one that treated every boundary tie as new information fails the tie evictions, and one that kept only the boundary row's key fails the tie deletes. The loader keeps the keys of the settled prefix rows that tie with its boundary row. The focused `keeps a repair's boundary when its retirement removes a row the loader sent` test remains as a supplemental example. An instrumented run placed every invalidating receiver unload inside retirement (200 of 200 per campaign). Following Atomic window publication, the window move stays pending while the replacement is held and settles with its chosen window visible; a mutant whose subset loads stop joining the active window operation fails only those move comparisons. Held-answer reach is recorded only after the provider answer is blocked and the prior-window and no-publication comparisons pass. The provider moves its repaired prefix behind a still-uninstalled row during unload and supplies no later external graph input. The shared progress relation checks that an authoritative start follows the invalidation and precedes the first replacement publication. Independent full-provider sorting checks final rows; held answers retain the previous complete public window and publish no callbacks, and completion publishes one complete replacement. Fixed replay witnesses force the held-answer premise for each consumer. An executed public-method call probe observed reentrant `loadMore` inside every sampled unload for both consumers, with no production-state reads. Original debt-clearing and deliberately delayed-one-pass mutants fail the progress/publication judgments; start observations before the invalidation or at publication fail calibration. This evidence does not require recursive synchronous dispatch: deferred replacement remains legal while the public gate holds. The replay-continuation grammar (`ordered-loader.replay-continuation`) follows ARCHITECTURE.md, Ordered requests/continuation/recovery: a pre-reset request belongs to truncate replay and cannot revive its discarded cursor; a graph continuation survives bookkeeping until a graph pass consumes it or cursor reset discards it. `continuationOwed` is model-only classification provenance, not product completion state. The unchanged handoff/reset grammar and exact shared-model replay reject consuming that provenance during replay bookkeeping. This is a harness correction; it supplies no production resource-leak claim. Its `deferred-refill` state is reached only when a synchronous repair answer meets a release-triggered truncate (6 of 96 inputs). An eager source's release never reaches the adapter (`CollectionSubscription.createSubsetAcquisitionRecord`), so production cannot produce that pairing, and the grammar calibrates continuation provenance without a production-reach claim. The broad request-authority arbitrary pairs synchronous repair answers with release faults, truncates, disposals, and reentrant passes in the same over-approximating way; those histories pass and support no production-reach claim either. The random handoff campaign runs 400 histories so that state stays above its minimum. Limits: the real receiver uses distinct finite numeric ranks, ascending root order, one source and consumer, one qualifying callback invalidation, no failure/truncate/disposal inside the real unload callback, one window move per history and no failed replacement under a move, and no provider-specific network. Its on-demand source settles every repair answer asynchronously in the loader, even a literal adapter answer; the loader's synchronous repair settlement needs an eager source, which has no adapter unload to invalidate. The controlled grammar measures the combined lifecycle cases; it does not transfer all such combinations across the public receiver boundary. No claim about obsolete later cursor-demand retirement (#2223) or arbitrary callback composition follows. Existing run budgets remain unchanged. Random samples do not establish bounded exhaustiveness. The controlled and receiving grammars have distinct registered replay properties, so each seed and shrink path selects only its own driver. The no-leak calibration supplies a retained-lease observation to the same request-ledger comparison after an unmodified run of the same covering-repair history; it reads no private member names. | -| Indexed predicate filtering | `packages/db/tests/query/index-path-collision-oracle.test.ts` | A bounded cross product of numeric values checks exact public keys before and after adding a nested-field BTree index. Direct predicates vary argument order, bound inclusivity, reversed operands, and bound field. Public Collection subscription and live-query callbacks run with and without the index. Selected-field ordering compares a direct JavaScript sort with a public `$selected` callback; restoring the dotted-key proxy cache made that comparison fail. The original compound grouping failed four indexed cases while scan controls passed. The original callback proxy caches also failed both unindexed public routes. A hostile cleanup control preserves the primary mismatch and secondary release error. Arbitrary path segments, nullish values, custom collation, and incremental publication need separate witnesses if claimed. | +| Indexed predicate filtering | `packages/db/tests/query/index-path-collision-oracle.test.ts` | A bounded cross product of numeric values checks exact public keys before and after adding a nested-field BTree index. Direct predicates vary argument order, bound inclusivity, reversed operands, and bound field. Public Collection subscription and live-query callbacks run with and without the index. Selected-field ordering compares a direct JavaScript sort with a public `$selected` callback; restoring the dotted-key proxy cache made that comparison fail. The original compound grouping failed four indexed cases while scan controls passed. The original callback proxy caches also failed both unindexed public routes. A mutant cleanup control preserves the primary mismatch and secondary release error. Arbitrary path segments, nullish values, custom collation, and incremental publication need separate witnesses if claimed. | | Lazy target path identity | `packages/db/tests/query/compiler/lazy-targets.test.ts` | A focused same-source `UnionFrom`/`coalesce` witness requires both [`a.b`] and [`a`, `b`] demand targets. Restoring dotted-string deduplication drops the second target at the compiler boundary. A public on-demand adapter and row-publication history still need a separate witness. | | Correlated include path identity | `packages/db/tests/query/includes-context-transport-oracle.test.ts` | The fixed path witnesses live in `packages/db/tests/query/includes-context-transport.test.ts`. A flat parent field and nested parent field reach one-level and nested `toArray` results independently; dotted ancestor aliases remain distinct through a grandchild route. Initial results and parent updates have exact public-value checks. A conditional projection gives [`a.b`] and [`a`, `b`] different include results and checks parent-key changes plus later child inserts. Removing the compiler's unique route-key allocator loses a public child result; restoring dotted-string deduplication at either builder site loses a distinct parent value. These fixed witnesses do not establish arbitrary path segments, every recursive source form, or every materialization form. | -| Alias scope identity | `packages/db/tests/query/includes-oracle.property.test.ts` (`includes alpha-renaming across lexical scopes`), `packages/db/tests/query/includes-scope-identity-oracle.ts`, `packages/db/tests/query/includes-alias-shadowing-oracle.test.ts`, `packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts`, `packages/db/tests/query/validate-aliases.test.ts`, [issue #1975 review](oracle-reviews/issue-1975-scope-identity.md) | Four topologies place a source beside a same-named source: a `from()` subquery with an include, a joined `unionAll()` branch with an include, a collapsed top-level pure wrapper with a join subquery, and an include directly on a joined `unionAll()`, which may reuse a branch alias. The grammar crosses zero to two joins, LEFT or INNER, zero to two predicates, plain, ordered-and-limited, or DISTINCT bodies, include forms, and eager or on-demand finite providers, after preload and after source writes. Includes read their source directly, through a join, through a `unionAll()` with an anchor join, or through a `from()` subquery that reads the parent row. One in four scenarios draws any naming; same-scope and union-branch repeats are rejected while ancestor shadowing is admitted. Each checkpoint compares the canonical naming with plain recomputation, the generated naming with the canonical naming, and per-Collection `loadSubset` WHERE clauses. The exact-output companion independently recomputes parent-child rows for direct, QueryRef, union, and implicit-join forms across two equality operand orders, shadowed and renamed aliases, eager and on-demand children, initial publication and parent/child writes. The recursive forms keep a required outer lockId correlation and an independent captured lockName predicate; the union has a nonempty right branch and a later two-branch overlap. It checks enumerable user and virtual key names, user values, multiplicity, sibling helper reuse, a fresh parent/inner QueryRef declaration across a source update, direct, child union-branch, parent union-branch, parent QueryRef, and nested-include rejection of ancestor/descendant builder reuse, one nested grandparent capture, functional callback child input, grouping, captured whole-row and nested-path spreads across shadowed and renamed aliases, eager and on-demand children, and parent/child writes, and query identity. Focused follow-up cells check same-path group keys under shadowing and renaming through parent and child writes, alpha-renamed pure wrappers at public-row and optimized-identity cuts, and indexed lazy demand for direct or wrapped nested includes through anchor and user writes. Wrong-query controls that drop either inner predicate or substitute a parent-shaped callback value fail at the initial public-row comparison. A finite follow-up crosses direct includes and joined QueryRefs with shadowed or renamed aliases, eager or on-demand sources, and child IDs equal to or distinct from an outer filter literal; it checks public rows and on-demand child admission after preload. Another follow-up projects a captured parent rank through a child QueryRef and checks public rows after preload, a child-rank change, and a parent-rank change. The original compiler misrouted the outer filter in four cases, and the original optimizer lost the captured binding in two cases at their first public-row comparison. Every pinned witness and both campaigns fail on `18abceee4`; each of the four optimizer re-mint sites fails a witness when reverted. Arbitrary nested includes, include-inside-subquery forms beyond the checked LEFT joined QueryRef, other join-subquery topologies, `having`, outer joins beyond the bounded RIGHT/FULL matrix, publication events, unload, ordered windows, other lazy join targets, and temporal demand remain outside these owners; they need witnesses before an alias-scope closure claim covers them. Ancestor/descendant builder reuse is rejected before correlation extraction; one helper builder remains legal across a parent QueryRef, union branch, or joined QueryRef and a sibling include child. Six placement/source-mode cells check construction, exact rows after preload, and a source update; the joined placement includes an unmatched manager that would expose a missing helper join. The original source-tree guard rejects all six. A discarded enumeration of a captured proxy cannot change the selected query identity or rows before or after a child update; the retained-marker implementation fails that identity check. The [PR #2079 follow-up](oracle-reviews/issue-2072-alias-shadowing-followup.md) records its RED trace, the repaired spread and nested-QueryRef cells, and the remaining compiler and optimizer evidence gaps. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds a self-join with an inner alias matching the outer joined source, a joined child whose local source competes with inherited parent context in matched and unmatched LEFT joins, captured/local spreads, grandparent whole-row capture, and direct captured-alias selection. These finite cells compare public rows after preload and selected writes; an alias-only lazy-target mutant fails the eager self-join comparison. The reported on-demand failure did not reproduce with the controlled source. A further finite matrix crosses nested FROM or JOIN placement, field or whole-row capture of a shadowed outer joined alias, shadowed or renamed spelling, and eager or on-demand acquisition. It compares source-role rows after preload, child deletion and movement, joined-row update and movement, and parent update; on-demand cases compare nonempty request sets. Disabling projected binding lookup fails all eight cells at the initial public-row assertion; an alias-only lazy-target mutant survives this matrix, so it adds no broader lazy-target claim. A bounded outer-join extension crosses RIGHT and FULL joins, main- or joined-side correlation, direct and QueryRef joined sources, shadowed and renamed aliases, eager and on-demand sources, two parent routes, and seven initial or post-write cuts. Its independent source-role model checks exact public rows, including both absent sides; on-demand cells also require source acquisition. A join-operand extension crosses an ancestor term combined with a local main or joined term, or serving as the whole main key, with equality reversal, alias renaming, source mode, and five public-row cuts. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) records RED and GREEN evidence. A foreign captured join operand must reject: a finite provenance matrix checks root and include placements, matching or different alias spelling, and eager or on-demand sources at construction or preload settlement; valid local and captured-ancestor operands remain controls. A two-LEFT-join history now checks that a later routed QueryRef or captured operand cannot restore an absent shadowed local source, across alias renaming, eager/on-demand mode, two parents, and local and second-joined arrival/removal. It also selects and updates a parent label while joins are matched, checking that the main route retains that ancestor value. A nested joined QueryRef control projects its join key from an inner tag source while the post alias shadows or renames an outer user alias; an independent plain-row model and installed on-demand post/tag rows are compared at preload, a user-key move, and a tag update. A wrong Collection/SourceId pair loads an irrelevant post at preload and fails the installed-row check. Other nested source shapes and real-provider request schedules remain outside this control. The route-work owner compares public rows and assembled parent-route work as irrelevant joined rows grow for direct main-correlated RIGHT/FULL joins in both source modes; an unconditional-route mutant grows from two to six pairs at preload. RIGHT joins correlated on main now join the outer-join row matrix, beside joined-side correlation and FULL controls. An include child rejects a bound join operand declared inside its parent QueryRef or union branch, across matching/renamed aliases and eager/on-demand sources; the original source-tree collector resolves preload instead of rejecting. A joined QueryRef rejects a captured source declared beside it in the enclosing query: the matrix crosses root/include placement, matching/renamed inner alias, and eager/on-demand sources; four true-ancestor controls assert rows, and the permissive sibling-scope mutant fails all eight invalid cells while those controls pass. The primary oracle now extends the hidden-source rejection through two QueryRef or union levels, with matching/renamed aliases and both source modes; a source-tree-binding mutant fails all eight added cells at the rejection cut. An unrelated bound reference is rejected in WHERE, GROUP BY, HAVING, ORDER BY, direct SELECT, and conditional SELECT across matching/renamed aliases; legal local controls and a grouped include with captured ancestor predicates, HAVING, ordering, shadowing, and parent/child writes prevent a blanket rejection. It also rejects foreign references moved into extracted include parent filters or into the removed correlation equality, each across alias spelling and source mode with valid parent controls. Disabling expression validation fails twelve non-join cells; omitting parent-filter or extracted-correlation validation fails four cells each. A separate plain relational model checks RIGHT-to-LEFT, RIGHT-to-RIGHT, FULL-to-FULL, and FULL-to-LEFT two-join chains under shadowing/renaming, both source modes, main/joined/tag correlation, and six source or ancestor writes; a later-FULL-as-LEFT mutant fails two initial public-row comparisons. The route-work owner now compares public rows and parent-route assembly counts at preload and four main-source writes while irrelevant joined rows grow. Other expression forms, arbitrary join-subquery and lazy-target shapes, deeper captures, other chained outer joins, real-provider schedules, and broader source-work bounds remain open under this owner. The alias-scope owner also checks outer grouping through a nested QueryRef and joined source with shadowed or renamed inner alias, eager or on-demand acquisition, and four source/tag writes at five public-row cuts. The tag insertion duplicates one joined source and the plain model counts both copies. A controlled rewrite probe reached an unbound pushed WHERE inside the QueryRef while the bound GROUP BY remained outside; an inner GROUP BY blocked that pushdown. A matcher mutant that ignored binding IDs failed two same-path group cells at the first public-row cut; a dropped-pushed-predicate mutant failed all four new cells by publishing an extra group. This refutes the reported WHERE-rewrite-to-GROUP-BY-matcher route in those shapes; another lost-provenance route needs a concrete fixture. Base/head work probes confirmed an extra pure-wrapper stage after #2079, but the same full-source outer-WHERE request and keyed lazy-join request on both revisions. No wrapper-stage work budget or safe binding remap is established. Bound root refs caused two route-metadata symbol checks per evaluation on the current head versus zero on base, while captured-ancestor rows remain protected by the primary oracle; a compile-time fast path needs a justified work budget and a preservation witness. The same function-form subquery placed twice compiled two results and invoked its callback four times for two source rows on both revisions. Cache comparison read more query properties on the current head, and a stateful callback produced placement-specific values. A callback identity or purity rule and work budget are needed before cache-equivalence changes. | -| Join equality and cold acquisition | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` | Independent recomputation for cold acquisition plus direct join/predicate equivalence across established equality domains. Binary/string and nullish classes, replacement histories, raw on-demand values, and both scan/auto-index paths are explicit; the compound lane also covers tuple equality, term order, all four join types, controlled cold sources, and keyed histories. Positive acquisition witnesses require a satisfiable tuple. | -| Aggregate selects | [aggregate select oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/aggregate-select-oracle.property.test.ts) | Generated select shapes of aggregate queries with and without `groupBy` by `c.k`, the pair `c.k`, `gt(c.v, 1)`, the triple that adds `gt(c.v, 2)`, `upper(c.k)`, `inArray(c.k, ['a', 'b'])`, a Date-valued `coalesce` or a `NaN`-valued `add`, rebuilt per use (literals, parent fields, the group key, the raw field `c.k`, source fields, `count`/`sum`/`max`, `add`, `caseWhen` with expression or object branches, nested objects, spreads, a nested include) and a HAVING condition that may compare an aggregate, selected as a field or not, at the top level, directly or through a `unionAll`, and in an include whose source may shadow the parent alias, through parent updates and source inserts, updates (including key changes that move a row to another group and route) and deletes. A shape throws `NonAggregateExpressionNotInGroupByError` exactly when a non-key source field appears outside an aggregate in its select or HAVING, or the shape spreads a row or holds a nested include; otherwise every published group equals the model and keeps the select's field order. The same grammar checks the opt-in operator laws: a grouped query whose select and HAVING hold no aggregate throws `GroupingOperatorRequiredError` when its keys are passed bare, and works through `groupKeys()`; a bare query builder throws `IncludeOperatorRequiredError` as a field, inside a nested object, or in a conditional branch; a wrapped include inside a nested object throws (a conditional branch may be an include; the includes-collection oracle publishes one, and a compiler that rejects one fails both that oracle and this one). When several errors apply, any one of them passes. Includes are wrapped in `toArray()`, `include()`, or `materialize()`. In three generated histories of six, the driver strips one kind of operator code, the grouping compiler, the include runtime, or the grouping compiler of the `orderBy` aggregate only, from the IR it built. One history in four is a forced history that strips the grouping compiler everywhere or from `orderBy` only, with no, one, or two group keys in turn, to sample each path through `new BaseQueryBuilder(ir)`; IR without its operator code must throw the coded error for the missing operator. A runtime-stripped history gives its include a child query that is valid on its own, so other errors cannot hide the missing runtime. Half the grouping-stripped histories give the top-level query a select of `count()` alone, with an aggregate HAVING when the generated shape has one, so a model error set of exactly `groupingRequired` witnesses the grouped, one-group, and HAVING paths. The top-level and include queries may order their groups with one or two `orderBy` calls, each on an aggregate or on a number or string select field through `$selected`, strings with the documented `en-US` collation: published rows and each parent's `kids` fall into blocks of equal values in that order. An `orderBy` aggregate that no top-level field selects throws `AggregateFunctionNotInSelectError` (now stated in the guide's "Ordering by SELECT Fields"), and a stripped `orderBy` aggregate compiles with the compiler that `groupKeys()` or a select or HAVING aggregate brings. One history in four takes ordered shapes from a grammar whose shapes compile, because generated ordered shapes compile in about one case in forty; the source starts with a key `A`, where locale and code-unit order differ. The property declares twenty-one operator states with `createOracleReach`. Each fixed and random campaign first executes twelve complete structural histories through the shared model, driver and comparison, then 160 generated histories. One reach requires all twenty-one states in the structural set. This guarantee concerns initial publication or compilation only: these inputs have no mutation steps, shadowed alias, or union. A separate generated reach requires the 160 generated histories themselves to enter all twenty-one states and seven additional checkpoints after parent updates, source inserts, updates, deletes, source route moves, a shadowed child alias comparison, and a union-source comparison. Those checkpoints count only after the public model comparison succeeds. Generated coverage remains sampled and can miss a state for a seed; a miss fails and retains the chosen seed and counts. The seven checkpoints do not require every operator state after every mutation or on every route; without the forced select, the one-group and HAVING states were never reached, and a mutant that throws the wrong error class only on the implicit path passed three campaigns. Pinned witnesses in `hand-built-ir-operators.test.ts` cover the old `IncludesSubquery` argument order, an aggregate only in `orderBy` without a select aggregate, and the mixed `orderBy` replay. The compiler before the order step took the query's grouping compiler fails the generated `orderBy` dimension (`top compiles`); an order step that ignores direction, keeps only the first `orderBy` call, or sorts strings by code unit, an include query that ignores direction, a group step that keeps only the first one or two keys, and a check that throws the wrong error class only beside HAVING each fail it (the last passed two of three campaigns before the alone states). A mutant without the include-runtime check, which publishes `kids: null`, and one without the grouping guard, which throws a `TypeError`, both fail it. A mutant that skips the grouping-operator check fails it with a `TypeError`, and one that treats a bare builder as an include fails it with the wrong error class. Open: an `orderBy` term on a raw expression other than an aggregate, and on boolean, Date, `NaN`, or object fields, whose order the docs do not state. The operator laws are generated only for aggregate and grouped selects; for plain selects, `includes.test.ts` pins a bare builder as a field, nested, and in a conditional, because this model publishes one row per group and needs a separate row-per-source model for them. The former seed-dependent string-order reach gap recurred in the main release run on 2026-11-02. The structural histories guarantee the initial premises. They cannot satisfy the separate generated reach. The model, comparisons, reach minimum, fixed seed 2094, and 160 generated runs per campaign remain unchanged. Generated history choices now use named fields rather than a positional tuple. Shared parameter helpers report each selected seed before execution, so a final reach failure after fast-check success has a seed-only campaign replay. Statistics use the exact value `TANSTACK_DB_ORACLE_STATISTICS=1` and survive comparison or reach failures. Removing the structural string history fails the fixed-reach check even when generated histories reach strings. Selecting code-unit order instead of the documented locale order fails the ordinary published-row comparison; seed 2094/path 11 replays that failure directly. The [original repair record](oracle-reviews/2026-11-02-aggregate-select-reach.md) preserves historical evidence. The [nine-item review record](oracle-reviews/2026-11-02-aggregate-select-review.md) records separate counts, generator ablations, replay diagnostics, reproducible commands, and remaining limits. | +| Alias scope identity | `packages/db/tests/query/includes-oracle.property.test.ts` (`includes alpha-renaming across lexical scopes`), `packages/db/tests/query/includes-scope-identity-oracle.ts`, `packages/db/tests/query/includes-alias-shadowing-oracle.test.ts`, `packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts`, `packages/db/tests/query/validate-aliases.test.ts`, [issue #1975 review](oracle-reviews/issue-1975-scope-identity.md) | Four topologies place a source beside a same-named source: a `from()` subquery with an include, a joined `unionAll()` branch with an include, a collapsed top-level pure wrapper with a join subquery, and an include directly on a joined `unionAll()`, which may reuse a branch alias. The grammar crosses zero to two joins, LEFT or INNER, zero to two predicates, plain, ordered-and-limited, or DISTINCT bodies, include forms, and eager or on-demand finite providers, after preload and after source writes. Includes read their source directly, through a join, through a `unionAll()` with an anchor join, or through a `from()` subquery that reads the parent row. One in four scenarios draws any naming; same-scope and union-branch repeats are rejected while ancestor shadowing is admitted. Each checkpoint compares the canonical naming with plain recomputation, the generated naming with the canonical naming, and per-Collection `loadSubset` WHERE clauses. The exact-output companion independently recomputes parent-child rows for direct, QueryRef, union, and implicit-join forms across two equality operand orders, shadowed and renamed aliases, eager and on-demand children, initial publication and parent/child writes. The recursive forms keep a required outer lockId correlation and an independent captured lockName predicate; the union has a nonempty right branch and a later two-branch overlap. It checks enumerable user and virtual key names, user values, multiplicity, sibling helper reuse, a fresh parent/inner QueryRef declaration across a source update, direct, child union-branch, parent union-branch, parent QueryRef, and nested-include rejection of ancestor/descendant builder reuse, one nested grandparent capture, functional callback child input, grouping, captured whole-row and nested-path spreads across shadowed and renamed aliases, eager and on-demand children, and parent/child writes, and query identity. Focused follow-up cells check same-path group keys under shadowing and renaming through parent and child writes, alpha-renamed pure wrappers at public-row and optimized-identity cuts, and indexed lazy demand for direct or wrapped nested includes through anchor and user writes. Wrong-query controls that drop either inner predicate or substitute a parent-shaped callback value fail at the initial public-row comparison. A finite follow-up crosses direct includes and joined QueryRefs with shadowed or renamed aliases, eager or on-demand sources, and child IDs equal to or distinct from an outer filter literal; it checks public rows and on-demand child admission after preload. Another follow-up projects a captured parent rank through a child QueryRef and checks public rows after preload, a child-rank change, and a parent-rank change. The original compiler misrouted the outer filter in four cases, and the original optimizer lost the captured binding in two cases at their first public-row comparison. Every fixed histories and both campaigns fail on `18abceee4`; each of the four optimizer re-mint sites fails a witness when reverted. Arbitrary nested includes, include-inside-subquery forms beyond the checked LEFT joined QueryRef, other join-subquery topologies, `having`, outer joins beyond the bounded RIGHT/FULL matrix, publication events, unload, ordered windows, other lazy join targets, and temporal demand remain outside these owners; they need witnesses before an alias-scope closure claim covers them. Ancestor/descendant builder reuse is rejected before correlation extraction; one helper builder remains legal across a parent QueryRef, union branch, or joined QueryRef and a sibling include child. Six placement/source-mode cells check construction, exact rows after preload, and a source update; the joined placement includes an unmatched manager that would expose a missing helper join. The original source-tree guard rejects all six. A discarded enumeration of a captured proxy cannot change the selected query identity or rows before or after a child update; the retained-marker implementation fails that identity check. The [PR #2079 follow-up](oracle-reviews/issue-2072-alias-shadowing-followup.md) records its RED trace, the repaired spread and nested-QueryRef cells, and the remaining compiler and optimizer evidence gaps. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds a self-join with an inner alias matching the outer joined source, a joined child whose local source competes with inherited parent context in matched and unmatched LEFT joins, captured/local spreads, grandparent whole-row capture, and direct captured-alias selection. These finite cells compare public rows after preload and selected writes; an alias-only lazy-target mutant fails the eager self-join comparison. The reported on-demand failure did not reproduce with the controlled source. A further finite matrix crosses nested FROM or JOIN placement, field or whole-row capture of a shadowed outer joined alias, shadowed or renamed spelling, and eager or on-demand acquisition. It compares source-role rows after preload, child deletion and movement, joined-row update and movement, and parent update; on-demand cases compare nonempty request sets. Disabling projected binding lookup fails all eight cells at the initial public-row assertion; an alias-only lazy-target mutant survives this matrix, so it adds no broader lazy-target claim. A bounded outer-join extension crosses RIGHT and FULL joins, main- or joined-side correlation, direct and QueryRef joined sources, shadowed and renamed aliases, eager and on-demand sources, two parent routes, and seven initial or post-write cuts. Its independent source-role model checks exact public rows, including both absent sides; on-demand cells also require source acquisition. A join-operand extension crosses an ancestor term combined with a local main or joined term, or serving as the whole main key, with equality reversal, alias renaming, source mode, and five public-row cuts. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) records RED and GREEN evidence. A foreign captured join operand must reject: a finite provenance matrix checks root and include placements, matching or different alias spelling, and eager or on-demand sources at construction or preload settlement; valid local and captured-ancestor operands remain controls. A two-LEFT-join history now checks that a later routed QueryRef or captured operand cannot restore an absent shadowed local source, across alias renaming, eager/on-demand mode, two parents, and local and second-joined arrival/removal. It also selects and updates a parent label while joins are matched, checking that the main route retains that ancestor value. A nested joined QueryRef control projects its join key from an inner tag source while the post alias shadows or renames an outer user alias; an independent plain-row model and installed on-demand post/tag rows are compared at preload, a user-key move, and a tag update. A wrong Collection/SourceId pair loads an irrelevant post at preload and fails the installed-row check. Other nested source shapes and real-provider request schedules remain outside this control. The route-work owner compares public rows and assembled parent-route work as irrelevant joined rows grow for direct main-correlated RIGHT/FULL joins in both source modes; an unconditional-route mutant grows from two to six pairs at preload. RIGHT joins correlated on main now join the outer-join row matrix, beside joined-side correlation and FULL controls. An include child rejects a bound join operand declared inside its parent QueryRef or union branch, across matching/renamed aliases and eager/on-demand sources; the original source-tree collector resolves preload instead of rejecting. A joined QueryRef rejects a captured source declared beside it in the enclosing query: the matrix crosses root/include placement, matching/renamed inner alias, and eager/on-demand sources; four true-ancestor controls assert rows, and the permissive sibling-scope mutant fails all eight invalid cells while those controls pass. The primary oracle now extends the hidden-source rejection through two QueryRef or union levels, with matching/renamed aliases and both source modes; a source-tree-binding mutant fails all eight added cells at the rejection cut. An unrelated bound reference is rejected in WHERE, GROUP BY, HAVING, ORDER BY, direct SELECT, and conditional SELECT across matching/renamed aliases; legal local controls and a grouped include with captured ancestor predicates, HAVING, ordering, shadowing, and parent/child writes prevent a blanket rejection. It also rejects foreign references moved into extracted include parent filters or into the removed correlation equality, each across alias spelling and source mode with valid parent controls. Disabling expression validation fails twelve non-join cells; omitting parent-filter or extracted-correlation validation fails four cells each. A separate plain relational model checks RIGHT-to-LEFT, RIGHT-to-RIGHT, FULL-to-FULL, and FULL-to-LEFT two-join chains under shadowing/renaming, both source modes, main/joined/tag correlation, and six source or ancestor writes; a later-FULL-as-LEFT mutant fails two initial public-row comparisons. The route-work owner now compares public rows and parent-route assembly counts at preload and four main-source writes while irrelevant joined rows grow. Other expression forms, arbitrary join-subquery and lazy-target shapes, deeper captures, other chained outer joins, real-provider schedules, and broader source-work bounds remain open under this owner. The alias-scope owner also checks outer grouping through a nested QueryRef and joined source with shadowed or renamed inner alias, eager or on-demand acquisition, and four source/tag writes at five public-row cuts. The tag insertion duplicates one joined source and the plain model counts both copies. A controlled rewrite probe reached an unbound pushed WHERE inside the QueryRef while the bound GROUP BY remained outside; an inner GROUP BY blocked that pushdown. A matcher mutant that ignored binding IDs failed two same-path group cells at the first public-row cut; a dropped-pushed-predicate mutant failed all four new cells by publishing an extra group. This refutes the reported WHERE-rewrite-to-GROUP-BY-matcher route in those shapes; another lost-provenance route needs a concrete fixture. Base/head work probes confirmed an extra pure-wrapper stage after #2079, but the same full-source outer-WHERE request and keyed lazy-join request on both revisions. No wrapper-stage work budget or safe binding remap is established. Bound root refs caused two route-metadata symbol checks per evaluation on the current head versus zero on base, while captured-ancestor rows remain protected by the primary oracle; a compile-time fast path needs a justified work budget and a preservation witness. The same function-form subquery placed twice compiled two results and invoked its callback four times for two source rows on both revisions. Cache comparison read more query properties on the current head, and a stateful callback produced placement-specific values. A callback identity or purity rule and work budget are needed before cache-equivalence changes. | +| Join equality and cold acquisition | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` | Independent recomputation for cold acquisition plus direct join/predicate equivalence across established equality domains. Binary/string and nullish classes, replacement histories, raw on-demand values, and both scan/auto-index paths are explicit; the compound grammar also covers tuple equality, term order, all four join types, controlled cold sources, and keyed histories. Positive acquisition witnesses require a satisfiable tuple. | +| Aggregate selects | [aggregate select oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/aggregate-select-oracle.property.test.ts) | Generated select shapes of aggregate queries with and without `groupBy` by `c.k`, the pair `c.k`, `gt(c.v, 1)`, the triple that adds `gt(c.v, 2)`, `upper(c.k)`, `inArray(c.k, ['a', 'b'])`, a Date-valued `coalesce` or a `NaN`-valued `add`, rebuilt per use (literals, parent fields, the group key, the raw field `c.k`, source fields, `count`/`sum`/`max`, `add`, `caseWhen` with expression or object branches, nested objects, spreads, a nested include) and a HAVING condition that may compare an aggregate, selected as a field or not, at the top level, directly or through a `unionAll`, and in an include whose source may shadow the parent alias, through parent updates and source inserts, updates (including key changes that move a row to another group and route) and deletes. A shape throws `NonAggregateExpressionNotInGroupByError` exactly when a non-key source field appears outside an aggregate in its select or HAVING, or the shape spreads a row or holds a nested include; otherwise every published group equals the model and keeps the select's field order. The same grammar checks the opt-in operator laws: a grouped query whose select and HAVING hold no aggregate throws `GroupingOperatorRequiredError` when its keys are passed bare, and works through `groupKeys()`; a bare query builder throws `IncludeOperatorRequiredError` as a field, inside a nested object, or in a conditional branch; a wrapped include inside a nested object throws (a conditional branch may be an include; the includes-collection oracle publishes one, and a compiler that rejects one fails both that oracle and this one). When several errors apply, any one of them passes. Includes are wrapped in `toArray()`, `include()`, or `materialize()`. In three generated histories of six, the driver strips one kind of operator code, the grouping compiler, the include runtime, or the grouping compiler of the `orderBy` aggregate only, from the IR it built. One history in four is a forced history that strips the grouping compiler everywhere or from `orderBy` only, with no, one, or two group keys in turn, to sample each path through `new BaseQueryBuilder(ir)`; IR without its operator code must throw the coded error for the missing operator. A runtime-stripped history gives its include a child query that is valid on its own, so other errors cannot hide the missing runtime. Half the grouping-stripped histories give the top-level query a select of `count()` alone, with an aggregate HAVING when the generated shape has one, so a model error set of exactly `groupingRequired` witnesses the grouped, one-group, and HAVING paths. The top-level and include queries may order their groups with one or two `orderBy` calls, each on an aggregate or on a number or string select field through `$selected`, strings with the documented `en-US` collation: published rows and each parent's `kids` fall into blocks of equal values in that order. An `orderBy` aggregate that no top-level field selects throws `AggregateFunctionNotInSelectError` (now stated in the guide's "Ordering by SELECT Fields"), and a stripped `orderBy` aggregate compiles with the compiler that `groupKeys()` or a select or HAVING aggregate brings. One history in four takes ordered shapes from a grammar whose shapes compile, because generated ordered shapes compile in about one case in forty; the source starts with a key `A`, where locale and code-unit order differ. The property declares twenty-one operator states with `createOracleReach`. Each fixed and random campaign first executes twelve complete structural histories through the shared model, driver and comparison, then 160 generated histories. One reach requires all twenty-one states in the structural set. This guarantee concerns initial publication or compilation only: these inputs have no mutation steps, shadowed alias, or union. A separate generated reach requires the 160 generated histories themselves to enter all twenty-one states and seven additional checkpoints after parent updates, source inserts, updates, deletes, source route moves, a shadowed child alias comparison, and a union-source comparison. Those checkpoints count only after the public model comparison succeeds. Generated coverage remains sampled and can miss a state for a seed; a miss fails and retains the chosen seed and counts. The seven checkpoints do not require every operator state after every mutation or on every route; without the forced select, the one-group and HAVING states were never reached, and a mutant that throws the wrong error class only on the implicit path passed three campaigns. Fixed histories in `hand-built-ir-operators.test.ts` cover the old `IncludesSubquery` argument order, an aggregate only in `orderBy` without a select aggregate, and the mixed `orderBy` replay. The compiler before the order step took the query's grouping compiler fails the generated `orderBy` dimension (`top compiles`); an order step that ignores direction, keeps only the first `orderBy` call, or sorts strings by code unit, an include query that ignores direction, a group step that keeps only the first one or two keys, and a check that throws the wrong error class only beside HAVING each fail it (the last passed two of three campaigns before the alone states). A mutant without the include-runtime check, which publishes `kids: null`, and one without the grouping guard, which throws a `TypeError`, both fail it. A mutant that skips the grouping-operator check fails it with a `TypeError`, and one that treats a bare builder as an include fails it with the wrong error class. Open: an `orderBy` term on a raw expression other than an aggregate, and on boolean, Date, `NaN`, or object fields, whose order the docs do not state. The operator laws are generated only for aggregate and grouped selects; for plain selects, `includes.test.ts` checks a bare builder as a field, nested, and in a conditional, because this model publishes one row per group and needs a separate row-per-source model for them. The former seed-dependent string-order reach gap recurred in the main release run on 2026-11-02. The structural histories guarantee the initial premises. They cannot satisfy the separate generated reach. The model, comparisons, reach minimum, fixed seed 2094, and 160 generated runs per campaign remain unchanged. Generated history choices now use named fields rather than a positional tuple. Shared parameter helpers report each selected seed before execution, so a final reach failure after fast-check success has a reseeded campaign. Statistics use the exact value `TANSTACK_DB_ORACLE_STATISTICS=1` and survive comparison or reach failures. Removing the structural string history fails the fixed-reach check even when generated histories reach strings. Selecting code-unit order instead of the documented locale order fails the ordinary published-row comparison; seed 2094/path 11 replays that failure directly. The [original repair record](oracle-reviews/2026-11-02-aggregate-select-reach.md) preserves historical evidence. The [nine-item review record](oracle-reviews/2026-11-02-aggregate-select-review.md) records separate counts, generator ablations, replay diagnostics, reproducible commands, and remaining limits. | | Query optimizer semantics | `packages/db/tests/query/optimizer-semantics-oracle.test.ts`, [aggregate review](oracle-reviews/2026-09-28-optimizer-aggregate-pushdown.md), [QueryRef repair](oracle-reviews/2026-09-30-queryref-publication-repair.md), [residual review](oracle-reviews/2026-10-01-pr-1977-residual-convergence.md) | Independent sum recomputation and a materialized-Collection formulation check the first public snapshot of a nested aggregate under a left join. Direct, arithmetic-wrapped, and conditional aggregates distinguish safe from unsafe pushdown; a grouped-key control covers the grouped boundary. Matching and nonmatching global aggregates also cross a nested QueryRef inner join with absent, accepting, and rejecting outer predicates. The matching initial cases fail on the pre-repair revision and pass after the computed-projection lookup repair. A separate fixed residual-convergence grammar checks exact predicate and outer WHERE clause counts at `optimizeQuery` return and after re-optimization: LEFT, RIGHT, INNER, and FULL joins; equality, greater-than, nullable-field `isUndefined`, same-source OR, cross-source equality, clause order, separate/AND form, two active predicates, two residual sources, and an existing residual marker. Plain-array LEFT/RIGHT and three-source join models check first public rows, including unmatched rows. Arbitrary expressions, more complex join chains, and incremental optimizer/publication histories remain outside this owner. | -| QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, shared `subquery-literal-value-oracle.ts`, supporting `subquery-literal-value-driver.ts`, correlated `subquery-literal-correlated-oracle.test.ts` / `subquery-literal-correlated-oracle.ts` with concrete factory/driver support, supplemental `subquery-user-value.test.ts` and `transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md), [chunk 19](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md) | The independent relational models own joined DISTINCT with outer WHERE, aggregate-source updates and joined findOne histories. Other fixed container/no-select/functional/predicate-literal examples are supplemental ordinary tests, not independent oracle scope. Chunk 19 adds one ordinary-JavaScript literal model with 17 forms × two positions × root/QueryRef receivers: 68 histories and three cuts each. It checks complete ordinary rows, event bags/current/previous values, event-time reads and immutable QueryRef callback inputs, with all 204 receiving and 102 callback states reached once. Strict exact-driver admission passes; unchanged production has eight undefined-to-null REDs (P11), ten empty-container-loss REDs (P12), 50 passing new histories and six passing relational neighbors. Twenty-four hostile and four lawful controls pass; partial selections still fail mandatory reach. Chunk 20 receives the same literal law through toArray, array/singleton materialize and Collection facades: 272 exact histories, all 3,264 path/cut states once, 200 main passes and 72 intended P11/P12 REDs; 33 checker controls and six independent facet controls pass. Legal QueryRef children have their own correlation WHERE and preserve the authored routeGroup input until actual projection removes it. Facade layouts are ordered, startup callbacks permit canonical empty values but owe complete ready inputs, and all 272 titles remain distinct for precise selection. Chunk 21 adds a separate source-public type judgment (`subquery-literal-types-oracle.ts` / `.test.ts`, supporting `subquery-literal-types-support.ts`) for those exact 340 authored receivers: 1,870 actual QueryResult, Collection-row, contextual-input, inline and facade/API markers. All consumers compile without diagnostics under TypeScript 5.9.3, and all 2,550 syntax/admission/marker states execute once. Unchanged production has 280 intended RED histories: 40 raw-nullish precision failures and 272 contextual-input or propagated-inline metadata failures, with 32 overlapping; 60 native histories and 55 checker/source controls pass. Independent review exposed erased call/construct signatures on records; the recorder now retains those declared capabilities and eight existing empty/nonempty × root/nested record controls reject invented signatures, after all eight failed under the original checker. Only the separate four virtual fields at named query-output or published/source Collection row roots are excluded from this ordinary-value judgment; callbacks and inline children remain strict. The full static callback metadata grammar remains open under existing `virtual-row-fields-types-oracle.test.ts`, with the literal sources as receiving witnesses of its accepted P10 law. Source-public inference does not establish declaration portability; an exported inferred factory currently gives TS4058 and is preserved for O22, including exported Collection and built/installed controls. Broader mixed-expression admission, sparse authored arrays, symbols, ordinary-record readonly modifiers, descriptor flags, arbitrary recursion and provider/framework schedules remain open with their sequential receiving owners. Metadata attribution stays with its separate owner; the four reserved fields are excluded only at each observed root row. No production repair or closure is claimed. | +| QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, shared `subquery-literal-value-oracle.ts`, supporting `subquery-literal-value-driver.ts`, correlated `subquery-literal-correlated-oracle.test.ts` / `subquery-literal-correlated-oracle.ts` with concrete factory/driver support, supplemental `subquery-user-value.test.ts` and `transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md), [chunk 19](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md) | The independent relational models own joined DISTINCT with outer WHERE, aggregate-source updates and joined findOne histories. Other fixed container/no-select/functional/predicate-literal examples are supplemental ordinary tests, not independent oracle scope. Chunk 19 adds one ordinary-JavaScript literal model with 17 forms × two positions × root/QueryRef receivers: 68 histories and three cuts each. It checks complete ordinary rows, event bags/current/previous values, event-time reads and immutable QueryRef callback inputs, with all 204 receiving and 102 callback states reached once. Strict exact-driver admission passes; unchanged production has eight undefined-to-null REDs (P11), ten empty-container-loss REDs (P12), 50 passing new histories and six passing relational neighbors. Twenty-four mutant and four lawful controls pass; partial selections still fail mandatory reach. Chunk 20 receives the same literal law through toArray, array/singleton materialize and Collection facades: 272 exact histories, all 3,264 path/cut states once, 200 main passes and 72 intended P11/P12 REDs; 33 checker controls and six independent facet controls pass. Legal QueryRef children have their own correlation WHERE and preserve the authored routeGroup input until actual projection removes it. Facade layouts are ordered, startup callbacks permit canonical empty values but owe complete ready inputs, and all 272 titles remain distinct for precise selection. Chunk 21 adds a separate source-public type judgment (`subquery-literal-types-oracle.ts` / `.test.ts`, supporting `subquery-literal-types-support.ts`) for those exact 340 authored receivers: 1,870 actual QueryResult, Collection-row, contextual-input, inline and facade/API markers. All consumers compile without diagnostics under TypeScript 5.9.3, and all 2,550 syntax/admission/marker states execute once. Unchanged production has 280 intended RED histories: 40 raw-nullish precision failures and 272 contextual-input or propagated-inline metadata failures, with 32 overlapping; 60 native histories and 55 checker/source controls pass. Independent review exposed erased call/construct signatures on records; the recorder now retains those declared capabilities and eight existing empty/nonempty × root/nested record controls reject invented signatures, after all eight failed under the original checker. Only the separate four virtual fields at named query-output or published/source Collection row roots are excluded from this ordinary-value judgment; callbacks and inline children remain strict. The full static callback metadata grammar remains open under existing `virtual-row-fields-types-oracle.test.ts`, with the literal sources as receiving witnesses of its accepted P10 law. Source-public inference does not establish declaration portability; an exported inferred factory currently gives TS4058 and is preserved for O22, including exported Collection and built/installed controls. Broader mixed-expression admission, sparse authored arrays, symbols, ordinary-record readonly modifiers, descriptor flags, arbitrary recursion and provider/framework schedules remain open with their sequential receiving owners. Metadata attribution stays with its separate owner; the four reserved fields are excluded only at each observed root row. No production repair or closure is claimed. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | -| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/current-generation durable rows, late old-response rejection, and final cleanup comparisons remain. Each round also remembers its retired storage target and checks that the late response's absent key reaches neither that target after delivery nor after replacement settlement. The seventh declared state requires this retired-target observation. This constrains the expired session's row; unrelated retained rows remain allowed, and a separate run with a valid claim can still use a retired generation. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated lane. | +| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand grammar checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event grammar checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end grammar checks that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page grammar (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit grammar checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page grammar deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client grammar in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/current-generation durable rows, late old-response rejection, and final cleanup comparisons remain. Each round also remembers its retired storage target and checks that the late response's absent key reaches neither that target after delivery nor after replacement settlement. The seventh declared state requires this retired-target observation. This constrains the expired session's row; unrelated retained rows remain allowed, and a separate run with a valid claim can still use a retired generation. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated grammar. | | PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. A metadata-bearing `Collection.update` passes through an observed SDK watcher callback to the public and SQLite rows and an exact queued CRUD patch. Held callback ordering and synthetic zero-field cases remain controlled-only, and remote upload has no receiving fixture here. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | -| SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts) (fixed histories and the source-commit scheduler-order test live in `packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts`), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts), [persisted sync problems](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts), [persisted reload replacement](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The persisted sync-problem oracle (`packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts`) owns how the wrapper reports failures of its own remote work and to which sync run: a later successful remote request ends the wrapper's remote failures (maintainer decision: a coarse clear, by any remote request that succeeds, the same one repeated or another; 8 histories failed before it, and a wrapper without the clear fails 7); 35 histories over eight sites (coordinator release, the adapter's own `unloadSubset` throw or reject, scoped refresh ensure and release, rotated reacquisition through a routed or single-process coordinator, and a reload's cleanup release), each crossed with ready or loading and with the current run or a run that `reload()` retired before or after the new run's `markReady()`. A remote failure is a ready Collection's failure reason and never a crash; before the first ready it is only logged; an `unloadSubset` breach surfaces uncaught; a retired run's failures change nothing in the new run and never reject its cleanup. HEAD failed 29 histories; mutants that crash, remove the generation fence, or let a cleanup release reject each fail. Limits: controlled adapter and coordinator doubles; no Browser or Electron follower routing; no coordinator retry queue. The persisted reload-replacement oracle (`packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts`) checks over 40 histories that the wrapper forwards `begin({ snapshot })` and `truncate({ markReady })`, that its restore and metadata commits replace nothing and do not complete a reload, and that durable rows equal visible rows after a replacement. HEAD's wrapper failed 15; a dropped snapshot, a non-durable snapshot, dropped truncate options, and a restore marked as a snapshot each fail. Limits: eager Collection only, in-memory adapter. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The per-election cells in `browser-coordinator.test.ts` (fixed tests; no oracle owner (tracked in #2264)) also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | +| SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts) (fixed histories and the source-commit scheduler-order test live in `packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts`), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts), [persisted sync problems](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts), [persisted reload replacement](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The persisted sync-problem oracle (`packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts`) owns how the wrapper reports failures of its own remote work and to which sync run: a later successful remote request ends the wrapper's remote failures (maintainer decision: a coarse clear, by any remote request that succeeds, the same one repeated or another; 8 histories failed before it, and a wrapper without the clear fails 7); 35 histories over eight sites (coordinator release, the adapter's own `unloadSubset` throw or reject, scoped refresh ensure and release, rotated reacquisition through a routed or single-process coordinator, and a reload's cleanup release), each crossed with ready or loading and with the current run or a run that `reload()` retired before or after the new run's `markReady()`. A remote failure is a ready Collection's failure reason and never a crash; before the first ready it is only logged; an `unloadSubset` breach surfaces uncaught; a retired run's failures change nothing in the new run and never reject its cleanup. HEAD failed 29 histories; mutants that crash, remove the generation fence, or let a cleanup release reject each fail. Limits: controlled adapter and coordinator doubles; no Browser or Electron follower routing; no coordinator retry queue. The persisted reload-replacement oracle (`packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts`) checks over 40 histories that the wrapper forwards `begin({ snapshot })` and `truncate({ markReady })`, that its restore and metadata commits replace nothing and do not complete a reload, and that durable rows equal visible rows after a replacement. HEAD's wrapper failed 15; a dropped snapshot, a non-durable snapshot, dropped truncate options, and a restore marked as a snapshot each fail. Limits: eager Collection only, in-memory adapter. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The per-election cells in `browser-coordinator.test.ts` (fixed tests; no oracle owner (tracked in #2264)) also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO mutant control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition grammars state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are fixed as failing under the known limit [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand grammar checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is fixed under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab grammar runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | | Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation; an empty key counts as none, and a nonempty key runs the query. React no longer accepts dependency arrays. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React retains two shared-suite registrations, but both now call the same derived-identity API; the registration still named `react (dependency list)` does not establish a second production path. The earlier five failures from skipping a dependency-list check at first compile were evidence for the removed API. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. The former React dependency-list/`DbClient` witness is historical; it does not establish current reuse or hydration behavior. The shared conformance rejection scenarios currently check that, after a rejected rerender, two reads each throw or hold no rows. Reactive drivers accept an unhashable query, as described below. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in fixed [descriptor release tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release.svelte.test.ts) (no oracle owner; tracked in #2264). A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) owns the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React exercises applicable causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query rejected during compilation, a single-result infinite query, and a render abandoned by a sibling throw or suspension) through live, infinite, and Suspense hooks with derived identity or changed explicit `queryKey`, plus ordinary-hook configurations that disable reclamation, at initial mounts or rerenders and through four readers. Explicit keys bypass source-ID and derived-IR rejection; single-result rejection applies only to infinite hooks, and abandoned-after-hook histories exclude Suspense because its preload already starts the sources. Svelte crosses collision, unhashable, factory, compile, and abandoned-setup causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isLoadingError`, fails all six collision cells. The earlier dependency-list Suspense cache-claim failures are historical. Current ordinary [source-ID tests](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse.test.tsx) check separate client-scoped Suspense hooks and an initial suspended retry with different same-ID sources; they use the current derived-identity API. These fixed histories are supplemental tests, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. React ordinary/infinite hooks and Svelte's compiled query release at their render boundaries. The current React Suspense rejection histories fail within its shared ordinary-hook preparation; removing only the outer Suspense wrapper's catch is an unreached mutant, not a demonstrated kill. The former dependency-array SSR history was removed because its API is absent from the RC; it had executed no cells in the preceding grammar. Routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23), or after the unsubscribed GC floor when the hook's config disables that reclaim (`gcTime` 0 or `Infinity`, or `startSync: false`); both release grammars add these causes, React adds the three config paths and Svelte a `gcTime: 0` setup, and waiting only for the reclaim fails those cells; removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for the current React derived-identity path and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Preact, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the health tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | | Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. | | Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | -| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-oracle.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph-oracle.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts), [murmur stream](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor-oracle.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, structural object keys in code-unit order (the flat-value, identity and consolidation grammars include keys that tie under locale collation), murmur byte framing against a byte-at-a-time reference (block mixing, the finalizer and symbol framing have no owner there), Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also pins that objects of another class differ, that plain and null-prototype objects are one class, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner checks equal-hash consequences for independently constructed flat values. Distinct type identities are checked by the hash-identity owner through `equalHashValues`, including forced digest collisions; distinct 32-bit digests are not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | +| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-oracle.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph-oracle.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts), [murmur stream](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor-oracle.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, structural object keys in code-unit order (the flat-value, identity and consolidation grammars include keys that tie under locale collation), murmur byte framing against a byte-at-a-time reference (block mixing, the finalizer and symbol framing have no owner there), Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this grammar. The deep-equality owner also checks that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also checks that objects of another class differ, that plain and null-prototype objects are one class, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner checks equal-hash consequences for independently constructed flat values. Distinct type identities are checked by the hash-identity owner through `equalHashValues`, including forced digest collisions; distinct 32-bit digests are not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; fixed cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` fixes how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | | Mixed-kind ordered prefixes | [mixed-kind public-order oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/mixed-index-order-oracle.test.ts) | A separate unindexed ready Collection computes the public ordered two-row prefix for the same keyed rows as an eager BasicIndex or BTreeIndex Collection. Five histories cross number/bigint, boolean/number, object/string, and array/object values, with insert, update, and delete transitions into and out of mixed domains. Ascending and descending reads use one ascending physical index, so descending checks exercise ReverseIndex. After preload and after both source copies commit each step, exact direct and live-query IDs must match the scan. Same-kind checkpoints verify that safe indexes still serve ordered reads. The original type-name index order failed eight ascending public-prefix checks. A second numeric source Map models a finite on-demand provider: a bigint enters and leaves the visible prefix, deletion requires an uninstalled row, and an offset window reaches a cursor after the retained BTree index loses ordered-plan eligibility. Public IDs match the independent provider order at each settled window; controls withholding either requested row fail those assertions. This owner does not define a global precedence among unlike values, cover in-place value mutation or arbitrary mixed-kind pairs, or prove that every retained on-demand traversal is safe after its source domain changes. | -| WHERE predicate publication | [WHERE predicate publication oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts), [`in` evaluator](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts) ([review](oracle-reviews/perf-in-constant-set.md)) | An independent Kleene evaluator judges `eq`/`not`/`and`/`or` predicates over strings, a normalization-prefixed string, booleans, numbers, `NaN`, a valid Date, `null`, a missing field, and virtual fields. Snapshot histories with pending optimistic inserts compare a live query, direct subscribers with and without initial state, and `currentStateAsChanges`; change histories apply multi-key sync transactions, including reinsertion of a key deleted earlier, and compare every consumer's key set after each commit, on scan and `BasicIndex` paths. Peer subscribers share one field with different literals, plus one on a virtual field, so every published change must reach exactly the subscribers whose predicate it can satisfy; routing mutants that ignored the previous value, ignored the field, delivered a change twice, or routed while stale published rows awaited reconciliation fail here. Fixed witnesses cover a layout-only publication reaching a filtered subscriber as one empty batch, a filtered subscriber's empty Collection-readiness batch, retraction of a vanished row after eager cleanup and restart, and the new row plus exact subscriber update payload when a same-key row stays TRUE. A stale-live-value mutant survives the generated membership checks but fails the payload witness. A focused [property-visibility test](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-prefilter-property-visibility.test.ts) compares the public unindexed snapshot with the enriched-row contract for inherited, non-enumerable, enumerable-own, and nested getter paths; the pre-fix stored-row shortcut failed three of its four controls. Other property-descriptor and stateful-getter histories remain outside those fixed controls. Hostile mutants for FALSE-for-UNKNOWN `eq`, `or` or number-literal subscription prefilters, a prefilter that ignores the previous value, a skipped readiness batch, a skip while stale published rows await reconciliation, and an unindexed snapshot scan that reads only synced rows while an optimistic insert or delete changes visibility passed the prior `@tanstack/db` suite and fail here. Change routing withholds a dropped row from an `eq` subscription before its sent key could be recorded, so a mutant that records dropped rows as sent is equivalent for routed predicates in this oracle. A focused `collection-subscription.test.ts` witness checks that a dropped insert or update, alone or beside a matching change, cannot advance a limited subscription's page offset under a routed `eq` and an unrouted `or` predicate; the unrouted cases beside a matching change kill that mutant, and the unrouted `alone` case does not. A loose-equality prefilter is an equivalent mutant: it only skips less. Skipping during truncate replay also survives; the replay's baseline diff re-derives the same retraction, so the guard keeps the prior dataflow without its own witness. Generated cleanup and restart histories for filtered subscribers remain open for the lifecycle publication owner. No-op updates, other comparison operators, Temporal and binary operands, joins, ordering, optimistic updates, and truncate are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. Change routing was later removed in favor of pooled live queries; the routing mutants above are historical, and the same peer laws now check plain subscription dispatch. The unindexed snapshot prefilter was restored for compiled queries as one stored-row `eq` test on a string or boolean literal. Its property-visibility test also covers the reverse direction: a stored row with an inherited or non-enumerable field must still match `isUndefined` on that field, which a scan that evaluated the full predicate on stored rows fails. The `in` evaluator owner checks `in` against an independent linear reference over Dates, `NaN`, `-0`, BigInt, bytes, Temporal values, objects and nullish items, for constant and row-supplied lists. | -| Joined result keys | [Joined result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/join-result-key-oracle.property.test.ts) | A nested-loop model recomputes the pair set of an inner, left, or full join over source keys drawn from plain, comma-bearing, bracket-bearing, and quoted strings, numbers beside the strings that print the same, both infinities, and `NaN`. After preload and each synced group change, the published rows must equal the model's pairs and the result key count must equal the row count. The comma-joined key encoding fails its two pinned histories and both campaigns; plain `JSON.stringify`, which prints infinities as the missing-side `null`, fails the pinned infinity history and both campaigns. A pinned history in which a `NaN`-keyed pair leaves and re-forms fails when the join index compares source-key prefixes with `===`. Joins over subqueries, more than two sources, custom `getKey`, and optimistic mutations are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | -| D2 Index storage | [Index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/index-refinement-oracle.property.test.ts) | A plain-`Map` model sums multiplicities under a type-and-value identity in which `NaN` equals `NaN` and `-0` equals `0`. Histories of up to twelve additions to two keys mix prefixed arrays with `NaN`, `0`, `-0`, `1`, `'1'`, `1n`, and `'a'` prefixes and unprefixed values, with cancellation and reappearance. After each addition, `get` and `has` must equal the model. Each step also checks that `get` returns what `getIterator` yields, in its order, since the two walk the storage separately. Comparing prefixes with `===`, which disagrees with the `Map` that holds them, fails three pinned histories and both campaigns; the `-0` control passes under both. Every history also runs with `prefixIdentity`, where values under one key with the same prefix are one value; mutants that merge unprefixed values or ignore the option in a prefix map fail. The [structural identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/structural-identity-oracle.property.test.ts) checks that `structuralIdentity` agrees with D2 value identity for equal representations (array subclasses, class instances, null prototypes, key order, non-enumerable symbols) and independent values, including separator-bearing strings and keys, enumerable symbol keys, Dates, RegExps with numeric `lastIndex`, Maps and Sets with zero to two entries (the second Set member is a fresh tagged array, so primitive deduplication and unwrapped reordering are outside this lane), arrays with an optional enumerable `note` field, binary values, holes, deep nesting, and a cyclic value; an independent normal-form model decides equality and is checked against `equalHashValues`. The model expands `deep` shorthand into nested arrays. A pinned equal `deep`/array pair and adjacent-depth control guard the model boundary exposed by seed 1158727847/path 255. The equal-pair grammar crosses same-spec values with explicit-array/`deep` equivalent specs; the independent-pair grammar uses two specs. Both run fixed and fresh campaigns with guarded one-case seed/path replay. Pinned same-description symbols and equal-body Map/Set/object pairs expose collisions that random independent pairs rarely reach. Mutants that stringify symbols, collapse those three object-kind tags, drop a string's or key's length or an array's length, or omit Date and RegExp headers fail it. A first-entry-only Map/Set mutant and an array-identity mutant that ignores enumerable non-index fields fail the pinned comparisons; lowering the depth cap is equivalent, because the cap only bounds work. Open: reference-valued RegExp `lastIndex` can give D2-unequal values one defined identity and merge `groupBy` contributions ([#2192](https://github.com/TanStack/db/issues/2192)); the structural-identity and incrementalization owners need a production repair and receiving witness. Temporal values, other opaque reference values, large binary values, getters/proxies, and mutation between observations are outside this generated grammar. groupBy's use of it is owned by the incrementalization law: its string-extrema lane fails a string identity without a length prefix, and a focused groupBy witness fails when the group key is left out of the identity. Compaction, presence tracking, and structural payloads are outside this owner; a focused test checks that an index built without presence (as reduce builds its indexes) throws when presence is read; the [reduce fast-path oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts) compares the fast path's emitted entries one by one with the general path's, over keys that move between the paths, hash-merged outputs, -0, and zero and negative multiplicities, while an unchanged `NaN` output is owned by a focused reduce test; the join operator tests and incrementalization law own operator behavior. | -| Pooled live queries | [pooled live query oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-oracle.property.test.ts) | An independent `eq` model (nullish UNKNOWN, Date as timestamp, `NaN` equal to `NaN`, `-0` equal to `0`) gives membership; a live-query Collection compiled for the same query gives order, values, and status. Histories mix sync transactions, optimistic writes confirmed or rolled back, peer mounts and unmounts, and source cleanup and restart, including cleanup while a write is pending and a mount between cleanup and restart. Wholesale observers must match after every step, granular changes must match a reference observer by type, key, value, and previous value and replay to the model's rows, the layout revision must advance when keys reorder, and observing must never build the live-query Collection. Peers may add a residual `not(eq(...))` conjunct, which the model evaluates itself; a view that ignores it, reports an unfiltered `entries()`, or delivers a row entering or leaving it as an update fails it. Other residual operators use the same compiler evaluator but are not generated. Peers may also order their rows by `id` or by a `g` that can be null, with explicit `nulls`; order comes from the reference. A comparator that ignores direction or `nulls`, an order left out of the partition key, and key order instead of the query's order each fail the pinned reorder history and both campaigns. A partition that ignores a row's previous group, orders by arrival, skips normalization, skips the initial snapshot, follows the source's status after cleanup, or never terminates fails it. Two shared conformance scenarios (`eq-filter-rows`, `eq-filter-peers`) run the pooled path under React, Vue, Solid, Svelte, and Angular; a partition that ignores a row's previous group fails both under every adapter, and `eq-filter-peers` checks through `subscriberCount` that each adapter shares one source subscription per partition while mounted and releases every source subscription after the final framework unmount. The [resource-lifetime oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts) compares when a pooled query and a live-query Collection release their sources across `gcTime` 1, 100, 0, and `Infinity`, with and without a subscriber, and with no `gcTime`, where both use the live-query Collection default, checks that a partition waits for its views' longest `gcTime`, checks that a view resubscribed after its partition released follows later source writes as its live-query Collection does, including a view with a residual conjunct, and checks that a partition that subscribes again still serves new mounts from one source subscription. A generated cleanup-admission lane (`pooled-live-query.cleanup-admission`) crosses 2–3 views of one partition, observed or not, read by a subscribed live query or not, and view cleanups with or without `dependents`; it checks the model's rejection rule and rows and status against live-query Collections. A view that gives up its partition share when its cleanup rejects fails it. A view whose own cleanup fulfilled must show `cleaned-up` with no rows (law 14, maintainer decision), checked against the model and the live-query Collection; a view that keeps its source's status or its group's rows fails it. Further focused witnesses check that a partition keeps groups only for values that have rows or watchers, that a detached reader never sees a dropped group's revision reused, keep a pooled query's public Collection live while the view is observed, accept it as a query source, and check that a pooled query turns terminal when source cleanup starts while the adapter's cleanup is still pending, as a live-query Collection does. A detached wholesale reader of every view, kept after its peer unmounts, must match the live-query Collection at every checkpoint, and generated histories cross the dependents cascade with readers on watched and unwatched groups that hold rows (declared reach); a cleanup that empties groups without advancing their revisions, or advances only watched groups, fails it. Every view and its live-query Collection also carry an app status listener that throws; each listener gets its own call and the throw is reported uncaught, so a view that lets a listener's throw stop the others or reject the source's cleanup fails it. Generated callback-boundary histories also compare ordinary compiled and pooled core observers across return/throw, Error/undefined, zero through three healthy peers, callback position before/after peers, and `gcTime` 1–3. Failed granular bootstrap retains no unreturned ownership after grace; a held cascade cleanup (`cleanup({ dependents: true })`) gives every admitted wholesale observer one terminal `cleaned-up` snapshot/callback despite a sibling throw, and an ended partition stops following its source's health, and preserves the exact failure at settlement. Original production and independent rollback-only or all-peer-only repairs fail the respective ownership or cleanup-start comparisons. These controlled core receiving paths do not establish framework schedules, arbitrary callback programs or provider shutdown. A pinned oracle witness checks that an `eq` path whose getter throws excludes the row on both paths; a pooled path that rethrows fails it. The pooled oracle's grammar never releases a partition, so release and resubscribe histories rely on these fixed witnesses; a fixed delay, a missing unsubscribed floor, a last-`gcTime`-wins rule, and releasing at `gcTime` 0 each fail it. Removing the partition's final-listener release survives the mounted row checks but fails the shared framework unmount checkpoint and core release-timing owner. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. The shared adapter conformance suite checks the opt-in boundary in every framework: queries without `pool` keep a source subscription each in the function, config, and parameterized forms, and a pooled parameterized query follows its parameter between pool groups beside an unchanged peer. Pooling every query by default, a pooled identity that ignores literal values (React then reuses the old query), and an Angular `params` form that drops `pool` each fail it. `pooled-param-change` also reads each driver's publication log: every value published after a switch holds only the new group's rows. Each driver also logs its keyed `state` map at the same points, and the law applies to it. A pooled view that serves the previous view's rows until a later task replaces them passes every final-state check and fails only the log, in all six drivers. A view that sends the previous rows as its first change set fails the `state` log in Vue; Solid rebuilds its map from a snapshot within the same turn, and Angular builds its map from `entries()`, so that mutant cannot reach them. The `invalid-pool` scenario requires `InvalidPoolOptionError` for `true` and for `false` (the value adapters pass to say a hook cannot pool), alone, beside `gcTime`, beside `queryKey`, on a parameterized query, and on a disabled config or parameterized query, in all six drivers; a check in only one of the three readers of `pool` fails it in four of them, and Angular's `params` form failed the `false` and disabled cases until it checked its pool first. The shared `grouping-operator-switch` scenario re-renders a grouped query without `groupKeys()`, and `compile-error-after-rerender` re-renders to a select that mixes a source field with an aggregate; both require the error and no rows after it. A binding that keeps its last compiled query when compilation throws fails both in Solid, Svelte, and Angular; one that throws once and then serves that query fails the no-rows check in Solid and Svelte, and cannot occur in Angular, whose `computed` rethrows a cached error until an input changes. In `grouping-operator-switch`, React and Preact kept the grouped query before query identity held operator code, and Vue served the previous rows before its compile error stopped reaching readers through a throwing computed (also on `main` at `5649a07d4`). | -| Live-query deferred acquisition | [deferred acquisition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts), [React Suspense warm-cache witness](https://github.com/TanStack/db/blob/main/packages/react-db/tests/suspense-warm-cache.test.tsx) | An independent model of the law that a live-query Collection starts no provider work before a subscriber or a preload in its current sync run crosses seven source states (eager or on-demand, idle, already started or render-deferred, and an on-demand source that already holds the rows), an unordered query and an ordered window, one or two live-query levels, and eleven histories: start and read, subscribe or preload after a start, subscribe or preload without one, deferred subscription and resumption with or without a start, cleanup before a subscriber, peer start, source truncate, and restart after a run that had one. After every command it checks source sync-run starts, acquisition attempts, readiness when the call returns (the synchronous observation cut), settled readiness, and rows. A pooled block checks that building a pooled view starts no idle eager source until a view subscribes or preloads, and eight DbClient histories check that a preload answered by a pending or settled stream still resumes acquisition across eager/on-demand idle and render-deferred sources. The React streaming witness receives the on-demand render-deferral premise, starts local acquisition before the stream settles, and retains server rows until browser acquisition completes. Mutants that never defer, keep the subscriber-or-preload fact across cleanup, ignore preload, never resume a builder's subscriptions, let a deferring subscriber start a base source, build a pooled partition without deferral, never resume it, mark a preload one microtask late, or drop the stream's preload mark each fail it. The React witness checks that `useLiveSuspenseQuery` over a source that already holds the rows renders without the fallback, for eager and on-demand sources and both shapes; creating the Suspense collection started, or not preloading it in render, fails it. A later review added `peer` and `truncate` commands: a peer that starts an idle on-demand source, or a truncate on a running one, made the live query acquire before any subscriber, and both guards are killed separately. Pinned blocks check that a subscriber surviving cleanup is served after a restart while a surviving deferring subscriber is not, and that `toArrayWhenReady()` and `stateWhenReady()` count as a preload. A pinned block checks that a deferring live query is not counted in its source's `subscriberCount` or `subscribers:change` until its first subscriber, while still keeping the source from garbage collection; a Query Collection test checks that it causes no refetch. Another pinned block checks that a preload or subscriber requested by an inner live query's status handler, while the outer query creates its subscription to it, still resumes acquisition. Solid's Suspense receiving witnesses check that a resource whose preload settles first retains its live-query Collection while its sibling remains pending past the unsubscribed floor, then releases that temporary owner on both commit and abandonment, and that a preload settling after its owner unmounts cannot publish an error through the abandoned result. Open: source failure and asynchronous loads before resumption, which the subscription lifecycle oracle owns once acquisition resumes; a resume that throws, which leaves later sources unstarted only for a live query that is already in error; asynchronous sources; and arbitrary Suspense schedules with a `DbClient` beyond the bounded abandoned-render and streaming handoffs recorded above. | +| WHERE predicate publication | [WHERE predicate publication oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts), [`in` evaluator](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts) ([review](oracle-reviews/perf-in-constant-set.md)) | An independent Kleene evaluator judges `eq`/`not`/`and`/`or` predicates over strings, a normalization-prefixed string, booleans, numbers, `NaN`, a valid Date, `null`, a missing field, and virtual fields. Snapshot histories with pending optimistic inserts compare a live query, direct subscribers with and without initial state, and `currentStateAsChanges`; change histories apply multi-key sync transactions, including reinsertion of a key deleted earlier, and compare every consumer's key set after each commit, on scan and `BasicIndex` paths. Peer subscribers share one field with different literals, plus one on a virtual field, so every published change must reach exactly the subscribers whose predicate it can satisfy; routing mutants that ignored the previous value, ignored the field, delivered a change twice, or routed while stale published rows awaited reconciliation fail here. Fixed witnesses cover a layout-only publication reaching a filtered subscriber as one empty batch, a filtered subscriber's empty Collection-readiness batch, retraction of a vanished row after eager cleanup and restart, and the new row plus exact subscriber update payload when a same-key row stays TRUE. A stale-live-value mutant survives the generated membership checks but fails the payload witness. A focused [property-visibility test](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-prefilter-property-visibility.test.ts) compares the public unindexed snapshot with the enriched-row contract for inherited, non-enumerable, enumerable-own, and nested getter paths; the pre-fix stored-row shortcut failed three of its four controls. Other property-descriptor and stateful-getter histories remain outside those fixed controls. Mutant for FALSE-for-UNKNOWN `eq`, `or` or number-literal subscription prefilters, a prefilter that ignores the previous value, a skipped readiness batch, a skip while stale published rows await reconciliation, and an unindexed snapshot scan that reads only synced rows while an optimistic insert or delete changes visibility passed the prior `@tanstack/db` suite and fail here. Change routing withholds a dropped row from an `eq` subscription before its sent key could be recorded, so a mutant that records dropped rows as sent is equivalent for routed predicates in this oracle. A focused `collection-subscription.test.ts` witness checks that a dropped insert or update, alone or beside a matching change, cannot advance a limited subscription's page offset under a routed `eq` and an unrouted `or` predicate; the unrouted cases beside a matching change kill that mutant, and the unrouted `alone` case does not. A loose-equality prefilter is an equivalent mutant: it only skips less. Skipping during truncate replay also survives; the replay's baseline diff re-derives the same retraction, so the guard keeps the prior dataflow without its own witness. Generated cleanup and restart histories for filtered subscribers remain open for the lifecycle publication owner. No-op updates, other comparison operators, Temporal and binary operands, joins, ordering, optimistic updates, and truncate are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. Change routing was later removed in favor of pooled live queries; the routing mutants above are historical, and the same peer laws now check plain subscription dispatch. The unindexed snapshot prefilter was restored for compiled queries as one stored-row `eq` test on a string or boolean literal. Its property-visibility test also covers the reverse direction: a stored row with an inherited or non-enumerable field must still match `isUndefined` on that field, which a scan that evaluated the full predicate on stored rows fails. The `in` evaluator owner checks `in` against an independent linear reference over Dates, `NaN`, `-0`, BigInt, bytes, Temporal values, objects and nullish items, for constant and row-supplied lists. | +| Joined result keys | [Joined result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/join-result-key-oracle.property.test.ts) | A nested-loop model recomputes the pair set of an inner, left, or full join over source keys drawn from plain, comma-bearing, bracket-bearing, and quoted strings, numbers beside the strings that print the same, both infinities, and `NaN`. After preload and each synced group change, the published rows must equal the model's pairs and the result key count must equal the row count. The comma-joined key encoding fails its two fixed histories and both campaigns; plain `JSON.stringify`, which prints infinities as the missing-side `null`, fails the fixed infinity history and both campaigns. A fixed histories in which a `NaN`-keyed pair leaves and re-forms fails when the join index compares source-key prefixes with `===`. Joins over subqueries, more than two sources, custom `getKey`, and optimistic mutations are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | +| D2 Index storage | [Index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/index-refinement-oracle.property.test.ts) | A plain-`Map` model sums multiplicities under a type-and-value identity in which `NaN` equals `NaN` and `-0` equals `0`. Histories of up to twelve additions to two keys mix prefixed arrays with `NaN`, `0`, `-0`, `1`, `'1'`, `1n`, and `'a'` prefixes and unprefixed values, with cancellation and reappearance. After each addition, `get` and `has` must equal the model. Each step also checks that `get` returns what `getIterator` yields, in its order, since the two walk the storage separately. Comparing prefixes with `===`, which disagrees with the `Map` that holds them, fails three fixed histories and both campaigns; the `-0` control passes under both. Every history also runs with `prefixIdentity`, where values under one key with the same prefix are one value; mutants that merge unprefixed values or ignore the option in a prefix map fail. The [structural identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/structural-identity-oracle.property.test.ts) checks that `structuralIdentity` agrees with D2 value identity for equal representations (array subclasses, class instances, null prototypes, key order, non-enumerable symbols) and independent values, including separator-bearing strings and keys, enumerable symbol keys, Dates, RegExps with numeric `lastIndex`, Maps and Sets with zero to two entries (the second Set member is a fresh tagged array, so primitive deduplication and unwrapped reordering are outside this grammar), arrays with an optional enumerable `note` field, binary values, holes, deep nesting, and a cyclic value; an independent normal-form model decides equality and is checked against `equalHashValues`. The model expands `deep` shorthand into nested arrays. A fixed equal `deep`/array pair and adjacent-depth control guard the model boundary exposed by seed 1158727847/path 255. The equal-pair grammar crosses same-spec values with explicit-array/`deep` equivalent specs; the independent-pair grammar uses two specs. Both run fixed and fresh campaigns with guarded one-case seed/path replay. Fixed same-description symbols and equal-body Map/Set/object pairs expose collisions that random independent pairs rarely reach. Mutants that stringify symbols, collapse those three object-kind tags, drop a string's or key's length or an array's length, or omit Date and RegExp headers fail it. A first-entry-only Map/Set mutant and an array-identity mutant that ignores enumerable non-index fields fail the fixed comparisons; lowering the depth cap is equivalent, because the cap only bounds work. Open: reference-valued RegExp `lastIndex` can give D2-unequal values one defined identity and merge `groupBy` contributions ([#2192](https://github.com/TanStack/db/issues/2192)); the structural-identity and incrementalization owners need a production repair and receiving witness. Temporal values, other opaque reference values, large binary values, getters/proxies, and mutation between observations are outside this generated grammar. groupBy's use of it is owned by the incrementalization law: its string-extrema grammar fails a string identity without a length prefix, and a focused groupBy witness fails when the group key is left out of the identity. Compaction, presence tracking, and structural payloads are outside this owner; a focused test checks that an index built without presence (as reduce builds its indexes) throws when presence is read; the [reduce fast-path oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts) compares the fast path's emitted entries one by one with the general path's, over keys that move between the paths, hash-merged outputs, -0, and zero and negative multiplicities, while an unchanged `NaN` output is owned by a focused reduce test; the join operator tests and incrementalization law own operator behavior. | +| Pooled live queries | [pooled live query oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-oracle.property.test.ts) | An independent `eq` model (nullish UNKNOWN, Date as timestamp, `NaN` equal to `NaN`, `-0` equal to `0`) gives membership; a live-query Collection compiled for the same query gives order, values, and status. Histories mix sync transactions, optimistic writes confirmed or rolled back, peer mounts and unmounts, and source cleanup and restart, including cleanup while a write is pending and a mount between cleanup and restart. Wholesale observers must match after every step, granular changes must match a reference observer by type, key, value, and previous value and replay to the model's rows, the layout revision must advance when keys reorder, and observing must never build the live-query Collection. Peers may add a residual `not(eq(...))` conjunct, which the model evaluates itself; a view that ignores it, reports an unfiltered `entries()`, or delivers a row entering or leaving it as an update fails it. Other residual operators use the same compiler evaluator but are not generated. Peers may also order their rows by `id` or by a `g` that can be null, with explicit `nulls`; order comes from the reference. A comparator that ignores direction or `nulls`, an order left out of the partition key, and key order instead of the query's order each fail the fixed reorder history and both campaigns. A partition that ignores a row's previous group, orders by arrival, skips normalization, skips the initial snapshot, follows the source's status after cleanup, or never terminates fails it. Two shared conformance scenarios (`eq-filter-rows`, `eq-filter-peers`) run the pooled path under React, Vue, Solid, Svelte, and Angular; a partition that ignores a row's previous group fails both under every adapter, and `eq-filter-peers` checks through `subscriberCount` that each adapter shares one source subscription per partition while mounted and releases every source subscription after the final framework unmount. The [resource-lifetime oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts) compares when a pooled query and a live-query Collection release their sources across `gcTime` 1, 100, 0, and `Infinity`, with and without a subscriber, and with no `gcTime`, where both use the live-query Collection default, checks that a partition waits for its views' longest `gcTime`, checks that a view resubscribed after its partition released follows later source writes as its live-query Collection does, including a view with a residual conjunct, and checks that a partition that subscribes again still serves new mounts from one source subscription. A generated cleanup-admission grammar (`pooled-live-query.cleanup-admission`) crosses 2–3 views of one partition, observed or not, read by a subscribed live query or not, and view cleanups with or without `dependents`; it checks the model's rejection rule and rows and status against live-query Collections. A view that gives up its partition share when its cleanup rejects fails it. A view whose own cleanup fulfilled must show `cleaned-up` with no rows (law 14, maintainer decision), checked against the model and the live-query Collection; a view that keeps its source's status or its group's rows fails it. Further focused witnesses check that a partition keeps groups only for values that have rows or watchers, that a detached reader never sees a dropped group's revision reused, keep a pooled query's public Collection live while the view is observed, accept it as a query source, and check that a pooled query turns terminal when source cleanup starts while the adapter's cleanup is still pending, as a live-query Collection does. A detached wholesale reader of every view, kept after its peer unmounts, must match the live-query Collection at every checkpoint, and generated histories cross the dependents cascade with readers on watched and unwatched groups that hold rows (declared reach); a cleanup that empties groups without advancing their revisions, or advances only watched groups, fails it. Every view and its live-query Collection also carry an app status listener that throws; each listener gets its own call and the throw is reported uncaught, so a view that lets a listener's throw stop the others or reject the source's cleanup fails it. Generated callback-boundary histories also compare ordinary compiled and pooled core observers across return/throw, Error/undefined, zero through three healthy peers, callback position before/after peers, and `gcTime` 1–3. Failed granular bootstrap retains no unreturned ownership after grace; a held cascade cleanup (`cleanup({ dependents: true })`) gives every admitted wholesale observer one terminal `cleaned-up` snapshot/callback despite a sibling throw, and an ended partition stops following its source's health, and preserves the exact failure at settlement. Original production and independent rollback-only or all-peer-only repairs fail the respective ownership or cleanup-start comparisons. These controlled core receiving paths do not establish framework schedules, arbitrary callback programs or provider shutdown. A fixed oracle witness checks that an `eq` path whose getter throws excludes the row on both paths; a pooled path that rethrows fails it. The pooled oracle's grammar never releases a partition, so release and resubscribe histories rely on these fixed witnesses; a fixed delay, a missing unsubscribed floor, a last-`gcTime`-wins rule, and releasing at `gcTime` 0 each fail it. Removing the partition's final-listener release survives the mounted row checks but fails the shared framework unmount checkpoint and core release-timing owner. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. The shared adapter conformance suite checks the opt-in boundary in every framework: queries without `pool` keep a source subscription each in the function, config, and parameterized forms, and a pooled parameterized query follows its parameter between pool groups beside an unchanged peer. Pooling every query by default, a pooled identity that ignores literal values (React then reuses the old query), and an Angular `params` form that drops `pool` each fail it. `pooled-param-change` also reads each driver's publication log: every value published after a switch holds only the new group's rows. Each driver also logs its keyed `state` map at the same points, and the law applies to it. A pooled view that serves the previous view's rows until a later task replaces them passes every final-state check and fails only the log, in all six drivers. A view that sends the previous rows as its first change set fails the `state` log in Vue; Solid rebuilds its map from a snapshot within the same turn, and Angular builds its map from `entries()`, so that mutant cannot reach them. The `invalid-pool` scenario requires `InvalidPoolOptionError` for `true` and for `false` (the value adapters pass to say a hook cannot pool), alone, beside `gcTime`, beside `queryKey`, on a parameterized query, and on a disabled config or parameterized query, in all six drivers; a check in only one of the three readers of `pool` fails it in four of them, and Angular's `params` form failed the `false` and disabled cases until it checked its pool first. The shared `grouping-operator-switch` scenario re-renders a grouped query without `groupKeys()`, and `compile-error-after-rerender` re-renders to a select that mixes a source field with an aggregate; both require the error and no rows after it. A binding that keeps its last compiled query when compilation throws fails both in Solid, Svelte, and Angular; one that throws once and then serves that query fails the no-rows check in Solid and Svelte, and cannot occur in Angular, whose `computed` rethrows a cached error until an input changes. In `grouping-operator-switch`, React and Preact kept the grouped query before query identity held operator code, and Vue served the previous rows before its compile error stopped reaching readers through a throwing computed (also on `main` at `5649a07d4`). | +| Live-query deferred acquisition | [deferred acquisition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts), [React Suspense warm-cache witness](https://github.com/TanStack/db/blob/main/packages/react-db/tests/suspense-warm-cache.test.tsx) | An independent model of the law that a live-query Collection starts no provider work before a subscriber or a preload in its current sync run crosses seven source states (eager or on-demand, idle, already started or render-deferred, and an on-demand source that already holds the rows), an unordered query and an ordered window, one or two live-query levels, and eleven histories: start and read, subscribe or preload after a start, subscribe or preload without one, deferred subscription and resumption with or without a start, cleanup before a subscriber, peer start, source truncate, and restart after a run that had one. After every command it checks source sync-run starts, acquisition attempts, readiness when the call returns (the synchronous observation cut), settled readiness, and rows. A pooled block checks that building a pooled view starts no idle eager source until a view subscribes or preloads, and eight DbClient histories check that a preload answered by a pending or settled stream still resumes acquisition across eager/on-demand idle and render-deferred sources. The React streaming witness receives the on-demand render-deferral premise, starts local acquisition before the stream settles, and retains server rows until browser acquisition completes. Mutants that never defer, keep the subscriber-or-preload fact across cleanup, ignore preload, never resume a builder's subscriptions, let a deferring subscriber start a base source, build a pooled partition without deferral, never resume it, mark a preload one microtask late, or drop the stream's preload mark each fail it. The React witness checks that `useLiveSuspenseQuery` over a source that already holds the rows renders without the fallback, for eager and on-demand sources and both shapes; creating the Suspense collection started, or not preloading it in render, fails it. A later review added `peer` and `truncate` commands: a peer that starts an idle on-demand source, or a truncate on a running one, made the live query acquire before any subscriber, and both guards are killed separately. Fixed blocks check that a subscriber surviving cleanup is served after a restart while a surviving deferring subscriber is not, and that `toArrayWhenReady()` and `stateWhenReady()` count as a preload. A fixed block checks that a deferring live query is not counted in its source's `subscriberCount` or `subscribers:change` until its first subscriber, while still keeping the source from garbage collection; a Query Collection test checks that it causes no refetch. Another fixed block checks that a preload or subscriber requested by an inner live query's status handler, while the outer query creates its subscription to it, still resumes acquisition. Solid's Suspense receiving witnesses check that a resource whose preload settles first retains its live-query Collection while its sibling remains pending past the unsubscribed floor, then releases that temporary owner on both commit and abandonment, and that a preload settling after its owner unmounts cannot publish an error through the abandoned result. Open: source failure and asynchronous loads before resumption, which the subscription lifecycle oracle owns once acquisition resumes; a resume that throws, which leaves later sources unstarted only for a live query that is already in error; asynchronous sources; and arbitrary Suspense schedules with a `DbClient` beyond the bounded abandoned-render and streaming handoffs recorded above. | | Live-query notification algebra | [real-query and checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/notification-laws-oracle.test.ts), [independent Map checker](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts) | One-key synchronous source histories check exact live-query callback reconstruction and unsubscribe silence. Synthetic multi-key histories calibrate the checker; direct source Collection events remain with the change-event history owner. | -| Flat-row change tracking | [flat change tracking oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/flat-change-tracking-oracle.property.test.ts) | Generated callbacks over flat rows, frozen or not and with or without a non-enumerable field (assignments of `0`, `-0`, `NaN`, `undefined`, functions, and fresh objects; reverts; deletions; added fields; data and accessor `defineProperty`; stored drafts; throws), run through the flat tracker, the draft proxy, and an independent model, which must report the same change sets with `0` and `-0` equal, or throw the same error and leave the rows unchanged. Defining a field acts as assigning it, and a non-enumerable field is row data only once written; the proxy broke three of those laws on `main` and the flat tracker one. Diffs using `!==`, `Object.is` alone, or no deletions fail it. It found that the proxy dropped a field added as `undefined` when another field reverted; the pinned history and both campaigns fail without that fix. Non-flat rows must fall back. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | +| Flat-row change tracking | [flat change tracking oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/flat-change-tracking-oracle.property.test.ts) | Generated callbacks over flat rows, frozen or not and with or without a non-enumerable field (assignments of `0`, `-0`, `NaN`, `undefined`, functions, and fresh objects; reverts; deletions; added fields; data and accessor `defineProperty`; stored drafts; throws), run through the flat tracker, the draft proxy, and an independent model, which must report the same change sets with `0` and `-0` equal, or throw the same error and leave the rows unchanged. Defining a field acts as assigning it, and a non-enumerable field is row data only once written; the proxy broke three of those laws on `main` and the flat tracker one. Diffs using `!==`, `Object.is` alone, or no deletions fail it. It found that the proxy dropped a field added as `undefined` when another field reverted; the fixed histories and both campaigns fail without that fix. Non-flat rows must fall back. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | | Virtual props cache | [cache laws](https://github.com/TanStack/db/blob/main/packages/db/tests/virtual-props-cache.test.ts) | Focused laws for the per-key cache of rows read with virtual props: a change's published value is the row later `get` and `toArray` reads return, for every subscriber, after sync and local writes, and a key that is deleted or rolled back leaves the cache. Enriching the value before the previous value fails the first two; skipping the drop on delete events fails the third. Rows read with virtual props through other paths, such as joins, are outside these laws. | | LocalStorage Collection | [write order over every short history](../../packages/db/tests/local-storage-write-order-oracle.test.ts) (`local-storage.write-order`), [peers over every short history](../../packages/db/tests/local-storage-peers-oracle.test.ts) (`local-storage.peers`), [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | The `local-storage.write-order` property runs every history of one to three automatic updates and deletes of two stored rows (1,448 histories), every handler completion order, and every decision vector. After each handler finishes, no accepted write may have settled ahead of an undecided predecessor, and storage may hold only the author-order fold of part of the released prefix; a rejected write may settle as soon as its own handler fails (the two-write loops it replaced also held a rejected later receipt for two microtasks, which no law requires). At settlement, outcomes, durable rows, public rows, and a fresh restore equal the fold of the accepted writes. Against `src/local-storage.ts` mutants it detects every mutant that the finite two-write loops it replaced detected (63), plus one. Inserts are excluded: an insert after a rejected delete of the same key is undecided behaviour. The `local-storage.peers` property runs every history of up to three actions (19,289 histories) across Collections A and B in one tab and C in another tab over the same bytes. Actions are direct, manual-acceptance, handler, and custom-parser writes of string keys and the number key 7, a held update by A, `clearStorage()` from any Collection, `reload()` of C, and delivery of pending storage events. After each action, stored rows equal the model's read-modify-write fold. Every Collection in the writing tab shows the stored rows, because a write reads storage for its whole tab (maintainer decision D43, 2026-11-02, raised by three comparison gaps in the gap report). The holder's own pending row overlays only the holder, and while it is held the holder may show its earlier view of other keys (glossary, accepted sync transaction). The other tab shows a changed row once its event arrives or it reloads. A fresh Collection restores the stored rows. Against `src/local-storage.ts` mutants the LocalStorage oracles detect 201 of 320 with this property and 153 without it; detections in the peer and event functions rise from 36 to 70 of 131. Excluded: write races between tabs on one key, value-normalizing parsers, startup errors, storage faults, and cleanup. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Only two fixed tests in `packages/db/tests/local-storage-order.test.ts` check this (same and disjoint key); no generated history does. Open: `local-storage.write-order` needs a checkpoint a macrotask after a rejected release that compares the rejected write's receipt and public row with the model. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | -| LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `when('settled')` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Pinned witnesses cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A pinned witness per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, pinned in `local-only.test.ts`. | -| Local-only direct writes | [direct write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) | Focused witnesses pin when a local-only direct write skips the optimistic stage: a completed transaction and one publication per write, and the fallbacks for a pending, persisting, or ambient transaction and a user handler, for insert, update, and delete. Each history also runs on a local-only Collection whose handlers resolve, which must match its rows, change batches, errors, and transaction states, across mixed multi-key batches, a batch that fails midway, schema rejection, and handler rollback. Mutants that drop each guard, write only the first mutation, or never complete the transaction fail them. The change-event history oracle drives the direct path through generated histories (about two thousand writes per run); removing the guard, or guarding only persisting transactions, fails these witnesses. | +| LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `when('settled')` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Fixed histories cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A fixed histories per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, fixed in `local-only.test.ts`. | +| Local-only direct writes | [direct write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) | Focused witnesses fix when a local-only direct write skips the optimistic stage: a completed transaction and one publication per write, and the fallbacks for a pending, persisting, or ambient transaction and a user handler, for insert, update, and delete. Each history also runs on a local-only Collection whose handlers resolve, which must match its rows, change batches, errors, and transaction states, across mixed multi-key batches, a batch that fails midway, schema rejection, and handler rollback. Mutants that drop each guard, write only the first mutation, or never complete the transaction fail them. The change-event history oracle drives the direct path through generated histories (about two thousand writes per run); removing the guard, or guarding only persisting transactions, fails these witnesses. | | Reused sync row objects | [reused row witness](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-reused-row.test.ts) | Focused witnesses for a sync source that changes a stored row object in place: in development, writing it again as an update without `previousValue` throws, while the same write with `previousValue`, or with a new object, moves the row out of an `eq` live query. Production skips the check. The check compares a shallow snapshot taken when the object was written, so it misses changes to nested fields; a write that names `previousValue` and a row whose fields throw on read are not checked. | | Index suggestions | [index suggestion oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-suggestion-oracle.test.ts) | A public filtered live-query Collection checks manual/default, eager, threshold, matching-index, and unrelated-index cases against the documented collection-size suggestion policy. A bounded ordered-query decision table separately classifies no field index, a null-placement mismatch, matching but order-ineligible indexes, and usable candidates in either registration order. It compares the development warning after preload; the matching-index selection owner below checks the chosen reader and ordered request. Two cyclic locale-options cells require the diagnostic to preserve valid query construction with or without a field index, and a custom-collation cell requires remediation to preserve comparator reference identity. The original manual-silence/eager-noise implementation failed two expected observations. The issue #2190 baseline failed four diagnostic cells, including a first-unusable/second-usable candidate order; the first formatter draft failed all three non-JSON-safe remediation cells. The owner does not judge repeated-query warning volume, slow-query timing, production-build suppression, every comparison-option mismatch, or every query shape. It runs in `@tanstack/db`'s `test:oracles` campaign. | -| Minified DB public API | [consumer bundle check](https://github.com/TanStack/db/blob/main/scripts/test-minified-db.mjs) | CI builds `@tanstack/db`, then bundles its built `dist` entry with db-ivm inlined through esbuild `minify: true`. The build renames TypeScript-private members from `packages/db/mangle-cache.json`, so this lane runs against the renamed output that consumers install. It rejects a `dist` that is older than `src`, the cache, or the package's build configuration. `pnpm --filter @tanstack/db test:dist` also runs five db test files against the built `dist`, chosen for coverage of the modules with the most renamed members. `pnpm check:mangle` fails when a cached name is used other than as a private member in db src, is read by another package, appears as a string, or has a short name that collides with a source identifier ([private-member mangling review](oracle-reviews/code-weight-private-member-mangling.md)). It checks every exported error class's current public `name`, the built-in `BasicIndex` resolver name in index metadata and its event, complete query rows (after removing the four documented virtual fields) against an independent array filter/sort/projection, and one live update. The `new.target.name`, public-member mangle, and extra-output-field calibration modes fail at the intended observations. This fixed slice does not replace the unminified generated oracles, exercise framework adapters, or establish stable names for custom index resolvers. | +| Minified DB public API | [consumer bundle check](https://github.com/TanStack/db/blob/main/scripts/test-minified-db.mjs) | CI builds `@tanstack/db`, then bundles its built `dist` entry with db-ivm inlined through esbuild `minify: true`. The build renames TypeScript-private members from `packages/db/mangle-cache.json`, so this grammar runs against the renamed output that consumers install. It rejects a `dist` that is older than `src`, the cache, or the package's build configuration. `pnpm --filter @tanstack/db test:dist` also runs five db test files against the built `dist`, chosen for coverage of the modules with the most renamed members. `pnpm check:mangle` fails when a cached name is used other than as a private member in db src, is read by another package, appears as a string, or has a short name that collides with a source identifier ([private-member mangling review](oracle-reviews/code-weight-private-member-mangling.md)). It checks every exported error class's current public `name`, the built-in `BasicIndex` resolver name in index metadata and its event, complete query rows (after removing the four documented virtual fields) against an independent array filter/sort/projection, and one live update. The `new.target.name`, public-member mangle, and extra-output-field calibration modes fail at the intended observations. This fixed slice does not replace the unminified generated oracles, exercise framework adapters, or establish stable names for custom index resolvers. | | Boundary refinements | [cleanup/restart](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-cleanup-restart-oracle.test.ts), [idle cleanup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-idle-cleanup.test.ts), [issue #1891 async-cleanup review](oracle-reviews/issue-1891-async-cleanup.md), [issue #1891 cleanup-start follow-up](oracle-reviews/issue-1891-cleanup-start-followup.md), [metadata publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-metadata-publication-oracle.property.test.ts), [row metadata composition](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts), [nested-begin metadata rebuild review](oracle-reviews/2026-10-06-nested-begin-metadata-rebuild.md), [state mutation round 4](oracle-reviews/2026-10-06-state-mutation-round-4.md), [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts), [acquisition cells](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-cells.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [D2 source reconciliation](https://github.com/TanStack/db/blob/main/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts), [top-K support windows](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts), [nested Query work](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts) | Explicit lifecycle products, independent source maps and weighted relations, exact publication cuts, support/multiplicity, and value-plus-work observations. These refine the larger subsystem models; they do not replace them. The cleanup/restart owner's failing-dependents product crosses every ordered pair of resolving, throwing, rejecting, held, and pooled-throwing dependent ends with synchronous, held, and rejecting adapter cleanup and both release orders, with declared reach; it rejects a cleanup that stops at a throwing dependent, settles with `Promise.all`, or waits only for its dependents. Effects and nested live-query chains with failing ends are not generated. | -| Small structures and test mechanics | [SortedMap](https://github.com/TanStack/db/blob/main/packages/db/tests/SortedMap-oracle.test.ts), [cleanup queue](https://github.com/TanStack/db/blob/main/packages/db/tests/cleanup-queue-oracle.property.test.ts), [live-query GC clock](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-clock.test.ts), [guarded replay](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-replay.test.ts) | Map/full-sort and elapsed appointment-list models with executed target/seed/path checks. SortedMap promises what Collection state uses, not full Map parity (maintainer decision). Keys are strings or numbers: `sync.write` and mutations throw `InvalidKeyError` or `UndefinedKeyError` for any other key, and grouped live queries key nullish, boolean, and bigint group values with strings (maintainer decision, 2026-10-27). The model is a Map of numbers plus a full sort; production stores row objects so the driver can change one in place. Keys include `NaN`, which a grouped live query uses for a `NaN` group; it is one key that key order places after every other number. Generated histories mix deferred and immediate sets and deletes, clear, deferred bursts of 17 to 41 keys, prunes that delete entries with deferOrder while iterating, and in-place changes of a deferred or a placed row. A prune must yield the order it began with. A row changed before restore sorts by its current value; once a placed row changes in place, comparator order downgrades to membership (each key once, current value) for the rest of the history, and a later write or delete affects only its own key. At each ordered read with a comparator, restoring k deferred keys among n others makes at most k * (ceil(log2 k) + ceil(log2 (n + 1)) + 2) comparator calls, and none with nothing deferred. Declared reach (minimum 5, per campaign of 100): burst restorations, an entry yielded after a prune deleted one, a placed `NaN` key written without deferral (7 to 16), a restore whose bound is below n (19 to 30), a changed placed row later written (11 to 20), and a deferred row changed before restore (11 to 19). Mutants that re-sort every key (108 calls against a bound of 7), re-sort the kept keys with the dirty keys appended (34 against 7), re-sort clean scans, drop the scan fallback for a changed row, match slots by `===` (a placed `NaN` key then removes the last key), keep dirty slots, apply deferred deletes immediately, or place a deferred row by its value at the write each fail. Supplemental examples in `SortedMap.test.ts` pin splice movement at scale, a key iterator opened before a deferred restore, scan reuse, and public single-transaction and queued-transaction Collection sync paths. Not promised: other mutation during iteration (immediate writes, clear), the order of rows once a placed row changed in place, keys outside the key type, `-0` keys, a comparator that throws, and the work of key order without a comparator, which the public API cannot observe. `forEach` was removed; SortedMap is not exported. Wall-clock steps do not move due times, and a focused public witness checks source subscription release. Callback-reentrant scheduling, OS suspend/resume, environments without `performance.now()`, and fake/real Performance clock replacement while GC is pending remain outside this coverage. Tests must drain or reset pending GC before switching timer providers. | +| Small structures and test mechanics | [SortedMap](https://github.com/TanStack/db/blob/main/packages/db/tests/SortedMap-oracle.test.ts), [cleanup queue](https://github.com/TanStack/db/blob/main/packages/db/tests/cleanup-queue-oracle.property.test.ts), [live-query GC clock](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-clock.test.ts), [guarded replay](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-replay.test.ts) | Map/full-sort and elapsed appointment-list models with executed target/seed/path checks. SortedMap promises what Collection state uses, not full Map parity (maintainer decision). Keys are strings or numbers: `sync.write` and mutations throw `InvalidKeyError` or `UndefinedKeyError` for any other key, and grouped live queries key nullish, boolean, and bigint group values with strings (maintainer decision, 2026-10-27). The model is a Map of numbers plus a full sort; production stores row objects so the driver can change one in place. Keys include `NaN`, which a grouped live query uses for a `NaN` group; it is one key that key order places after every other number. Generated histories mix deferred and immediate sets and deletes, clear, deferred bursts of 17 to 41 keys, prunes that delete entries with deferOrder while iterating, and in-place changes of a deferred or a placed row. A prune must yield the order it began with. A row changed before restore sorts by its current value; once a placed row changes in place, comparator order downgrades to membership (each key once, current value) for the rest of the history, and a later write or delete affects only its own key. At each ordered read with a comparator, restoring k deferred keys among n others makes at most k * (ceil(log2 k) + ceil(log2 (n + 1)) + 2) comparator calls, and none with nothing deferred. Declared reach (minimum 5, per campaign of 100): burst restorations, an entry yielded after a prune deleted one, a placed `NaN` key written without deferral (7 to 16), a restore whose bound is below n (19 to 30), a changed placed row later written (11 to 20), and a deferred row changed before restore (11 to 19). Mutants that re-sort every key (108 calls against a bound of 7), re-sort the kept keys with the dirty keys appended (34 against 7), re-sort clean scans, drop the scan fallback for a changed row, match slots by `===` (a placed `NaN` key then removes the last key), keep dirty slots, apply deferred deletes immediately, or place a deferred row by its value at the write each fail. Supplemental examples in `SortedMap.test.ts` fix splice movement at scale, a key iterator opened before a deferred restore, scan reuse, and public single-transaction and queued-transaction Collection sync paths. Not promised: other mutation during iteration (immediate writes, clear), the order of rows once a placed row changed in place, keys outside the key type, `-0` keys, a comparator that throws, and the work of key order without a comparator, which the public API cannot observe. `forEach` was removed; SortedMap is not exported. Wall-clock steps do not move due times, and a focused public witness checks source subscription release. Callback-reentrant scheduling, OS suspend/resume, environments without `performance.now()`, and fake/real Performance clock replacement while GC is pending remain outside this coverage. Tests must drain or reset pending GC before switching timer providers. | The index-suggestion oracle's PR #2196 extension checks a matching local custom-collation index against the full-source request and retained warning, @@ -1918,7 +1918,7 @@ The [round 1](oracle-reviews/index-unit-retirement-round-1.md), [round 3](oracle-reviews/index-unit-retirement-round-3.md), and [round 4](oracle-reviews/index-unit-retirement-round-4.md) ledgers retain each retired assertion. The [build review](oracle-reviews/basic-index-nullish-build.md) -records the direct RED/GREEN repair. A separate mixed number/string lane +records the direct RED/GREEN repair. A separate mixed number/string grammar checks that full direct traversals and one indexed, bounded public Collection snapshot return each live source key once after deletion and after a direct delete/update/re-add history. Two more direct histories keep an ordinary string @@ -1938,15 +1938,15 @@ empty-bucket mutant failed at the intended comparator-count comparison. The invalid-comparator owner checks rejection exactly when a comparison returns a non-number or `NaN` and checks rejected removal atomicity; it does not require a comparison merely because one comparator-equal row is removed. -The binary lane checks fresh equal-content removal, re-addition, and +The binary grammar checks fresh equal-content removal, re-addition, and replacement beside another byte value. It compares complete forward and reverse key reads to a source Map. Separate short and 8193-byte pairs check exact membership and complete forward and reverse key conservation when only the last byte differs. An intermediate PR BTree rewrite lost a surviving key -after one equal-content owner left; the expanded lane rejected that result. -A separate two-value Date, Temporal.Instant, and Uint8Array callback lane +after one equal-content owner left; the expanded grammar rejected that result. +A separate two-value Date, Temporal.Instant, and Uint8Array callback grammar checks legacy scalar input kinds on direct add only. Arbitrary callbacks and -update, build, and range paths are outside that lane. +update, build, and range paths are outside that grammar. This owner does not prescribe mixed-kind order or cover binary range ordering, all JavaScript value kinds, or general Collection publication histories. @@ -1991,10 +1991,10 @@ The [Temporal index order tests](https://github.com/TanStack/db/blob/main/packag (fixed tests; no oracle owner (tracked in #2264)) covers ZonedDateTime instant ordering and time-only Duration ordering for direct BasicIndex/BTreeIndex reads and bounded public first-row reads. It separates exact Duration fields from order ties, including exclusive cursor behavior. -An extended-year PlainDate lane checks calendar order under lexical string +An extended-year PlainDate grammar checks calendar order under lexical string collation through ready scan and direct indexed snapshots, with eager live-query preload as parity evidence. The old indexed direct path chose the wrong year. -An exception lane requires ready scan, BasicIndex, and BTreeIndex direct reads +An exception grammar requires ready scan, BasicIndex, and BTreeIndex direct reads and eager live-query preloads to reject mixed PlainDate/PlainDateTime values and distinct PlainMonthDays, as changelog #1519 specifies. The old indexed direct path returned a row instead. Calendar-unit Duration order, other extended-year @@ -2101,7 +2101,7 @@ domain: a structurally sound index can temporarily lose ordered-read eligibility as row kinds change, without warranting a duplicate auto-index. The [PR #2116 confirmed-fixes review](oracle-reviews/pr-2116-confirmed-fixes.md) -records baseline failures, hostile controls, and the bounded scope of these +records baseline failures, mutant controls, and the bounded scope of these repairs. The [deferred findings record](oracle-reviews/pr-2116-deferred-findings.md) tracks remaining design and coverage questions, including valid Temporal kinds without public order and mixed-kind query order. Earlier PR-branch @@ -2289,7 +2289,7 @@ an explicit provider result, then checks that result and ownerless-row retirement. It covers one physical alias and an unbounded scope, not arbitrary hash functions or windowed scopes. The ordinary-unload, provisional-row, and alias tests reject their respective -hostile classifiers at public-row or cache checkpoints. These finite histories +mutant classifiers at public-row or cache checkpoints. These finite histories do not establish every persistence schedule, every Query hash collision, or a full offline replay; those remain owned by the Query DB ownership and offline receiving oracles. Query Core 5.90.15 is the lowest version on which the full @@ -2610,7 +2610,7 @@ without a mutation Collection owner. The [follow-up oracle review](oracle-reviews/2026-10-08-localstorage-final-review.md) adds direct-owner idle, ready, and cleaned-up histories for module-level acceptance into a DbClient materialization. It checks the manual receipt, -durable snapshot, active public snapshots, and fresh restore. A hostile subject +durable snapshot, active public snapshots, and fresh restore. A mutant subject that drops transaction commit work fails the held-receipt checkpoint. The peer owner checks a peer write admitted by a restore subscriber, custom-parser normalization of an authored insert and update without a version-token change, @@ -3048,7 +3048,7 @@ rest reject with `AbortError`. Every close call shares the first close's outcome. The worker receives exactly one close request, and work submitted after close starts never reaches it. Rejecting at close start, posting a second close, admitting work during close, deferring rejection past close settlement, -and the pre-fix hang each fail this lane, as does ignoring pagehide or a Worker +and the pre-fix hang each fail this grammar, as does ignoring pagehide or a Worker error while close is in flight. The controlled worker answers in any order, so these histories also cover a close response that overtakes pending work. @@ -3086,12 +3086,12 @@ Its startup witness covers one synchronous rank update after an indexed page load and before the last source subscription returns. It does not cover every source ordering, multiple buffered mutations, or asynchronous startup changes. The [ordered Effect startup review](oracle-reviews/gap-06-ordered-effect-startup.md) -records its baseline and hostile-mutant observations. +records its baseline and mutant-mutant observations. The Collection lifecycle change-event history owner also checks eager auto-index equality buckets and fresh indexed `eq` live queries against its independent row Map. Its required same-turn delete/reinsert prefix reaches an already-built -index for both BasicIndex and BTreeIndex. In the batched lane, public rows and +index for both BasicIndex and BTreeIndex. In the batched grammar, public rows and equality buckets are checked after each mutation returns, before any transaction is awaited; the batched final state and each sequential settled prefix are also checked. Fresh indexed queries are checked at the final checkpoint. @@ -3101,7 +3101,7 @@ versus two model rows. Another fault that preserved the final keys failed the new returned-mutation check. The direct index refinement owner does not prove this Collection mutation path. Callback-time index agreement, the reported insert-seeded string-key setup, adapter cancellation, and -non-local-only persistence remain outside this lane. +non-local-only persistence remain outside this grammar. The [issue #1912 review record](oracle-reviews/issue-1912-eager-index-history.md) records the grammar controls, exact reviewed commit, and ORC audit. @@ -3121,7 +3121,7 @@ The [optimistic-history oracle](https://github.com/TanStack/db/blob/main/package now owns the held-row law for sync transactions deferred behind a wrapping sync's durable step, and checks that every optimistic mutation is visible in the tick that makes it. Its -[persisted lane](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts) +[persisted production path](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts) runs the shared grammar through `persistedCollectionOptions` with `hold-store` and `release-store` steps and a handler that commits its own server row. It was RED before the wrapper reported deferred sync keys to @@ -3138,7 +3138,7 @@ non-optimistic write kept local attribution for a dropped transaction. A campaign check requires each run to reach a hold across a drain and a hold released by an abort. Open: wrapper cleanup or source restart while a transaction is deferred has no grammar step, and a held-row release filtered -as a recent sync echo was not reproduced. The lane uses an in-memory +as a recent sync echo was not reproduced. The production path uses an in-memory adapter. Several tabs on one durable store each run their own source and store every source transaction they receive. DB promises only eventual @@ -3146,7 +3146,7 @@ consistency between them: while their streams interleave, a lagging tab's copy of an older transaction can be stored and announced after a newer one, so a tab can briefly show an older server state. Every tab delivers the same log in order, so durable state and every tab converge once the streams -catch up. No lane checks this yet. +catch up. No production path checks this yet. A counterexample bounds that convergence claim ([#2209](https://github.com/TanStack/db/issues/2209)). Two Browser tabs share one SQLite file through the real coordinator, with a controlled @@ -3160,7 +3160,7 @@ over stale rows (#2281). The eager SQLite bridge now records source continuity atomically and requires a fresh snapshot on reopen after a gap; see the [repair evidence](oracle-reviews/issue-2281-investigation.md). This preserves temporary arrival-order regressions and leaves #2209 open. The -multi-tab Electric oracle in `browser-db-sqlite-persistence` pins this +multi-tab Electric oracle in `browser-db-sqlite-persistence` fixes this history with `it.fails` beside a passing control in which the lagging tab catches up. Ordering the copies would need a generic source position on sync commits; Electric already stores `{ handle, offset }` @@ -3557,7 +3557,7 @@ without caching it. The initial repair receipt on main at `a37e69ab` records 48 core routing assertion failures and eight real scheduler-cycle assertion failures for the original production code. The latest review follow-up passes 640 persisted-oracle checks (with one existing todo) and 25 Chromium OPFS checks. -The original eight hostile repairs cover premature receipts, cached loans, +The original eight mutant repairs cover premature receipts, cached loans, omitted data, reverse FIFO and suppressed/misclassified failure. New calibration shows the old browser checks accepting cached loans and ordinary scheduling bypass; the strengthened receiver rejects them at tail routing and the complete @@ -3577,7 +3577,7 @@ retains the original issue ledger. - [ ] **Nested work under fairness backlog.** The shared-driver fairness owner (`tests/shared-driver-fairness-oracle.ts` in the browser package, with its OPFS receiver) needs a nested source commit inside a hydrate while both - lanes retain a backlog that distinguishes K=1 from K=2. Finite peer + grammars retain a backlog that distinguishes K=1 from K=2. Finite peer progress here does not establish that composition. - [ ] **Elected-owner/host handoffs.** The per-Collection coordinator owner retains transfer/cross-tab RPC while nested durability is pending. The @@ -3722,9 +3722,9 @@ The post-merge review added three missing domains to existing owners: in the same drain. Bounded histories also cross two persisting same-key mutations, one or two source batches, both settlement orders, and success/failure combinations. The first source batch is local - when at least one mutation succeeds; two failures leave it remote. A hostile + when at least one mutation succeeds; two failures leave it remote. A mutant failed-sibling mutant fails the one-success cases at settlement. The pending - lane rejects a mutant that ignores pending mutations at its source or + grammar rejects a mutant that ignores pending mutations at its source or settlement publication. Bounded manual histories now cross success, direct rollback, and handler rejection with both optimistic visibility settings. A later disjoint edit cannot supply the earlier manual request's handler @@ -3778,7 +3778,7 @@ Issue #2071 adds two focused checks to this acceptance map: causal `$origin` guarantee would require an explicit source signal and a new core-owner model and provider receiving witness. The [PR #2078 review](oracle-reviews/pr-2078-origin-attribution.md) records the - original same-drain RED, the repair, and hostile controls. The optimistic + original same-drain RED, the repair, and mutant controls. The optimistic history owner still needs controlled pending-plus-persisting same-key histories, truncate with two active owners, and longer reentrant or mixed source suffixes if those broader schedules are claimed. Such a witness must @@ -3789,10 +3789,10 @@ Issue #2071 adds two focused checks to this acceptance map: | Issue obligation | Implemented evidence | Limit | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Metamorphic laws | Includes cross-formulation/partition, D2 independent-key commutation, DBSP incremental/full recomputation, pagination provider/UI boundaries, optimistic snapshot stability | Equivalence premises are explicit; not arbitrary query rewrites. | -| Public observations | Reads, exact event payloads and reconstructed state, observer eligibility, downstream includes, lifecycle and ownership checks | Count/work budgets are separate from row truth and only pin established promises. | +| Public observations | Reads, exact event payloads and reconstructed state, observer eligibility, downstream includes, lifecycle and ownership checks | Count/work budgets are separate from row truth and only fix established promises. | | Meaningful async histories | Applied receipts, truncate/replay, cleanup/restart, pending optimistic work, held provider completion, leadership loss | Control real causes; unsupported SDK traces receive no coverage credit. | | Checker sensitivity | Faulty output controls, missing/duplicate events, stale completion/ownership controls, forced collisions and pre-fix runtime witnesses | Setup failures and timeouts are recorded separately from assertion kills. | -| Executed reach and replay | Named manifest with guarded replay, finite boundary products, pinned examples, fixed/random lanes and explicit stress runs | Root `test:oracles` is a selected core/Query DB campaign, not all repository oracles. | +| Executed reach and replay | Named manifest with guarded replay, finite boundary products, fixed histories, fixed/random campaigns and explicit stress runs | Root `test:oracles` is a selected core/Query DB campaign, not all repository oracles. | | Contract and scope records | Guide, companion case notes, this map, suite-local law/domain comments and architecture | This is an executable testing method, not a completeness proof or new product specification. | ## Running and replaying @@ -3832,7 +3832,7 @@ target execution, not reproduction of a particular bug. Local IVM and offline properties have separate environment variables; inspect their test headers. Do not assume the core multiplier reaches them. -The adapter-window properties have named replay routes in +The adapter-window properties have named fixed history routes in `packages/db/tests/oracle-replay-manifest.ts`. Each shared history property includes its production driver: `framework..window-history`, `framework..pending-window-history`, or @@ -3840,7 +3840,7 @@ includes its production driver: `framework..window-history`, `react-dependency-list`, `preact`, `vue`, `solid`, `angular`, and `svelte`. Each route selects its own conformance file and names the shared model module as its source. React and Preact render witnesses, all twelve React SSR -runtime/path cells, and the IVM numeric-window lane retain their own routes. +runtime/path cells, and the IVM numeric-window grammar retain their own routes. Selectors choose only the fresh or random campaign. The earlier three shared `framework.*` names are retired because they lost the failing driver's identity. The [replay review](oracle-reviews/2026-10-30-adapter-page-window-replay.md) @@ -4027,7 +4027,7 @@ and the campaigns that `runOracleCampaign` in ran than were declared, the check reports only that. - A replay keeps its seed, and a path replay of the property keeps its path and runs one history. `runOracleCampaign` runs a fixed-seed and a random - campaign without a prefix seed, one replay campaign with one, passes each + campaign without a prefix seed, one reseeded campaign with one, passes each its parameters, and reports each passing campaign on one `ORACLE_REACH` line. An invalid prefix environment throws. @@ -4068,7 +4068,7 @@ RFC #1659 reviews found several green oracles whose stated laws remained valid but whose fixtures, grammars, or observations did not cover a neighboring boundary. Track the generalized repairs here instead of accumulating isolated regressions. Completion requires an executable owner, a production-path witness, -a hostile wrong-answer control, and an explicit statement of remaining limits. +a mutant wrong-answer control, and an explicit statement of remaining limits. - [ ] **Offset-only providers under a joined ordered window.** A provider that honors `offset` and ignores `cursor` gets a wrong window at preload under a @@ -4085,7 +4085,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. is available. A failed page, tie, or bounded repair, and an invalidation before any request has settled, still recover with a full-source read. A first attempt to make failures recover with a bounded prefix repair - broke 48 pinned expectations and showed real regressions: a wrong window + broke 48 fixed expectations and showed real regressions: a wrong window (`[1, 4]`, expected `[1, 2]`) after a failed tie request, changed lease release after a failed tie, and settlement mismatches in the pagination pending-history campaigns. Owner: the ordered-source-loader and pagination @@ -4195,7 +4195,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. | Gap recovery's cycle start, on `2047f4281` | 12 pullSince cells fail: an unreserved commit during pullSince rejected, and a reservation opened then straddled. Dropping the reservation check after pullSince fails 8. | | Gap recovery that reloads partway through, on `851e8d9da` | 28 cells fail: recovery advanced the stream position and applied a targeted delta before it deferred, so its retry skipped the reload. A gate only at recovery start still fails 15 scope-entry cells; reloads that start their own cycle inside recovery fail 61. | | `5649a07d4` | The open cells fail with `a persisted sync transaction cannot cross a hydration cycle`. Three `after read` reset follow-up cells also fail, because `main` applied a pre-reset commit before the reset set its term fence. Queueing resets with notices fixes this pre-existing defect. | - | Hostile mutants | Each mutant fails its cells, and every failure is an assertion. Deferring every notice fails the targeted change. Dropping the follower reply's wait fails the routed subset. Reporting hydration start before the check fails the aborted demand. A reset reload that starts its own cycle fails the first-publication reset. Applying a reset outside the notice queue fails the reset follow-ups. Retrying a waited notice while the reservation is open fails the follow-up and routed cells (repeated pullSince). Checking only at the mutex turn fails every scope-entry cell. Gating subset hydration only fails every reset, reload, and gap cell. Requeueing a waited notice behind later notices fails 12 reset follow-up cells: the pre-reset commit is published before its reset, and a reload is lost. | + | Mutant | Each mutant fails its cells, and every failure is an assertion. Deferring every notice fails the targeted change. Dropping the follower reply's wait fails the routed subset. Reporting hydration start before the check fails the aborted demand. A reset reload that starts its own cycle fails the first-publication reset. Applying a reset outside the notice queue fails the reset follow-ups. Retrying a waited notice while the reservation is open fails the follow-up and routed cells (repeated pullSince). Checking only at the mutex turn fails every scope-entry cell. Gating subset hydration only fails every reset, reload, and gap cell. Requeueing a waited notice behind later notices fails 12 reset follow-up cells: the pre-reset commit is published before its reset, and a reload is lost. | A routed load also judges law 7 at its settlement: the owner's row is public when the load settles. A reservation that ends between the follower's flush and its wait check is a one-microtask window no @@ -4297,14 +4297,14 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. the repair. Removing the reservation gate, or the `onError`, give-up, or signal-abort abandon, fails both campaigns. Each declared premise, including a peer commit after each kind of stop mid-transaction, is - reached in every campaign. Known gap #2267 is left out of the - generated histories and pinned with `it.fails`: a resumed tab that lags + reached in every campaign. Known limit #2267 is left out of the + generated histories and fixed with `it.fails`: a resumed tab that lags behind a peer can receive an update for a row the peer already deleted, and Electric's resume check puts it in `error`. The published Electric resume-safety contract requires rejecting an unknown partial row. Whether composition must avoid that condition without failing the source remains a separate contract question; - this repair does not remove the guard or resolve the pinned expectation. + this repair does not remove the guard or resolve the fixed expectation. The histories also post a peer `collection:reset` on the tabs' coordinator channel; no shipped host sends one yet, so this premise is controlled. The reset abandons every open reserved transaction, and @@ -4320,10 +4320,10 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. A delivery may also commit only a prefix of what a stream owes, as a server answering at an earlier moment does, so lagging commits are generated. The final checkpoint requires the server head (Law 2). - Known gap #2209 is one history that breaks it: a lagging tab + Known limit #2209 is one history that breaks it: a lagging tab commits an older row and closes for good. Generated histories never close a tab for good, and an `it.fails` replay through the same driver - and checks pins that history. + and checks that history. The [#2281 repair record](oracle-reviews/issue-2281-investigation.md) retains the row-loss and resurrection counterexamples on `82c5f999b`. A lagging source changed rows; another source advanced the shared resume @@ -4408,7 +4408,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. comparisons do not establish clean teardown. A generated cached-row regression also preserves intermediate observations during source-continuity repair. #2209's permanent-close case and #2267's - resumed-update gap remain pinned expected failures under this owner. + resumed-update gap remain fixed expected failures under this owner. A claimed leftover-replay/reset failure still needs a concrete schedule; the bounded two-replay-then-reset probe drains replay before reset. Live Electric/browser OPFS delivery needs the matching schedule in @@ -4430,7 +4430,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. - [x] **Real-provider conformance fixtures.** Frozen 15.2.7 React Native and Node receipts cover the supported peer version; 18.2.1 React Native, Node, and browser receipts cover the known forward shapes. Exact-row checks and a - row-dropping hostile control prove the shim accepts those envelopes without + row-dropping mutant control prove the shim accepts those envelopes without mutating them. Owner: `packages/react-native-db-sqlite-persistence/tests/fixtures/op-sqlite-provider-results.ts` and `packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts`. @@ -4480,7 +4480,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. same-database-handle admission law. - [x] **Startup generation interleavings.** Hold persisted startup between its metadata and hydration snapshots, then cross no write, a managed mutation, - and hostile raw loss. Compare public rows with the atomic durable snapshot, + and mutant raw loss. Compare public rows with the atomic durable snapshot, and require mutation persistence to wait until the prior stream position is known. Owners: SQLite resume snapshots and Electric resume snapshot races. - [x] **Schema-generation fences on cached adapters.** Cross a newer-schema @@ -4658,7 +4658,7 @@ Remaining limits: coverage relies on the source committing a load's rows before it settles that load (law 7); an unordered demand that loses one row and gains another keeps its row count and stays covered, so a row that retention removed can be missing from a covered restore (maintainer -decision, 2026-10-26; a fixed history pins the rule); and multi-tab +decision, 2026-10-26; a fixed history covers the rule); and multi-tab coordinators, outside the law by maintainer decision (2026-10-26). Part 4 now covers a tie at the window boundary, modelled as a limited demand plus an unordered tie-boundary demand, and page moves with and without an index. @@ -4731,8 +4731,8 @@ Solid, Vue, Svelte, and Angular witnesses run the covered, never-loaded, and failure histories through their own hook or primitive. With the contract's session-2 source forced to a 60 s wait and an upstream that never answers, every host and framework reports violations on its covered histories. The -OPFS witness also pins the guide's statement that a multi-tab coordinator -waits for the network; that run is recorded, not judged, because it pins the +OPFS witness also checks the guide's statement that a multi-tab coordinator +waits for the network; that run is recorded, not judged, because it fixes the guide rather than the law. Limits: no CI job runs the Expo, Capacitor, or Tauri e2e suites, and no host @@ -4770,19 +4770,19 @@ reload after a named notice keeps a seed's metadata (decision 18). | Owner | Production paths | Observations and checkpoint | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [sync metadata session oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-metadata-session-oracle.ts) (`flat`, `nested`, `seeds`, `nested-seeds`, `seeds-full`, `nested-conflicts` lanes) | `DbClient` Collection, core hold, `DbClient.hydrate` seeds | `metadata.row.get`, `metadata.collection.get`, `list()`, `list(prefix)`, `collection.base`, each commit receipt's state, thrown errors; after every action and after settlement | -| [persisted driver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts) (`persisted-flat`, `persisted-nested`, `persisted-seeds`, `persisted-cached`, `persisted-conflicts`, `persisted-nested-seeds`, `persisted-reads`, `persisted-reads-seeds`, `persisted-peers`, `persisted-peers-full`, `persisted-peers-on-demand`) | `persistedCollectionOptions` over an in-memory adapter, store hold, core hold, and a storage read held in flight; seeds through a `DbClient`, alone and crossed with nesting and a row cached in storage at startup; a peer's notices, eager, under full row updates, and on demand | the same source reads after every action; `collection.base` and receipts after settlement; in the peer lanes, stored rows after settlement and stored row metadata at every completed durable action cut and after settlement | -| [native SQLite/coordinator receiver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts) | two real Collections and coordinators, node:sqlite, BroadcastChannel and Web Locks; partial/full/on-demand lanes | shared model and source reads after correlated commit/notice/recovery cuts; rows, metadata and applied receipts after settlement; direct durable metadata after each correlated action and durable rows after settlement | +| [sync metadata session oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-metadata-session-oracle.ts) (`flat`, `nested`, `seeds`, `nested-seeds`, `seeds-full`, `nested-conflicts` grammars) | `DbClient` Collection, core hold, `DbClient.hydrate` seeds | `metadata.row.get`, `metadata.collection.get`, `list()`, `list(prefix)`, `collection.base`, each commit receipt's state, thrown errors; after every action and after settlement | +| [persisted driver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts) (`persisted-flat`, `persisted-nested`, `persisted-seeds`, `persisted-cached`, `persisted-conflicts`, `persisted-nested-seeds`, `persisted-reads`, `persisted-reads-seeds`, `persisted-peers`, `persisted-peers-full`, `persisted-peers-on-demand`) | `persistedCollectionOptions` over an in-memory adapter, store hold, core hold, and a storage read held in flight; seeds through a `DbClient`, alone and crossed with nesting and a row cached in storage at startup; a peer's notices, eager, under full row updates, and on demand | the same source reads after every action; `collection.base` and receipts after settlement; in the peer production paths, stored rows after settlement and stored row metadata at every completed durable action cut and after settlement | +| [native SQLite/coordinator receiver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts) | two real Collections and coordinators, node:sqlite, BroadcastChannel and Web Locks; partial/full/on-demand grammar | shared model and source reads after correlated commit/notice/recovery cuts; rows, metadata and applied receipts after settlement; direct durable metadata after each correlated action and durable rows after settlement | | [recovery schedules](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-metadata-recovery-oracle.ts) using the same session model and native receiver | response delivery, storage reading and captured-row application held separately; bare/non-bare seeds, a second tab's source write, or cleanup during one recovery on demand | source metadata before application, after the interruption and after completion; stale source reads at cleanup start and after late work; direct durable metadata after completion | | paired properties and boundary witnesses in the core driver file | production against itself: holds removed, row 2's actions removed, or seeds performed first; stale run; write without a transaction; cleanup with a held seed | source reads (and row 1's view) at each shared checkpoint, or every observation after settlement for seed order; thrown errors; unhandled rejections | -| [row metadata composition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts) held-window and nested lanes | Collection, core hold, a transaction begun inside an open one, `DbClient.hydrate` | implicit row-metadata rules during a hold, and for an outer transaction whose inserts are reclassified after an inner write applies first, with and without a hold | +| [row metadata composition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts) held-window and nested grammar | Collection, core hold, a transaction begun inside an open one, `DbClient.hydrate` | implicit row-metadata rules during a hold, and for an outer transaction whose inserts are reclassified after an inner write applies first, with and without a hold | | [hydration authority oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/db-client-hydration-authority-oracle.test.ts) | `DbClient` initialData, hydration chunks, and an adapter sync transaction (inserts, updates, deletes, truncates, and metadata-only writes) in full and partial row modes | public rows, the synced store, and row metadata through `client.dehydrate()` after each action; D5 per object | | [Query ownership lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts) receiving witnesses | Query DB claims, releases, and retention cleanup over core and `persistedCollectionOptions` | published owners and non-owner row metadata | | receiving expectations in the [persisted oracle](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), the Electric oracle, the Browser coordinator oracle, and the Electron IPC tests | persisted source inserts over a hydrated baseline; the durable mutation a source insert writes | public and durable rows; persisted mutation types | On `30175be48`, 51 of 186 tests fail in the session, composition, and retention files: held reads, commit order and outer writes after a held inner -commit, every seed rule, and the paired properties. The persisted lanes fail +commit, every seed rule, and the paired properties. The persisted production path fails the held reads through core, reads of an enclosing open transaction, an insert's implicit clear, equal re-inserts, inserts over seeds and cached rows under partial row updates, and late seeds beside a store-held transaction. @@ -4802,7 +4802,7 @@ the review record: transaction: invalidation removes the whole transaction, row 1's writes included (decision 7). -The `persisted-peers` lane plays a second tab's source through the +The `persisted-peers` production path plays a second tab's source through the persistence coordinator: named row, metadata-only, delete, full-reload, and reset notices, crossed with seeds, the cached row, nesting, and holds. A reload that a notice causes is a storage read: its truncate replaces rows @@ -4812,19 +4812,19 @@ its status, and a seed's metadata survives a reload after a named notice later notice in its term reloads storage; on demand the reset keeps the seeds this tab holds (decisions 19 and 20). After settlement the stored rows must equal the model's, which a restart would read. -`persisted-peers-full` repeats the lane under full row updates, where an +`persisted-peers-full` repeats the production path under full row updates, where an update replaces its row in storage too; `persisted-peers-on-demand` repeats it on demand with a demand for every row. A reset over metadata of a missing -row is too rare in generated histories to declare; a replay pins it. +row is too rare in generated histories to declare; a fixed history covers it. An equal source re-insert keeps metadata without writing it (decisions 9 and 14). As with an update, that keep neither makes a DbClient seed durable nor sends it to peers (decision 18). Direct durable metadata is compared separately from this tab's source reads. The constructive durability grammar includes all 36 update/equal-reinsert/inner-reinsert × bare/non-bare seed × implicit/explicit -metadata × no-notice/named-notice/full-reload cases in each generated batch. Its partial/full/on-demand lanes run +metadata × no-notice/named-notice/full-reload cases in each generated batch. Its partial/full/on-demand grammar run through both the controlled persisted adapter and the real SQLite/coordinator -receiver, with 108 declared case/lane/notice states per host, each reached eight times +receiver, with 108 declared case/production path/notice states per host, each reached eight times in both fixed and random campaigns. Seed row shape, seed timing and repeat count vary; no campaign can omit the full reload. Explicit metadata writes are the neighboring control. The original seed/re-insert/reload grammar, fixed/random @@ -4911,7 +4911,7 @@ remains separate and open under this owner, with P8 investigating: property controlled-notice witness or the broader #2281 persistence reports. Remaining observation work under this owner: the read-hold and abandonment -lanes retain source-facing comparisons but do not provide the peer lanes' +production paths retain source-facing comparisons but do not provide the peer production paths' direct durable transaction trace. A receiving witness must stage a wrong durable write followed by repair while a read hold buffers a commit, including abandonment, and reject it at the appropriate durable boundary. These additions @@ -4929,10 +4929,10 @@ this run-only metadata from durable metadata when the outer update recreates the row; the old model's false durable expectation masks the later source failure. Owner: this session oracle. The generated `sync-metadata-session.{persisted,sqlite}-missing-row-metadata` campaigns -cover twelve cases in all three peer lanes: implicit/explicit metadata, +cover twelve cases in all three peer production paths: implicit/explicit metadata, immediate/core-held application, a prior source truncate, same-key peer row or metadata replacement, unknown-write reloads, peer/source delete and source -truncate. All 36 case/lane states reach eight times in each fixed/random campaign +truncate. All 36 case/production path states reach eight times in each fixed/random campaign on both hosts. The wrapper distinguishes local metadata from a named peer replacement, preserving D16 without defeating D11 or D15. Three wrong retention policies fail the same source-read comparisons. The @@ -5345,7 +5345,7 @@ establish elapsed-time performance. ## Compound joins [The replacement evidence record](oracle-reviews/2026-10-05-compound-joins.md) -records RED admission on main, GREEN refinement, and hostile-mutant failures. +records RED admission on main, GREEN refinement, and mutant-mutant failures. The cold-join reconciliation owner now covers compound equality classes, nullish operands, two/three terms, order/reversal/nesting, all four join types, scan/index paths, controlled cold acquisition, and keyed replacement histories. @@ -5543,7 +5543,7 @@ records calibration, validation and the remaining limits. - DP17: `packed-consumer.test.ts` supplements declaration tests with installed tarball ESM/CJS consumers and broken-export controls. The browser spec builds the same consumer without source aliases and executes it in all three engines. - Tarball overrides pin the installed dependency closure; registry range selection, + Tarball overrides fix the installed dependency closure; registry range selection, older Node/tool versions and every public export remain outside this smoke test. The ordinary local Playwright WebKit profile rejects Blob preparation with @@ -5859,7 +5859,7 @@ artifacts because package exports otherwise resolve stale `dist` files. Reachable limits remain. A source that declares a restart capability must itself retire callbacks synchronously when recovery calls it. Claim expiry after core publication but before SQLite accepts the write rejects the receipt -and leaves the Collection in terminal error; a real-SQLite hostile probe +and leaves the Collection in terminal error; a real-SQLite adversarial probe confirmed the row can remain public without becoming durable. The ordinary durability-failure law permits that fail-stop, while automatic expiry reload covers admission before publication. The first new-format @@ -5917,10 +5917,10 @@ The [cache-generation oracle audit](oracle-reviews/issue-2069-cache-generation-o records the generated grammar controls, killed mutants, direct replays, and the remaining handoff limits for code commit `53ded62d8ef70ccd63b75d0e6dbc0a964f14b17c`. The [cache-claim gap audit](oracle-reviews/issue-2069-cache-claim-gap-audit-575fb9473.md) -records the added receiving witnesses, hostile checks, direct replay, and +records the added receiving witnesses, mutant checks, direct replay, and remaining limits for code commit `575fb9473dd7d8eccbe469d0831dc9f71cffddd4`. The [native SQLite index receiving audit](oracle-reviews/issue-2069-native-ddl-receiver-b4d57ced6.md) -records the two reverse-order DDL witnesses, hostile checks, and remaining +records the two reverse-order DDL witnesses, mutant checks, and remaining host-specific limit for code commit `b4d57ced63d513dd0dcb794b8e8bb2a840577758`. The [follow-up review audit](oracle-reviews/issue-2069-native-ddl-review-followup-b42b94b73.md) records the exact recovery-settlement and target-table assertion repairs for @@ -6023,7 +6023,7 @@ demands rather than leaving a renewed gate pending. The scoped reset history acquires a snapshot before must-refetch, then requires subset-end to apply its row before demand success without waiting for a full up-to-date. The neighboring full-mode reset retains that stronger wait. The -Electric durable fixture has an adversarial insert check that rejects retained +Electric durable fixture has an adversarial keys insert check that rejects retained old columns or tag metadata; it is driver validation, not a new model owner. The Electric adapter owner now holds an asynchronous user retry decision and checks that a new full-recovery demand remains pending until source replacement, @@ -6269,17 +6269,17 @@ named callbacks across 200 finite single-source, LEFT-join and populated-union histories. It preserves previous values, multiplicity, field absence/undefined, typed source keys, and opaque event-key continuity only within active intervals. Held mutations, independent confirmations/outcomes, both settlement orders, -remote updates, peer exit/reentry and next-use reach 5,512 lane/channel/cut cells +remote updates, peer exit/reentry and next-use reach 5,512 production path/channel/cut cells exactly once. No implicit Effect-root metadata or output Collection ID is claimed. Original P09 evidence: all 64 LEFT-join histories emitted a startup update for a never-entered unmatched occurrence after its load finished. Those initial comparisons were RED on unchanged `858b174a6`; histories continued and all later comparisons passed. -The other 136 native histories and 16 controls pass. Independent hostile +The other 136 native histories and 16 controls pass. Independent mutant overlays reject dispatch, previous-value, duplication, colliding-key and extra undefined defects; lawful key renaming, fresh reentry and empty partitions retain -the baseline. Per-lane omissions and seeded partial selections now fail reach. +the baseline. Per-campaign omissions and seeded partial selections now fail reach. The finite owner explicitly disables the irrelevant shared replay exemption; O26 retains a broader campaign inventory for that harness class. See chunk 16 in the RC chunk record for exact fingerprints, evidence and review. @@ -6647,7 +6647,7 @@ histories remain. Unkeyed functional projections are not legal derived-identity framework inputs, so they receive no framework handoff credit. Legal fresh-object expression controls provide those neighboring receiving comparisons instead. See the [second review record](oracle-reviews/2026-10-30-solid-oracle-review-round-two.md) -for complete intake, RED gates, GREEN, hostile variants, and bounded closure. +for complete intake, RED gates, GREEN, mutant variants, and bounded closure. The same review adds six ordinary/subclass-array overlap cells. Dotted writes select lengths zero, one, and two while current and retained source arrays keep @@ -6725,7 +6725,7 @@ at checkpoint 2. Inline arrays pass: `createActiveBuckets` is used only for Collection-valued includes. This is a receiving witness for route coexistence, shared support, partial/last removal and reactivation, not a claim about every facade lifecycle observation. Four-member buckets are exercised at D2; the -public includes witness has two colliding routes and one anchor. Neither lane +public includes witness has two colliding routes and one anchor. Neither production path claims mutable values, arbitrary payload kinds, framework scheduling or intermediate source-transaction publication cuts. @@ -6780,7 +6780,7 @@ See the appended [review evidence](oracle-reviews/2026-10-30-distinct-collisions ### Adapter-owned page windows -The framework owner includes [window-history-oracle.ts](../../packages/db/tests/conformance/window-history-oracle.ts), registered through the shared conformance suite in React, Preact, Vue, Solid, Angular and Svelte. Maintainer decision 2026-10-30 for #2291: changing only `.limit()`/`.offset()` moves the existing outer window; it adds no hook option. The independent sort/filter/slice model checks ordered selected rows and keyed-map values after each framework flush, Collection retention for page moves, replacement for source/filter/order/projection/nested-window changes, and retained upstream ownership. Direct and nested paths have separate reach, as do changing/stable/opaque keys where the driver supports keys. Keyed source swaps include distinct source objects under one Collection ID. Generated source updates check liveness after reuse. The bare-callback lanes also vary a Collection option together with a page change and check its applied value. The pending lane observes a move during initial loading, oldest settlement while the latest remains pending, initial readiness, overlapping held acquisitions, final latest rows, and unmount before settlement. The publication-callback lane changes offset, limit or both from a public change callback, then checks the retained Collection after the host settles. It controls demand settlement over already installed source rows; the core pagination owner continues to own source delivery, failures and ordered repair. +The framework owner includes [window-history-oracle.ts](../../packages/db/tests/conformance/window-history-oracle.ts), registered through the shared conformance suite in React, Preact, Vue, Solid, Angular and Svelte. Maintainer decision 2026-10-30 for #2291: changing only `.limit()`/`.offset()` moves the existing outer window; it adds no hook option. The independent sort/filter/slice model checks ordered selected rows and keyed-map values after each framework flush, Collection retention for page moves, replacement for source/filter/order/projection/nested-window changes, and retained upstream ownership. Direct and nested paths have separate reach, as do changing/stable/opaque keys where the driver supports keys. Keyed source swaps include distinct source objects under one Collection ID. Generated source updates check liveness after reuse. The bare-callback grammar also vary a Collection option together with a page change and check its applied value. The pending grammar observes a move during initial loading, oldest settlement while the latest remains pending, initial readiness, overlapping held acquisitions, final latest rows, and unmount before settlement. The publication-callback grammar changes offset, limit or both from a public change callback, then checks the retained Collection after the host settles. It controls demand settlement over already installed source rows; the core pagination owner continues to own source delivery, failures and ordered repair. The [React receiving witness](../../packages/react-db/tests/window-render-oracle.test.tsx) reuses this model/checker at suspended, cancelled and committed render cuts for eager and finite on-demand sources, with derived and explicit keys. It checks the captured committed Collection and provider acquisition count while a render is abandoned. The [Preact receiving witness](../../packages/preact-db/tests/window-render-oracle.test.tsx) checks an aborted render under a real error boundary while a public subscription keeps the committed Collection active. Both run separate offset-only, limit-only and combined-move campaigns with declared reach per category. These do not establish arbitrary concurrent framework schedules. The retained-work observation checks continuous upstream ownership and every public subscription-count transition during a retained move; add-before-remove replacement fails, while retired Collections may complete GC. This is not a bound on CPU time or every possible internal compilation strategy. DbClient/hydration and Suspense shared-result caches, supplied Collections, opaque-plan optimization, queries without an outer limit and findOne retain their existing contracts and paths; this fix does not retarget their shared Collections. The generated retention guarantee covers finite bounds. An explicit Infinity limit can also move to a finite target under the resolver guard, but this evidence does not establish that additional domain or promise replacement outside the finite domain. The existing identity, deferred-acquisition, window-controller and includes owners remain authoritative for their neighboring laws. @@ -6788,7 +6788,7 @@ Evidence, per-path reach counts, reviewer reconciliation and mutation outcomes a The [fresh independent review](oracle-reviews/2026-10-30-adapter-page-windows-round-2.md) records observation and failure-fidelity repairs. Shared and receiving campaigns preserve the first violated law, checkpoint and input trace through shrinking. Later action and cleanup failures remain distinguishable; non-reproducing shrink candidates supply no successful product evidence. The original late-coverage process gap remains documented. -The existing [top-K support-window owner](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts) also has a numeric-window lane for safe-integer maximum offsets and widths, exact omitted-bound inheritance on partial moves, and required demand messages when installed ranges are empty. It permits additional messages. It observes one-group settled operator rows, size and demand signals; real framework source growth belongs to the shared receiving owner above. [Numeric evidence](oracle-reviews/2026-10-30-topk-numeric-windows.md) records RED, GREEN, reach, replay and mutants. +The existing [top-K support-window owner](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts) also has a numeric-window grammar for safe-integer maximum offsets and widths, exact omitted-bound inheritance on partial moves, and required demand messages when installed ranges are empty. It permits additional messages. It observes one-group settled operator rows, size and demand signals; real framework source growth belongs to the shared receiving owner above. [Numeric evidence](oracle-reviews/2026-10-30-topk-numeric-windows.md) records RED, GREEN, reach, replay and mutants. ### Projection and negative-window gaps from removal review (2026-11-02) @@ -6803,12 +6803,12 @@ identical offset/limit across projection changes, including positive offsets. Native cuts require raw shape; settled cuts also compare selected values and keyed state. It is one `oracleProperty` definition, registered by each framework's conformance suite with that framework's driver as -`framework..result-shape`; two segments enter its twelve required -states on every lane, the former fixed examples are pins, and the previous-mode +`framework..result-shape`; two constructed histories enter its twelve required +states on every production path, the former fixed examples are fixed histories, and the previous-mode projection and stale rows are calibrations. Reintroducing the Angular defect below fails 32 of its tests, each at law `current query projection`. This does not promise synchronous row freshness or identical host scheduling. Disabled inputs, errors, supplied Collections, DbClient and broad -host/AOT behavior remain outside this lane. +host/AOT behavior remain outside this grammar. **Repaired receiving defect:** Angular previously returned the old query's raw projection at its synchronous Signal read before effects. Both directions @@ -6830,7 +6830,7 @@ cardinality. Signed rows, order, values, multiplicity and size remain observed. The direct and orderBy size getters also count with an always-true predicate, which must equal the selected row count. Negative widths, fractional bounds, multiple groups, varying prefix predicates and downstream acquisition remain -outside this lane; existing support/numeric budgets and their prior evidence +outside this grammar; existing support/numeric budgets and their prior evidence remain unchanged. The negative-width question has a maintainer decision (2026-11-02): public @@ -6865,7 +6865,7 @@ raw authored IR, framework error timing and provider delivery are outside the new admission evidence. Existing findOne, order and reentrancy error precedence remains separate. All 24 fixed/random admission campaigns reach every declared state in 21 histories each; unchanged production fails all six negative -receivers, and eight hostile mutations fail the intended comparisons. See the +receivers, and eight mutant mutations fail the intended comparisons. See the [admission evidence](oracle-reviews/2026-11-02-negative-limit-admission.md). **Repaired operator defects:** negative-window rows, size, source edits and @@ -6974,7 +6974,7 @@ campaign measured 58 divergence repairs, of which 57 lacked that overwrite premise and one had it (81 initiated/80 accepted histories). Earlier final "lagging rewrite" counts are historical divergence evidence. A fourth terminal regime constructs the actual overwrite. Original arbitrary/reset/stop regimes, -24-step prefix, 8–24-step arbitrary tail, models, comparisons, known gaps, +24-step prefix, 8–24-step arbitrary tail, models, comparisons, known limits, 80-run budgets and minima remain. New lagging suffix length is 12/15 for two/three tabs. Final commit omission fails the ordinary durable/public comparison. diff --git a/docs/contributing/oracle-rubric.md b/docs/contributing/oracle-rubric.md index df249ca78d..cb6f3ac0d6 100644 --- a/docs/contributing/oracle-rubric.md +++ b/docs/contributing/oracle-rubric.md @@ -143,7 +143,7 @@ presence of a preferred heading, class, comment template, or helper. - **Trigger:** An important generated property or a claimed oracle repair. - **Obligation:** The evidence MUST name at least one plausible wrong answer or design and demonstrate that the relevant comparison rejects it. -- **Acceptance evidence:** A wrong-result control, hostile fixture, fault +- **Acceptance evidence:** A wrong-result control, mutant fixture, fault injection, or production mutant reaches the intended checkpoint and preserves the distinguishing failure. - **Not required:** Modifying production, checking in a mutant, or running a @@ -158,7 +158,7 @@ presence of a preferred heading, class, comment template, or helper. - **Trigger:** An important generated property. - **Obligation:** The package's oracle campaign MUST execute the same property, generators, observation recorder, refinement check, and run budget twice: - once with a documented fixed seed and once without a seed. A replay interface + once with a documented campaign seed and once without a seed. A replay interface MUST accept both the seed and shrink path and MUST run the requested replay directly. When the property uses `fc.commands`, the interface MUST also accept the commands arbitrary's reported `replayPath` and pass it back to @@ -167,7 +167,7 @@ presence of a preferred heading, class, comment template, or helper. campaign records its execution, and a checked replay command reproduces a captured seed-and-path failure, including the command replay path when the property uses `fc.commands`. -- **Does not satisfy:** Fixed examples do not replace the fixed-seed campaign. +- **Does not satisfy:** Fixed histories do not replace the fixed-seed campaign. Different generators, observation recorders, refinement checks, or budgets do not establish campaign parity. - **Not required:** Running either normal campaign before an explicit replay. @@ -233,13 +233,13 @@ presence of a preferred heading, class, comment template, or helper. applicable numbered requirement and MUST identify the requirements that do not apply. - **Acceptance evidence:** Each applicable requirement has concrete evidence or - an explicit unresolved gap. Each non-applicable requirement has a reason tied + an explicit unresolved limit. Each non-applicable requirement has a reason tied to its trigger. - **Conditional bug-class closure:** When a change claims to eliminate a bug class, the review evidence MUST name its contract × history × production-path × observation boundary, identify the original and adjacent distinguishing witnesses, show a relevant wrong design rejected at the intended checkpoint, - and list unresolved in-scope cells with their coverage-map owner. A known + and list unresolved in-scope cells with their coverage-map oracle owner. A known reachable counterexample prevents a closure claim. One oracle need not own every boundary. - **Evidence location:** At closeout, verdict-critical evidence outside the @@ -258,7 +258,7 @@ presence of a preferred heading, class, comment template, or helper. premise and a nearby in-domain witness whose observation at the claimed checkpoint distinguishes the stated law from a plausible wrong boundary or consequence. If those witnesses are unavailable, the claim MUST be limited - to the tested cases, and the reusable-law gap and its owner MUST be recorded. + to the tested cases, and the reusable-law limit and its oracle owner MUST be recorded. - **Acceptance evidence:** The test or review record names the wrong rule and shows which existing case rejects it. For a numeric threshold, cases on opposite sides are insufficient if a plausible misplaced threshold still @@ -273,7 +273,7 @@ presence of a preferred heading, class, comment template, or helper. provider or host. - **Obligation:** The evidence MUST identify that premise and either point to a receiving witness in which the real provider or host supplies the same - premise, or mark the cross-boundary claim unresolved with an owner. + premise, or mark the cross-boundary claim unresolved with an oracle owner. - **Shared law:** When the controlled and real environments check the same law, the receiving witness SHOULD be another production driver for the same oracle. It @@ -318,7 +318,7 @@ presence of a preferred heading, class, comment template, or helper. broader campaign. They MUST NOT restate their expected answers. A type oracle MUST apply a reusable independent type law to generated legal inputs. - **Acceptance evidence:** A reviewer can vary the input without adding an - expectation. Every retained regression and receiving driver uses the shared + expectation. Every retained regression and receiving production driver uses the shared judgment. Checker calibration supplies deliberately wrong observations to that judgment. - **Does not satisfy:** Renaming a unit test, adding contract prose, enumerating @@ -347,7 +347,7 @@ Before writing the loop, answer six questions: | What will we compare, and when? | Rows, order, events or other promised behavior, at a named checkpoint. | | Why should we trust this check? | Evidence the path ran, the comparison rejects the relevant wrong answer, and failures can be replayed. | -These are responsibilities, not six required classes. A small property may answer them in a comment and twenty lines of code. A lifecycle suite may need a separate model and driver. Do not build a framework just to fill the table. +These are responsibilities, not six required classes. A small property may answer them in a comment and twenty lines of code. A lifecycle suite may need a separate model and production driver. Do not build a framework just to fill the table. An evidence card is an optional way to collect this information; it is not a required file format: @@ -398,10 +398,10 @@ imports nothing so that browser and native-app modules can use it: the check compares production observations with the model or with a second formulation, and the grammar is the generators that produce legal histories. Each part is optional. Every test in the file uses a declared part, directly -or through a driver: +or through a production driver: - A generated property or a fixed history runs through the check. -- A model pin steps the model and checks one rule against its authority. +- A fixed history steps the model and checks one rule against its authority. - A calibration shows that the check rejects a wrong answer (ORC-006). - A grammar calibration shows that the generators reach the states that the laws need (ORC-004, ORC-015). @@ -430,16 +430,16 @@ module is linted on its own, so the rule does not report a call to it. An rule judges a call to its helpers as it judges an ordinary module's. ORC-016 keeps ordinary unit tests and concrete type assertions out of oracle -files. Fixed regressions replay the shared law; supplemental examples live in +files. Fixed regressions run through the shared law; supplemental examples live in ordinary test files. An ordinary example or regression does not need this suffix merely because it asserts a contract. The same applies to concrete type assertions, fixtures, -registration wrappers, and generic runner utilities. A driver that only invokes +registration wrappers, and generic runner utilities. A production driver that only invokes production or records observations remains support. Coverage-map inclusion, parameterization, and contract prose alone do not establish oracle ownership. -Count owners, model/checker definitions, drivers, and other support separately. +Count oracle owners, model/checker definitions, production drivers, and other support separately. Several files may execute one shared oracle, and one file may contain several oracle mechanisms. A filename is a discovery aid, not evidence of guide conformance. Preserve runner suffixes such as `.property.test.ts`, `.test-d.ts`, @@ -608,18 +608,18 @@ rather than a generic session, state, owner, or generation. When production renames a concept, update its glossary entry and the models that represent it in the same change. -### Audit prose, model, driver, and observations together +### Audit prose, model, production driver, and observations together Treat the layers as separate claims. Compare each pair during review: 1. Does every promise in the prose have a model rule? 2. Does the model retain every distinction that a legal next action can expose? 3. Can the history grammar reach each modeled transition? -4. Does the driver exercise the named production path and event boundary? +4. Does the production driver exercise the named production path and event boundary? 5. Does each modeled output reach an assertion at the promised checkpoint? 6. Can the recorder represent duplicate, missing, reordered, or partial output? 7. Does any assertion impose behavior that the contract and model do not state? -8. Do prose, model, driver, and production use the glossary's canonical term +8. Do prose, model, production driver, and production use the glossary's canonical term and transition grammar for each shared concept? 9. Does every model-only term declare how it maps to production, or that it has no production counterpart? @@ -628,7 +628,7 @@ This comparison is a useful audit instrument. ORC-012 requires outcomes for the applicable numbered requirements, not these questions or their answers in the test file. If the prose cannot explain an assertion through the model, the model may be incomplete. If the model predicts a fact that the test never observes, -the test may be false green. If the driver cannot create a named phase, the +the test may be false green. If the production driver cannot create a named phase, the prose claims more reach than the test has. ### Keep the prose proportional @@ -699,7 +699,7 @@ Take two rows: `(id: 1, rank: 2)` and `(id: 2, rank: 1)`, with width two. The ex That is a history: a starting state followed by actions. The generator can produce many such histories while the same reference computes each answer. -The integration loop below is pseudocode; the named driver operations are not TanStack APIs: +The integration loop below is pseudocode; the named production driver operations are not TanStack APIs: ```text create independent model and production fixture @@ -727,18 +727,18 @@ After choosing an action kind, choose its key from the applicable set. This is c Shrinking means reducing a failing input or action list while keeping its failure. Design action dependencies so the smaller histories remain meaningful; don't disable shrinking merely because the test is stateful. The [fast-check model guide](https://fast-check.dev/docs/advanced/model-based-testing/) describes commands and replay; check the installed version before copying an API recipe. -Pin the structural cases that matter: no rows, width zero, a boundary tie, and repeated changes to one key. Exhaust a small domain where that is cheap. Then randomize values and longer legal histories. Fixed cases, bounded enumeration and random exploration do different jobs; overlap between them is not a defect. +Include fixed histories for the structural cases that matter: no rows, width zero, a boundary tie, and repeated changes to one key. Exhaust a small domain where that is cheap. Then randomize values and longer legal histories. Fixed cases, bounded enumeration and random exploration do different jobs; overlap between them is not a defect. ### Run fixed and random campaigns with direct replay -A fixed fast-check seed always generates the same cases. This makes a useful +A fixed campaign seed always generates the same cases. This makes a useful history stable, but it does not explore new histories on later runs. Do not describe a fixed-seed property as random coverage. Under ORC-007, every important generated property runs in two campaigns: -1. Run a fixed seed that preserves a known useful campaign. -2. Run without a seed so fast-check chooses a new seed. +1. Run a fixed campaign seed that preserves a known useful campaign. +2. Run without a campaign seed so fast-check chooses a new seed. When the random campaign fails, retain the reported seed and shrink path. Give the suite environment variables or another checked replay interface. The replay @@ -757,20 +757,20 @@ reproduce. The fixed and random campaigns must use the same property, generators, observation recorder, refinement check, and run budget. Only their seed source -may differ. This keeps a random failure eligible for promotion into a pinned -example or fixed campaign. +may differ. This keeps a random failure eligible for promotion into a fixed +history or the fixed-seed campaign. [`fifo-retry-oracle.property.test.ts`](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) -shows this shape. Its fixed run preserves one scheduler campaign. Its second -campaign uses a random seed by default. To replay its recorded failure, set +shows this shape. Its fixed-seed campaign preserves one scheduler campaign. Its second +campaign uses a random campaign seed by default. To replay its recorded failure, set `TANSTACK_DB_ORACLE_PROPERTY=offline.fifo-retry` with -`TANSTACK_DB_ORACLE_SEED` and `TANSTACK_DB_ORACLE_PATH`. The fixed run stands +`TANSTACK_DB_ORACLE_SEED` and `TANSTACK_DB_ORACLE_PATH`. The fixed-seed campaign stands aside, and the random campaign runs the recorded path once. The offline -owners' other properties use the same variables and name their own properties, +oracle owners' other properties use the same variables and name their own properties, such as `offline.leadership.stale-read` and `offline.settlement.retry-decision`. Do not use a larger random run count as a substitute for structural reach. -Pinned examples force rare boundaries. Bounded enumeration proves small finite +Fixed histories force rare boundaries. Bounded enumeration proves small finite domains. A stress job increases sampling depth. Record each form separately. ### Make the comparison prove its usefulness @@ -874,16 +874,16 @@ test releases write delivery and lets production apply it test records the visible state at the promised applied checkpoint ``` -The fixture must really hold write delivery. If it delivers the writes and only delays returning from its load function, it controls a different event. Whether the returned Promise settles before or after application is a separate contract question. Record publications during the held interval if the promise concerns what users can see then. A scheduler is helpful only within the boundaries it controls; a seed cannot schedule arbitrary external I/O. The [fast-check scheduler documentation](https://fast-check.dev/docs/advanced/race-conditions/) explains the distinction between an operation's lifecycle and delivery of its result. +The fixture must really hold write delivery. If it delivers the writes and only delays returning from its load function, it controls a different event. Whether the returned Promise settles before or after application is a separate contract question. Record publications during the held interval if the promise concerns what users can see then. A scheduler is helpful only within the boundaries it controls; a campaign seed cannot schedule arbitrary external I/O. The [fast-check scheduler documentation](https://fast-check.dev/docs/advanced/race-conditions/) explains the distinction between an operation's lifecycle and delivery of its result. -### Treat driver timing helpers as premises +### Treat production driver timing helpers as premises A production driver often needs helpers such as "wait until the request is blocked" or "let the message reach the worker". Each helper establishes a premise. If a helper does less than its name says, every production driver that uses it can go green without reaching the history. -In one [recorded case](oracle-reviews/pr-2213-opfs-close.md), a driver loop +In one [recorded case](oracle-reviews/pr-2213-opfs-close.md), a production driver loop shared by the Node and Chromium production drivers gave back a lock in the same turn in which it submitted close. The close message was still crossing a task boundary, so the earlier request finished first. Close never ran during the @@ -953,14 +953,14 @@ restate the expected results. The OPFS request-order oracle shows the layout. [`opfs-request-order-oracle.ts`](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts) -owns the law, the model, the fixed examples, the driver loop, and the +owns the law, the model, the fixed histories, the production driver loop, and the comparison. The [Node driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.test.ts) and the [Chromium page driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order.opfs.ts) supply the entry point, access-handle control, database names, and result reporting. The Node driver also generates random histories from the same -grammar. Neither driver states expected results, and both feed the same +grammar. Neither production driver states expected results, and both feed the same comparison. The [Chromium spec](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts) checks for page errors, a completed run, one result per example, and no @@ -1054,7 +1054,7 @@ Original trace; reduced trace; reproduction result Primary failure; separate cleanup diagnostics ``` -Check replay commands against the installed tool. A seed is not a recording of the network, operating system or every Promise. If a challenge influenced the design, keep it as a regression or tuning case; it is no longer a held-out test of that design. +Check replay commands against the installed tool. A campaign seed is not a recording of the network, operating system or every Promise. If a challenge influenced the design, keep it as a regression or tuning case; it is no longer a held-out test of that design. ## Review and maintain the portfolio @@ -1141,7 +1141,7 @@ or a claimed repair, ask: Adapter and lifecycle oracles should consider these laws when the contract has the corresponding boundary. They are prompts, not universal requirements. State -why an inapplicable law does not belong to the owner instead of adding a vacuous +why an inapplicable law does not belong to the oracle owner instead of adding a vacuous case. - **Real-provider conformance:** freeze representative values from each @@ -1168,7 +1168,7 @@ case. result and a deterministic work/cardinality measure. Correct rows alone do not establish the work law. -Every owner should also state its known omissions beside the contract. The +Every oracle owner should also state its known limits beside the contract. The coverage map tracks open reusable laws; an unchecked item is not evidence that the neighboring laws are absent. diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 58f4864ecf..0e22d52802 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -431,7 +431,7 @@ through FROM, JOIN and union placement at initial graph drain. Its raw output comparison is differential and supplies no independent Effect-root attribution policy. Optimizer cloning can prevent an original raw-key cache hit in union placement; that path checks propagation and transparency instead. -Separate static receiving-type gaps remain in the +Separate static receiving-type limits remain in the [deferred oracle record](../../../../../docs/contributing/oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md). D2 hashes enumerable symbol keys and uses exact local-symbol identity plus @@ -2083,10 +2083,10 @@ expected-failure guard for a planner or ownership defect that this graph does not own. Run the DB oracle set with `pnpm test:oracles` from `packages/db`. Broad -properties use FastCheck's random seed, while structural matrices keep fixed -seeds so each run covers the same named cells. Increase both corpora with -`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER=10 pnpm test:oracles`. Preserve FastCheck's -reported seed and shrink path while reducing a failure. Replay a broad +properties draw a random campaign seed, while structural matrices use fixed-seed +campaigns so each run covers the same named cells. Increase both corpora with +`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER=10 pnpm test:oracles`. Preserve the reported +campaign seed and shrink path while reducing a failure. Reseed a broad campaign with `TANSTACK_DB_ORACLE_SEED= pnpm test:oracles`, then add the smallest case as a deterministic regression trace. diff --git a/packages/db/tests/indexed-db/ORACLE.md b/packages/db/tests/indexed-db/ORACLE.md index 7354b82f1b..dfe26817d2 100644 --- a/packages/db/tests/indexed-db/ORACLE.md +++ b/packages/db/tests/indexed-db/ORACLE.md @@ -43,7 +43,7 @@ replacement. Manual acceptance writes overlapping keys with distinct values in two stores. Every history ends with fresh restores. The authored reconstruction history visits every action in both stores. -Pinned cases retain generated update/clear and repeated no-op histories. +Fixed histories retain generated update/clear and repeated no-op histories. Matrices reconstruct CRUD × decision, persistence-entry × bad-row-position, and remote delete/clear × local rollback/commit. Transport cases retain the old rapid/concurrent and routing coverage and add the twelve histories below. @@ -204,9 +204,9 @@ introduced. ## Campaigns and direct replay -The normal test command runs the same property, generator, recorder, driver and -30-run budget twice: fixed seed 1179001, then no supplied seed. Each campaign -reports its actual seed, run count and outcome. Fast-check reports counterexample +The normal test command runs the same property, generator, recorder, production driver and +30-run budget twice: fixed campaign seed 1179001, then no supplied campaign seed. Each campaign +reports its actual campaign seed, run count and outcome. Fast-check reports counterexample and shrink path; verbose output retains failed histories. A reduced trace proves its reported checkpoint; do not label a reduction a reproduction of a different facet without replaying both. @@ -285,7 +285,7 @@ The executable companions keep distinct responsibilities: | `pending-history-oracle.test.ts` | One held multi-row intent, durable rows, exposed base, queued source batches and acknowledgement attribution; public/raw/downstream snapshots at handler entry, each peer step and settlement | 24 CRUD × peer action × decision cases, with all actual peer CRUD operations reached; 30+30 generated histories of up to four peer actions, 1–3 local rows; stress 300+300 | | `persistence-oracle.test.ts` | Three distinct Collection owners, mixed deletes/inserts, two stores and version isolation after each acceptance | All six acceptance orders; no cross-Collection atomicity or same-ID identity policy | | `settlement.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | Failure before, within and after valid rows in a three-row native batch | First/middle/last clone failures × automatic/manual/import, each a `SerializationError`, with populated replacement rollback | -| `e2e/indexed-db/cross-tab-oracle.spec.ts` | Native IndexedDB and BroadcastChannel, independent same-origin pages, runner-owned raw evidence | Chromium, Firefox and WebKit; pinned receiving matrix and 10 fixed + 10 fresh histories per engine | +| `e2e/indexed-db/cross-tab-oracle.spec.ts` | Native IndexedDB and BroadcastChannel, independent same-origin pages, runner-owned raw evidence | Chromium, Firefox and WebKit; fixed receiving matrix and 10 fixed + 10 random-campaign histories per engine | The transport model combines exposed base and public rows only when local mutations have settled. The pending companion keeps them separate. It adapts @@ -419,7 +419,7 @@ non-truncating full-row comparison. Automatic writes intentionally wait for their predecessor; they can no longer supply that old fixture schedule. Runtime/type tests resolve workspace source with no dependency build. The -packed-package lane tests ESM/CJS consumers after complete builds. +packed-package production path tests ESM/CJS consumers after complete builds. The review evidence and guide audit are in [`2026-10-05-indexeddb-xhigh.md`](../../../../docs/contributing/oracle-reviews/2026-10-05-indexeddb-xhigh.md). diff --git a/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md b/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md index b26531de3f..a1451565b8 100644 --- a/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md +++ b/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md @@ -137,7 +137,7 @@ accepted data-before-reset and reset-before-data in the protocol model. `electric-sdk-framing.test.ts` independently uses the real SDK with controlled HTTP responses. Both normal and stale-row-bearing 409 bodies produce singleton -reset callbacks, as the generated protocol histories now require. This pins the +reset callbacks, as the generated protocol histories now require. This fixes the installed SDK's HTTP reset path, not an exhaustive specification of every possible server response or SSE path. @@ -159,7 +159,7 @@ delivers a new insert and a partial update after the initial up-to-date. Every contiguous callback partition is tested. Expected rows follow tag membership and ordinary row updates, without consulting adapter transaction state. -The bug was RED in two progressive fixed cases and the random property (seed +The bug was RED in two progressive fixed histories and the random property (seed `-1632249566`, path `0:1`); seven fixed controls passed. Previous tag tests began after readiness, while the initial-sync generators lacked tag move-outs followed by later live work. This was also reproduced by the full service-backed Electric @@ -209,7 +209,7 @@ after user callbacks fence both the waiter loop and the remaining message batch. All three extensions have fixed-seed and random properties, with the shared oracle multiplier and seed/path replay controls. These are ordinary assertions, -not expected-failure classifiers. At `049cc9a5d`, fixed seeds `42711`, `42712`, +not expected-failure classifiers. At `049cc9a5d`, fixed campaign seeds `42711`, `42712`, and `42713` reproduce the three failures respectively. Binding and reentry use epoch/ownership guards; persisted membership uses the approved refetch contract. diff --git a/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md b/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md index 12400618b1..486c0160ed 100644 --- a/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md +++ b/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md @@ -39,7 +39,7 @@ metadata withdrawal, acquisition/fallback failure and healthy peer recovery. ## What the failures taught us The first candidate used the latest response's `hasMore` for all consumers and -treated missing metadata as false. It failed seven of nine tests. Seed `863` +treated missing metadata as false. It failed seven of nine tests. Campaign seed `863` shrunk one failure to two source rows, backend page size one, a one-row window, and missing metadata. It incorrectly announced the end. @@ -104,9 +104,9 @@ replacement for the production controller's lifecycle and error handling. - First red: seven assertion failures, two passes; no setup failure or timeout. - Final fixed-seed campaign: all 66 tests across both experiments passed. - The new no-peek property ran 15,000 histories at multiplier 100, seed `863`; + The new no-peek property ran 15,000 histories at multiplier 100, campaign seed `863`; the preceding cursor experiment ran another 35,000 generated cases. -- A separate default random-seed run covers the no-peek files. +- A separate default random campaign covers the no-peek files. - Package TypeScript and lint on all experiment TypeScript passed. From `packages/query-db-collection`: diff --git a/packages/query-db-collection/tests/cursor-pagination/README.md b/packages/query-db-collection/tests/cursor-pagination/README.md index 25040a9209..bee1ff6e05 100644 --- a/packages/query-db-collection/tests/cursor-pagination/README.md +++ b/packages/query-db-collection/tests/cursor-pagination/README.md @@ -101,7 +101,7 @@ observe production publications, not manufacture them in the fixture. retaining old rows. The Query-owned cache with shared-prefix invalidation passes backend growth, shrink, empty and regrowth plus later page expansion. - The frozen candidate passed 87 tests across five files with multiplier 100, - seed 863: 35,000 cursor histories, 10,000 cache histories and 15,000 no-peek + campaign seed 863: 35,000 cursor histories, 10,000 cache histories and 15,000 no-peek experiment histories. The latter are retained experiments, not shipped metadata behavior. A loss audit and final package gates follow. @@ -123,7 +123,7 @@ failed eight cells before production changed (seed 863, minimized size/depth `[1, 1]`): global/key `maxPages`, `select`, their combination, and cancellation with an existing prefix during growth/refresh. Initial cancellation stayed green. -The fixes pin the internal page format and preserve Query fetch rejection even +The fixes check the internal page format and preserve Query fetch rejection even when Query's fetch API returns reverted cache data. Generated cancellation histories hold actual response delivery at varied page depths, observe a real peer fetch join, assert both waiters reject without replacement transport, fence @@ -142,7 +142,7 @@ See LOSS-AUDIT.md for the distinct generator and observation gaps. Verification for the Query fetch boundary review fixes on base head `3f43deeb6`: -- Full Query DB package: **370 tests in 14 files**, default random-seed lane, +- Full Query DB package: **370 tests in 14 files**, default random campaign, exit 0, 7.82 seconds. - Final stress: **116 tests in seven files**, multiplier 100, seed 863, exit 0, 73.48 seconds. 154,000 generated histories: the previous 135,000 plus @@ -216,8 +216,8 @@ safety of removing peek-ahead or adding a public metadata API. not timeouts. - Final green: 48 tests, including 32 backend/UI page-size integration cells and a held-response shared-consumer case. Four generated properties ran - 35,000 cases in total at multiplier 100 with seed `863`. A separate normal - random-seed run also passed. + 35,000 cases in total at multiplier 100 with campaign seed `863`. A separate normal + random campaign also passed. - Package TypeScript check and lint on all changed TypeScript files passed. From `packages/query-db-collection`, using the installed local binaries: diff --git a/packages/trailbase-db-collection/tests/ORACLE.md b/packages/trailbase-db-collection/tests/ORACLE.md index a7d57a4681..5dd13499c2 100644 --- a/packages/trailbase-db-collection/tests/ORACLE.md +++ b/packages/trailbase-db-collection/tests/ORACLE.md @@ -24,7 +24,7 @@ The same interpreter runs a fixed corpus and generated histories. the listener and not for the subscription, it does not depend on the order in which `start` and the load resume; a mutant that adds an await before `start` subscribes is equivalent. -- The early-demand lane checks that law with its own service model: `list` +- The early-demand grammar checks that law with its own service model: `list` answers from the service rows at the moment it is called, and a change reaches only subscriptions that exist when it commits. Its 16 histories cross the subscription outcome (listen, subscribe rejects, reader fails, @@ -40,17 +40,17 @@ The same interpreter runs a fixed corpus and generated histories. change that the listener applies while the page is in flight, so the page skips rows that such an event touched. Every page row is written as a full-row `update`, which creates a missing row and replaces a held one, so - a page never collides with an event that core accepted but still holds. The page-event lane + a page never collides with an event that core accepted but still holds. The page-event grammar enumerates 15 histories: eager startup or an on-demand load; an update, delete, or insert; committed before the list call (on-demand only), while the page is in flight, or after it applies. It compares the Collection's rows with the service after the page settles and after a later change. A - generated lane (`trailbase.page-event`) extends the same law: 258 service + generated grammar (`trailbase.page-event`) extends the same law: 258 service rows read in two pages of 256, cursor or offset paging, one or two overlapping on-demand loads, and up to four inserts, updates, and deletes among page releases. After every step a changed key holds the service's value; after the history drains, every returned or changed key does. Four - named replays pin an update between pages, an update then a delete during a + fixed histories cover an update between pages, an update then a delete during a page, overlapping loads around an update, and offset paging with two updates. After the history drains, the Collection holds exactly the rows that a page returned or an event touched and that the service still has. @@ -62,14 +62,14 @@ The same interpreter runs a fixed corpus and generated histories. applies, because the stream and the list are separate requests. An `Insert` event is a full row, so the listener writes it as an `update`: a late insert for a row that a page already holds no longer tears the stream - down with `DuplicateKeySyncError`. The page-event lane crosses changes + down with `DuplicateKeySyncError`. The page-event grammar crosses changes committed before the list with delivery at once, while the page is in flight, or after it applies, including an insert followed by an update; - the generated lane delivers each change at once or later, in commit order. + the generated grammar delivers each change at once or later, in commit order. Both check that a later change still reaches the listener. A late older event shows its older value until the newer event that follows it arrives. The adapter has no row version to order an event against a page, so the - lanes check a row only once every event for it has arrived. + grammars check a row only once every event for it has arrived. - A page never fails a load for an older event that core accepted but holds while a user mutation persists. TrailBase mutation handlers wait for their own change event, so core holds sync commits for the round trip of every @@ -108,8 +108,8 @@ The same interpreter runs a fixed corpus and generated histories. its late result changes nothing` checks that a subset load whose signal aborts while its list is in flight rejects with `AbortError` and writes no row when the list answers. Two histories make this limit - executable; they do not claim it is the right product behavior. They pin - one stream end followed by one change and one load. A page whose newer + executable; they do not claim it is the right product behavior. They are + fixed histories of one stream end followed by one change and one load. A page whose newer event never arrives because the stream ended keeps the value the fence allowed; that follows from the same no-resubscribe limit and has no cell. - Graceful closure drains buffered events before releasing the reader. @@ -126,14 +126,14 @@ its late result changes nothing` checks that a subset load whose signal fulfilled. Its direct subset promise settles as a canceled no-op after I/O. - Error and detached-rejection observations are append-only through final gate release and cleanup. Epoch tags record when an observation arrives, not the - hidden sync run that caused it. Exact console output checks pin the + hidden sync run that caused it. Exact console output checks cover the adapter's current compatibility policy, not a general service reporting contract. Histories contain one to three Collection sync runs, eager/on-demand modes, delayed startup and list resolve/reject, zero to eight row edits, five stream endings, and -immediate versus settled cleanup. Thirty-two fixed cases pin the boundaries; -ordinary runs add 50 fixed-seed and 50 fresh-seed histories with shrinking. +immediate versus settled cleanup. Thirty-two fixed histories cover the boundaries; +ordinary runs add 50 histories from a fixed-seed campaign and 50 from a random campaign, with shrinking. Additional controls cover rejecting late cancellation in both modes and mutate provider-owned list, event, and buffered rows before parsing. Model rows derive separately from command scalars, so provider mutation cannot change the answer. @@ -142,14 +142,14 @@ This is not a model of filtered subsets, optimistic mutation acknowledgements, or service reconnects. Offset paging takes updates only: an insert or delete between pages moves rows across a position boundary at the service, which the adapter cannot correct. An eager load cannot overlap an -on-demand one, because an eager source has no subset loader. The page lanes +on-demand one, because an eager source has no subset loader. The page-event grammars use one change listener per run; more than two concurrent loads and pages larger than two remain outside the generated grammar. The generated event phase starts after loading; existing loading-time unit matrices and the two held-cancellation startup controls remain valuable. Those controls were both RED when listener failure awaited cancellation, and GREEN -when cancellation was observed separately. The lifecycle driver calls the core subset boundary; -the early-demand lane also drives a real live query. Service-backed E2E coverage remains separate. +when cancellation was observed separately. The lifecycle production driver calls the core subset boundary; +the early-demand grammar also drives a real live query. Service-backed E2E coverage remains separate. ## Run and replay @@ -167,7 +167,7 @@ TANSTACK_DB_ORACLE_SEED=123 TANSTACK_DB_ORACLE_PATH=0:1 TANSTACK_DB_ORACLE_PROPE ``` Use `TANSTACK_DB_ORACLE_PROPERTY=trailbase.page-event` and `-t 'generated page -histories with a random or replayed seed'` for the page lane. +histories with a random or replayed seed'` for the page-event grammar. Replace the example seed/path with the failure's values. The guarded runner requires a witness that the named property executed with this seed and path; @@ -177,7 +177,7 @@ do not copy its replay parser. ## Evidence against false greens -The early-demand lane fails all 16 histories on the adapter from before the +The early-demand grammar fails all 16 histories on the adapter from before the listener wait (#2115's `main`): each history lists rows before the listener runs. Four mutants of the wait fail it at assertions: @@ -188,7 +188,7 @@ runs. Four mutants of the wait fail it at assertions: | The wait ignores the caller's abort | 2 | | Cleanup does not settle the wait | 2 | -The page-event and stream-end lanes fail on the adapter before this change: +The page-event and stream-end grammars fail on the adapter before this change: an update during the fetch makes the page's insert collide with the row the event wrote (`DuplicateKeySyncError`, so the load rejects), a delete during the fetch brings the row back, and a later load that lists a changed row @@ -200,7 +200,7 @@ collides the same way. | Pages ignore change events in flight | 4 | | Pages always insert | 2 | -The held-commit lane fails on the adapter before this change: with a +The held-commit grammar fails on the adapter before this change: with a mutation persisting, a page's `insert` for a row that a held event inserted is a duplicate of the pending synced row, and the load rejects. Deciding insert or update from the visible rows (`collection.has`) still fails that @@ -220,20 +220,20 @@ The delivery dimension fails on the adapter before the listener change: an insert and its update committed before the list call, delivered after the page applies, make the listener's `insert` collide with the page's newer row. The stream tears down, and a later change never arrives. A listener that -writes `insert` fails both the single-page history and the generated lane's -named replay at that assertion. +writes `insert` fails both the single-page history and the generated grammar's +named fixed history at that assertion. -The generated lane and its four replays all fail on the adapter before the +The generated grammar and its four fixed histories all fail on the adapter before the fence and on the fence-less mutant; both properties shrink to one update while -the first page is in flight. Always inserting survives that lane, because a +the first page is in flight. Always inserting survives that grammar, because a page can only meet a different stored value after a missed event, which only -the stream-end lane produces. A wall-clock fence failed the fresh-run cell: an +the stream-end grammar produces. A wall-clock fence failed the fresh-run cell: an earlier run's event in the same millisecond hid the row from the new run. The 10x campaign passed 800 generated page histories. Before the stale-startup fix, both campaigns independently shrank to: cancel a pending subscription, restart and load a healthy stream, reject the old subscribe. -The old startup canceled the replacement reader. The fixed corpus pins this in +The old startup canceled the replacement reader. The fixed corpus covers this in both modes and sends further edits through the replacement stream. Five isolated source-transform mutations were each rejected by the fixed corpus: @@ -248,5 +248,5 @@ Five isolated source-transform mutations were each rejected by the fixed corpus: These targeted mutations test known failure classes, not overall mutation coverage or a proof of completeness. The 10× campaign passed 800 generated -histories (fixed seed 714203, random seed 127535183) plus the 32 corpus cases. +histories (fixed campaign seed 714203, random campaign seed 127535183) plus the 32 corpus cases. No service-backed E2E run is claimed. From 7a0a353fd35e053b1df82f02083d89737435f0c8 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Nov 2026 11:29:44 -0700 Subject: [PATCH 032/168] docs(oracles): rewrap the switchover's lines and retire "pinned" in the glossary Prettier keeps prose line breaks as written (proseWrap "preserve"), so the vocabulary switchover left lines past 80 columns. This reflows, with Prettier's own `proseWrap: "always"` formatter, only the paragraphs that d8c0158e6 touched and that held a line over 80 columns; untouched paragraphs are unchanged. Text is identical after whitespace normalization in every file. The glossary's D2 value identity row said Map/Set insertion-order sensitivity "is pinned current behavior". It now says it "is current behavior that tests assert", matching the retired oracle sense of pin. Co-authored-by: Isaac --- .agents/skills/oracle-authoring/SKILL.md | 20 +- .agents/skills/oracle-review/SKILL.md | 5 +- AGENTS.md | 97 +- docs/contributing/glossary.md | 2 +- .../instruments/adversarial-review.md | 12 +- .../instruments/law-restructuring.md | 31 +- .../instruments/mutant-gap-hunt.md | 18 +- docs/contributing/oracle-coverage.md | 1275 +++++++++-------- docs/contributing/oracle-rubric.md | 154 +- packages/db/src/query/live/ARCHITECTURE.md | 4 +- packages/db/tests/indexed-db/ORACLE.md | 23 +- .../tests/ORACLE_MUTATIONS.md | 19 +- .../cursor-pagination/NO-PEEK-RESULTS.md | 12 +- .../tests/cursor-pagination/README.md | 14 +- .../trailbase-db-collection/tests/ORACLE.md | 263 ++-- 15 files changed, 1017 insertions(+), 932 deletions(-) diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index 16b4518859..8489095fba 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -51,11 +51,11 @@ concepts. Delegated agents must do the reading relevant to their own scope. Follow the subsystem reading requirements in AGENTS.md. Keep unit tests and standalone concrete type assertions out of oracle files -(ORC-016). Fixed regressions are inputs to the same model, production driver, and -comparison as the broader campaign, without their own expected answers. -Compile-time oracles generate legal TypeScript inputs and compare public -types with a reusable independent law; a matrix of fixed assertions does not -satisfy this requirement. Supplemental examples belong in ordinary test files. +(ORC-016). Fixed regressions are inputs to the same model, production driver, +and comparison as the broader campaign, without their own expected answers. +Compile-time oracles generate legal TypeScript inputs and compare public types +with a reusable independent law; a matrix of fixed assertions does not satisfy +this requirement. Supplemental examples belong in ordinary test files. Use the [instrument library](../../../docs/contributing/instruments/index.md) when there is a material uncertainty. Select cards within the authorized task; @@ -90,9 +90,9 @@ when requested by the task. A proposal with unresolved authority can be explored in a scratch model; do not silently make it a product expectation. At closeout, write the evidence block from AGENTS.md (law, reach, RED, GREEN, -mutant, limits). Connect the law to the evidence actually obtained: which histories -and paths ran, which wrong behavior was rejected, and what remains untested. -Use concise prose suited to the change; no particular report template is -required. When changing repository coverage, update the existing oracle owner and -coverage map to reflect the changed scope. A scratch law-design experiment +mutant, limits). Connect the law to the evidence actually obtained: which +histories and paths ran, which wrong behavior was rejected, and what remains +untested. Use concise prose suited to the change; no particular report template +is required. When changing repository coverage, update the existing oracle owner +and coverage map to reflect the changed scope. A scratch law-design experiment retains its proposals and limits without claiming new repository coverage. diff --git a/.agents/skills/oracle-review/SKILL.md b/.agents/skills/oracle-review/SKILL.md index 62e4a18a26..4e22dc063b 100644 --- a/.agents/skills/oracle-review/SKILL.md +++ b/.agents/skills/oracle-review/SKILL.md @@ -41,8 +41,9 @@ from the [instrument library](../../../docs/contributing/instruments/index.md): - **Law restructuring** tests whether merging, splitting, replacing, or removing laws gives a better account while preserving necessary obligations. - **Mutant gap hunt** tests whether a plausible wrong implementation can pass - the oracle owner and traces survivors to a missing distinction, a contract-permitted - difference, a scoped equivalence argument, or a baseline product bug. + the oracle owner and traces survivors to a missing distinction, a + contract-permitted difference, a scoped equivalence argument, or a baseline + product bug. Choose attacks for the actual uncertainty; do not run every card or require a finding at every level. Use a fresh reviewer for an independent challenge when diff --git a/AGENTS.md b/AGENTS.md index 25ad4f3c86..72ef3be221 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,23 +97,22 @@ is not ready: - **Production first:** a production change in an oracle-owned area without a RED result from the extended oracle on the code before the change. - **Model hacking:** a model rule changed to match production output without a - stated authority (a contract document and section, or a maintainer - decision). Production output is evidence. It is not authority. + stated authority (a contract document and section, or a maintainer decision). + Production output is evidence. It is not authority. - **Unreachable claims:** a closure claim for a state that generated histories do not reach, or a reach that nobody measured. - **Quiet exclusions:** a skipped, narrowed, or relaxed comparison without an entry in the [coverage map](docs/contributing/oracle-coverage.md) that names the oracle owner and the maintainer decision. -- **Fixed-history-only coverage:** a named fixed history where the law needs a generator - dimension. +- **Fixed-history-only coverage:** a named fixed history where the law needs a + generator dimension. - **Unit tests inside oracles:** ordinary examples or concrete type assertions - placed in an oracle file. A fixed regression must run through the shared model, - production driver, and comparison without its own expected answer. + placed in an oracle file. A fixed regression must run through the shared + model, production driver, and comparison without its own expected answer. - **"Pre-existing" as a dismissal:** a reachable counterexample that an agent - labels as older than the change, but does not fix, record in the coverage - map with its trace, or bring to the maintainer for a decision. -- **No mutant:** a closure claim without a mutant that fails the - oracle. + labels as older than the change, but does not fix, record in the coverage map + with its trace, or bring to the maintainer for a decision. +- **No mutant:** a closure claim without a mutant that fails the oracle. ### The evidence block @@ -169,8 +168,8 @@ that combines, splits, or does not correspond to production concepts. Some oracle files are also the shortest documentation for their subsystem. Before changing a covered contract or its machinery, use -`docs/contributing/oracle-coverage.md` to find the primary executable oracle owner and -read its stated limits. Do not infer authority from a filename alone. +`docs/contributing/oracle-coverage.md` to find the primary executable oracle +owner and read its stated limits. Do not infer authority from a filename alone. Read these stable entry points before the narrower oracle owner: @@ -186,8 +185,8 @@ Read these stable entry points before the narrower oracle owner: - For TrailBase lifecycle work, read `packages/trailbase-db-collection/tests/ORACLE.md`. - For cross-framework behavior, read the shared contract under - `packages/db/tests/conformance/` and the receiving framework's production driver. One - framework's scheduling cut does not prove another's. + `packages/db/tests/conformance/` and the receiving framework's production + driver. One framework's scheduling cut does not prove another's. The opening prose states the contract. The small model states the expected behavior. The production driver and comparison check whether the implementation @@ -557,9 +556,9 @@ const dependentBuilders = [] // Accurately describes dependents ### Required reading: oracle tests Before designing, changing, or reviewing an oracle or generated-history test, -read [Oracle Testing Rubric](docs/contributing/oracle-rubric.md). -Use the [coverage map](docs/contributing/oracle-coverage.md) to find an existing -oracle owner and its limits before adding another model. The guide explains testing +read [Oracle Testing Rubric](docs/contributing/oracle-rubric.md). Use the +[coverage map](docs/contributing/oracle-coverage.md) to find an existing oracle +owner and its limits before adding another model. The guide explains testing methods; it does not authorize new product behavior or retire existing laws. ### Write Oracle Tests as Literate Programs @@ -635,10 +634,10 @@ separate oracle owner when another boundary needs a different model. Show that the check fails on the original implementation or a mutant at the intended checkpoint, then passes with the fix. -At closeout, state what the evidence covers. Record remaining in-scope -histories or paths in the oracle coverage map with an oracle owner and needed witness. -A reachable in-scope counterexample keeps the class open. Passing random runs -is not a universal proof. A bug-fix pull request is not ready while a known +At closeout, state what the evidence covers. Record remaining in-scope histories +or paths in the oracle coverage map with an oracle owner and needed witness. A +reachable in-scope counterexample keeps the class open. Passing random runs is +not a universal proof. A bug-fix pull request is not ready while a known in-scope part of the declared bug class remains open. Every bug-fix pull request must show how it evaluated and closed the declared @@ -658,18 +657,19 @@ that an oracle. Both copies can encode the same wrong assumption. - Derive expected behavior from public contracts, documented prior behavior, mathematical laws, or a separately specified reference model. - Keep the reference model structurally different from production. Do not import - the production helper or reuse its classifications to compute expected results. -- Preserve existing contract tests unless a product or design decision explicitly - changes the contract. Rewriting a passing expectation to match new production - behavior is a design review, not routine test maintenance. + the production helper or reuse its classifications to compute expected + results. +- Preserve existing contract tests unless a product or design decision + explicitly changes the contract. Rewriting a passing expectation to match new + production behavior is a design review, not routine test maintenance. - When production work suggests an oracle change, compare the old and new semantics with counterexamples before editing the oracle. -- Use mutants to prove the oracle rejects plausible wrong designs, - including the mistake production currently makes. A green oracle without a - demonstrated kill is weak evidence. -- Use process grammar to explore lifecycle paths, and design grammar to challenge - the oracle's reference semantics. More generated traces cannot repair a wrong - reference model. +- Use mutants to prove the oracle rejects plausible wrong designs, including the + mistake production currently makes. A green oracle without a demonstrated kill + is weak evidence. +- Use process grammar to explore lifecycle paths, and design grammar to + challenge the oracle's reference semantics. More generated traces cannot + repair a wrong reference model. ### Require Applicable Oracle Coverage @@ -684,12 +684,12 @@ oracle. An internal refactor does not require new oracle coverage when existing applicable oracles already exercise the unchanged contract. Applicability follows the contract ownership recorded in -`docs/contributing/oracle-coverage.md`, not the limits of the oracle owner's current -histories, production paths, or observations. A missing history, path, or -observation is a coverage gap in the primary oracle owner. Add a separate oracle owner -only when the boundary requires a different independent judgment. Add a -receiving witness when a claim crosses an adapter, framework, provider, or other -integration boundary. +`docs/contributing/oracle-coverage.md`, not the limits of the oracle owner's +current histories, production paths, or observations. A missing history, path, +or observation is a coverage gap in the primary oracle owner. Add a separate +oracle owner only when the boundary requires a different independent judgment. +Add a receiving witness when a claim crosses an adapter, framework, provider, or +other integration boundary. For a bug fix, use a focused regression to isolate and shrink the failure, then keep it as a fixed regression for the broader oracle. Verify that the expanded @@ -699,9 +699,10 @@ oracle coverage. If no existing oracle applies, create one when the behavior admits a practical independent model. For a change that is not a bug fix, a focused test alone is acceptable only when an oracle is not practical for the behavior. The pull request must identify the relevant coverage-map entry and -explain why extending its oracle owner, creating a new independent oracle, or adding a -receiving witness is not practical. Bug fixes cannot use this focused-test-only -exception. They must provide evidence that closes the entire declared bug class. +explain why extending its oracle owner, creating a new independent oracle, or +adding a receiving witness is not practical. Bug fixes cannot use this +focused-test-only exception. They must provide evidence that closes the entire +declared bug class. ### Keep Unit Tests Out of Oracles @@ -730,14 +731,14 @@ Modules that define the model, checker, or its history grammar also qualify. Identify the actual mechanism before renaming a file. A coverage-map entry, contract comment, or collection of fixed assertions is not sufficient evidence. -An oracle file holds only oracle tests. It declares its check, model, and history -grammar with `declareOracle` from `packages/db/tests/oracle-declaration.ts`. A -type-level oracle names its model types in `typeModel`. Each -part is optional. Every test in the file must use a declared part, directly -or through a production driver, or run a generated property that states its law inline. -A test that compares production with a hand-written value is a unit test: -put it in an ordinary test file. The `oracle/no-unit-test` lint rule reports -a test that does neither. +An oracle file holds only oracle tests. It declares its check, model, and +history grammar with `declareOracle` from +`packages/db/tests/oracle-declaration.ts`. A type-level oracle names its model +types in `typeModel`. Each part is optional. Every test in the file must use a +declared part, directly or through a production driver, or run a generated +property that states its law inline. A test that compares production with a +hand-written value is a unit test: put it in an ordinary test file. The +`oracle/no-unit-test` lint rule reports a test that does neither. Ordinary example tests, type assertions, fixtures, registration wrappers, and runner utilities keep their ordinary names. A module that only drives production diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 9c94b7334a..737aae8eaa 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -187,7 +187,7 @@ crossing subsystem boundaries; these comparisons are not interchangeable. | Term | Meaning | | --- | --- | -| D2 value identity | The value domain compared by `hash` and `equalHashValues`, including their documented reference leaves. A matching hash digest alone does not establish identity. The [identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts) defines its bounded value rules. Map/Set insertion-order sensitivity is pinned current behavior, not a universal D2 operator contract. | +| D2 value identity | The value domain compared by `hash` and `equalHashValues`, including their documented reference leaves. A matching hash digest alone does not establish identity. The [identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts) defines its bounded value rules. Map/Set insertion-order sensitivity is current behavior that tests assert, not a universal D2 operator contract. | | change-event equality | The `deepEquals` comparison used for Collection change-event suppression. Its supported domain ignores Map/Set insertion order, RegExp `lastIndex`, and array holes versus `undefined`. The [change-event equality oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts) defines its value-class rules. | | draft equality | The comparison that recognizes a reverted draft value. Its [revert oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) preserves Map/Set insertion order, RegExp `lastIndex`, and array holes, with explicit snapshot rules for class instances. Native mutator methods have separate change-tracking laws. | | persisted-snapshot equality | The value-shape comparison used before skipping a reconciliation reload. It preserves array holes, enumerable array properties, and exact nested prototypes. Equal keyed class, null-prototype, or cross-realm records with different prototypes require reload; Map and Set insertion order remains significant. | diff --git a/docs/contributing/instruments/adversarial-review.md b/docs/contributing/instruments/adversarial-review.md index a53aa13e3d..0afcd1f143 100644 --- a/docs/contributing/instruments/adversarial-review.md +++ b/docs/contributing/instruments/adversarial-review.md @@ -52,12 +52,12 @@ the evidence that could decide between them, and a possible repair condition. Check that the attack is within scope and that its standard actually applies. An outside-domain case may motivate a scope change; label that proposal. -When execution is authorized and practical, run the discriminating experiment. -A wrong-result control must reach the intended comparison to demonstrate rejection. -Use the oracle guide's calibration and mutant-outcome rules. A source-level -argument can remain useful when execution is unavailable; label it accurately. -For a systematic search across plausible wrong implementations, use the -[mutant gap hunt](mutant-gap-hunt.md) to design semantic edits and classify +When execution is authorized and practical, run the discriminating experiment. A +wrong-result control must reach the intended comparison to demonstrate +rejection. Use the oracle guide's calibration and mutant-outcome rules. A +source-level argument can remain useful when execution is unavailable; label it +accurately. For a systematic search across plausible wrong implementations, use +the [mutant gap hunt](mutant-gap-hunt.md) to design semantic edits and classify survivors. **Return:** supported defects, design objections, and untested hypotheses as diff --git a/docs/contributing/instruments/law-restructuring.md b/docs/contributing/instruments/law-restructuring.md index 702df99a06..6b7831c367 100644 --- a/docs/contributing/instruments/law-restructuring.md +++ b/docs/contributing/instruments/law-restructuring.md @@ -9,28 +9,29 @@ recovering the obligations they represent. ## Operation -1. Decompose the laws into traceable obligations, conditions, distinctions, - and promised observations. Check that these parts can reconstruct each - original claim; invented categories can lose the essential constraint. - If public wording and an executable oracle owner disagree, retain both claims and - the unresolved authority. A passing test alone cannot retire public wording. +1. Decompose the laws into traceable obligations, conditions, distinctions, and + promised observations. Check that these parts can reconstruct each original + claim; invented categories can lose the essential constraint. If public + wording and an executable oracle owner disagree, retain both claims and the + unresolved authority. A passing test alone cannot retire public wording. 2. Propose a different organization and state the operation: merge under a common principle, split by authority or checkpoint, replace a concept, or retire an obligation with an explicit reason. Fewer words or model fields alone do not establish improvement. -3. Map every original obligation to where it is preserved, narrowed, strengthened, - rejected, or still unresolved in the candidate. State what insight originally - made the obligation necessary. A claim may be defeated, but needs a reason - and a design decision rather than disappearing from the mapping. +3. Map every original obligation to where it is preserved, narrowed, + strengthened, rejected, or still unresolved in the candidate. State what + insight originally made the obligation necessary. A claim may be defeated, + but needs a reason and a design decision rather than disappearing from the + mapping. 4. Derive old and proposed predictions for a witness exercising each distinction that the reorganization could erase. Compare both allowed and forbidden - outcomes. Check whether the change accidentally forbids a valid implementation - or admits one of the formerly rejected wrong designs. + outcomes. Check whether the change accidentally forbids a valid + implementation or admits one of the formerly rejected wrong designs. 5. When an independent preservation check is useful and authorized, give a fresh agent the original obligation, its sources, and one candidate. Hide sibling candidates and the author's preferred outcome. Ask what insight survives, - what is defeated, and what smallest repair is needed. Without that separation, - describe the result as a trace audit, not independent validation. + what is defeated, and what smallest repair is needed. Without that + separation, describe the result as a trace audit, not independent validation. **Return:** the proposed organization, the old-to-new obligation mapping, witnesses and their evidence status, losses, and unresolved design decisions. @@ -38,8 +39,8 @@ Keeping the existing laws separate is a valid result. **Control:** reconstruct each original obligation from the candidate and check its discriminating witness. A semantic change may be justified, but is not an -equivalent refactor. Merging prose does not require merging executable oracle owners -whose models or production boundaries differ. +equivalent refactor. Merging prose does not require merging executable oracle +owners whose models or production boundaries differ. **Distortion:** neat categories that erase obligations, speculative connections presented as established, or preserving familiar wording instead of useful diff --git a/docs/contributing/instruments/mutant-gap-hunt.md b/docs/contributing/instruments/mutant-gap-hunt.md index 6e606fe1f3..9e05680b5a 100644 --- a/docs/contributing/instruments/mutant-gap-hunt.md +++ b/docs/contributing/instruments/mutant-gap-hunt.md @@ -12,8 +12,8 @@ distinguish a mistake. Keep test bodies and assertions out of the design brief. ## Operation 1. **Design plausible mistakes before reading the tests.** Start with semantic, - type-plausible edits near the changed boundaries: a lost operand, wrong - prior value, premature clear, duplicated event, changed ordering, or skipped + type-plausible edits near the changed boundaries: a lost operand, wrong prior + value, premature clear, duplicated event, changed ordering, or skipped callback. Turn each open review question into a candidate. If code is called dead, try removing it. In a larger hunt, spread candidates across the affected files and boundaries rather than clustering where edits are easy. @@ -39,8 +39,8 @@ distinguish a mistake. Keep test bodies and assertions out of the design brief. source; it is no oracle gap. If that freedom seems undesirable, take the question to law discovery or adversarial review at the suitability level. Grade an equivalence claim at a named revision: equivalent by invariant, - equivalent only on instrumented reached histories with a positive control, - or evidence-limited and unresolved. The last is not an equivalence verdict. + equivalent only on instrumented reached histories with a positive control, or + evidence-limited and unresolved. The last is not an equivalence verdict. Check all relevant entry paths, including reentry and environment-specific branches; one unreachable path does not establish general equivalence. Probe the nearest unusual legal history before accepting an unreachability claim. @@ -53,11 +53,11 @@ distinguish a mistake. Keep test bodies and assertions out of the design brief. clean git status in its checkout. 4. **Repair the oracle chain, when authorized.** Locate the missing law, model rule, history action, production path, recorder, or assertion. Extend the - primary oracle owner where it can express the distinction. Show that the extension - rejects the motivating mutant and a nearby plausible wrong design without - rejecting a legal alternative. For a bug on the baseline, demonstrate the - production RED before its repair. Update the coverage map when the oracle owner's - reach or limits change. For a claimed oracle repair, follow + primary oracle owner where it can express the distinction. Show that the + extension rejects the motivating mutant and a nearby plausible wrong design + without rejecting a legal alternative. For a bug on the baseline, demonstrate + the production RED before its repair. Update the coverage map when the oracle + owner's reach or limits change. For a claimed oracle repair, follow [ORC-012](../oracle-rubric.md#orc-012-review-evidence): at closeout, verdict-critical evidence outside the executable oracle belongs in a versioned review record tied to the exact reviewed head. This card also asks diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 0da5bc4fc3..c6dc9b61e9 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -148,34 +148,40 @@ The Convex package has these independent judgments: Each owner runs matching fixed and random campaigns and accepts explicit seed/path replay through their CONVEX_*_ORACLE_SEED/PATH environment variables. -Each normal campaign has a fresh reach ledger, fails if any declared state has fewer -than one reached history, and prints its counts as `ORACLE_REACH` JSON. Explicit -path replays run directly with one selected fast-check run and bypass campaign -minima; shrink-path reconstruction can evaluate the callback more than once. -Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination has separate CONVEX_PAGINATION_ROWS, CONVEX_PAGINATION_WORK, and CONVEX_PAGINATION_BOUND selectors; -offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay uses -CONVEX_VALUES_ORACLE; ownership uses CONVEX_OWNERSHIP_ORACLE. - -`packages/convex-db-collection/e2e/backend-oracle.test.ts` receives these contracts -at an actual isolated local Convex backend using both Convex 1.47.0 SDK clients. -Its row-bound rejection is a fixed test in -`packages/convex-db-collection/e2e/backend.test.ts` (fixed tests; no oracle owner -(tracked in #2264)). -It compares pagination with a separately evaluated unpaginated server query, -including real server-generated split responses. Its durable driver runs fixed -and random insert/update/delete histories through SIGKILL after disk admission and, optionally, -a second SIGKILL after server acceptance but before recording outbox completion. -It requires no offline provider call, restored Collection rows, and one server -receipt after replay with the original key and the expected final server rows. -Native payload variation covers inserts and updates. A separate non-idempotent application counter must equal one, and the receipt key must match the on-disk durable key. Restored Collections are compared on every application field, including native payloads. Atomic batches and multi-tab leadership -remain outside this backend witness. Replay uses -CONVEX_BACKEND_INSERT_ORACLE_SEED/PATH, CONVEX_BACKEND_UPDATE_ORACLE_SEED/PATH, or CONVEX_BACKEND_DELETE_ORACLE_SEED/PATH, with matching operation test selection. The command owns backend startup and shutdown. -The offline engine's interruption, settlement, storage, and leadership owners -remain primary for those laws; this is their Convex receiving witness. -Generated bundles constructively cover known and uncertain acknowledgement for -each native payload class while retaining six actual process histories per -operation per campaign (36 total). Reach is credited at observed disk admission, -server-acceptance and full replay comparisons, not from requested fixture inputs. +Each normal campaign has a fresh reach ledger, fails if any declared state has +fewer than one reached history, and prints its counts as `ORACLE_REACH` JSON. +Explicit path replays run directly with one selected fast-check run and bypass +campaign minima; shrink-path reconstruction can evaluate the callback more than +once. Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination has separate +CONVEX_PAGINATION_ROWS, CONVEX_PAGINATION_WORK, and CONVEX_PAGINATION_BOUND +selectors; offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay +uses CONVEX_VALUES_ORACLE; ownership uses CONVEX_OWNERSHIP_ORACLE. + +`packages/convex-db-collection/e2e/backend-oracle.test.ts` receives these +contracts at an actual isolated local Convex backend using both Convex 1.47.0 +SDK clients. Its row-bound rejection is a fixed test in +`packages/convex-db-collection/e2e/backend.test.ts` (fixed tests; no oracle +owner (tracked in #2264)). It compares pagination with a separately evaluated +unpaginated server query, including real server-generated split responses. Its +durable driver runs fixed and random insert/update/delete histories through +SIGKILL after disk admission and, optionally, a second SIGKILL after server +acceptance but before recording outbox completion. It requires no offline +provider call, restored Collection rows, and one server receipt after replay +with the original key and the expected final server rows. Native payload +variation covers inserts and updates. A separate non-idempotent application +counter must equal one, and the receipt key must match the on-disk durable key. +Restored Collections are compared on every application field, including native +payloads. Atomic batches and multi-tab leadership remain outside this backend +witness. Replay uses CONVEX_BACKEND_INSERT_ORACLE_SEED/PATH, +CONVEX_BACKEND_UPDATE_ORACLE_SEED/PATH, or +CONVEX_BACKEND_DELETE_ORACLE_SEED/PATH, with matching operation test selection. +The command owns backend startup and shutdown. The offline engine's +interruption, settlement, storage, and leadership owners remain primary for +those laws; this is their Convex receiving witness. Generated bundles +constructively cover known and uncertain acknowledgement for each native payload +class while retaining six actual process histories per operation per campaign +(36 total). Reach is credited at observed disk admission, server-acceptance and +full replay comparisons, not from requested fixture inputs. `packages/convex-db-collection/e2e/public-pagination-oracle.test.ts` receives the finite pagination contract through the actual public `get-convex/convex-demos` @@ -312,15 +318,16 @@ the earlier injected-edge control is not claimed as a rejected waiting strategy. `lazy-paging-native-receipt-oracle.property.test.ts` feeds actual native batches and core receipts into that same scientific model. Independently authored -required versions absent at acquisition have unique supplying batches, which -can establish two callers. It covers cached zero new edges, a shallow cached -caller behind a deeper held peer, canceled/successful shared callers, and explicit +required versions absent at acquisition have unique supplying batches, which can +establish two callers. It covers cached zero new edges, a shallow cached caller +behind a deeper held peer, canceled/successful shared callers, and explicit cache revalidation to a new version. Early or delayed cached settlement is permitted; responsive post-drain completion is required. Its mutant native -receiver returns the already applied startup receipt instead of the actual -held receipt. Native attribution here excludes idempotent/superseding facts, +receiver returns the already applied startup receipt instead of the actual held +receipt. Native attribution here excludes idempotent/superseding facts, reentrant commit observations and cleanup abandonment; the older controlled -receiver retains idempotent receipt coverage and its all-responses-first profile. +receiver retains idempotent receipt coverage and its all-responses-first +profile. `lazy-paging-version-model-oracle.ts` owns coherent authored revisions and their monotone public frontier. `lazy-paging-version-oracle.property.test.ts` checks @@ -409,18 +416,18 @@ does not discharge an unobserved receipt. The shared `LazyTerminalGraphModel` supplies the old graph judgment to both controlled and backend receivers. Both new families run matching fixed/random campaigns, direct seed/path replay, -and both SDKs in every history. Selectors are `CONVEX_REVALIDATION_ORACLE_SEED/PATH` -and `CONVEX_BOUNDARY_ORACLE_SEED/PATH`; `CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER` -expands both campaign budgets equally. Standard package oracle scripts discover -them; the existing browser CI lane runs the backend receiver via `test:e2e`. -Calibration distinguishes unchanged-content application shortcuts, lost native -deletions, and a separate stale-publication perturbation that ends with correct -rows. Exact commands, source hashes and outcome classifications are in the -record. This does not close arbitrary scheduling, rank ties in the new backend -family, provider errors before acceptance, adversarial keys stale real-SDK callbacks, -same-run authoritative truncate, or React rendering boundaries. Those retain -their existing owners; the controlled failure family remains broader than its -new backend receiving subset. +and both SDKs in every history. Selectors are +`CONVEX_REVALIDATION_ORACLE_SEED/PATH` and `CONVEX_BOUNDARY_ORACLE_SEED/PATH`; +`CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER` expands both campaign budgets equally. +Standard package oracle scripts discover them; the existing browser CI lane runs +the backend receiver via `test:e2e`. Calibration distinguishes unchanged-content +application shortcuts, lost native deletions, and a separate stale-publication +perturbation that ends with correct rows. Exact commands, source hashes and +outcome classifications are in the record. This does not close arbitrary +scheduling, rank ties in the new backend family, provider errors before +acceptance, adversarial keys stale real-SDK callbacks, same-run authoritative +truncate, or React rendering boundaries. Those retain their existing owners; the +controlled failure family remains broader than its new backend receiving subset. The [application/cancellation follow-up](oracle-reviews/2026-10-27-convex-lazy-app-abort.md) adds three receiving families motivated by issue #2269. `LazyWriteModel` in @@ -461,11 +468,12 @@ withheld delivery; no-demand delivery accepts no rows. Retired success/error callbacks after each successful replay cannot resurrect rows, poison current sync or read a retired React watch. Native Convex does not initiate these truncates; the driver supplies that core input. This is no claim about actual -SDKs emitting adversarial keys stale callbacks or about bounded work during replay. -The existing receipt model still owns accepted writes. This checkout's older -core waits for uncancelable baselines; #2269's immediate-core-abort decision -belongs to integration with unmerged #2168. The new family exercises their -shared cancellable-adapter obligation without silently changing either law. +SDKs emitting adversarial keys stale callbacks or about bounded work during +replay. The existing receipt model still owns accepted writes. This checkout's +older core waits for uncancelable baselines; #2269's immediate-core-abort +decision belongs to integration with unmerged #2168. The new family exercises +their shared cancellable-adapter obligation without silently changing either +law. All three families run fixed/random campaigns with direct seed/path replay and declared reach. The follow-up records three rejected production decisions @@ -616,28 +624,28 @@ records the rejection policy and exact calibration. The eight-item follow-up at reviewed head `16824f677` extends the same owners. The cleanup/restart owner adds six plain/live-query logical-demand cases at idle, ready, and cleaned-up cleanup start, plus six admission outcomes that -distinguish a pending cleanup promise from an actual replacement sync run. -The deferred-acquisition owner adds render-deferred eager/on-demand sources and -explicit deferred subscription/resumption histories across both query shapes -and one/two live-query levels. The React descriptor driver receives resumption -after a real abandoned suspended render through the existing startup judgment. -The observer-history owner adds eight failed-setup histories, with both modes, +distinguish a pending cleanup promise from an actual replacement sync run. The +deferred-acquisition owner adds render-deferred eager/on-demand sources and +explicit deferred subscription/resumption histories across both query shapes and +one/two live-query levels. The React descriptor driver receives resumption after +a real abandoned suspended render through the existing startup judgment. The +observer-history owner adds eight failed-setup histories, with both modes, one/two failures, cleanup rejection or real sync-start failure. Client, status, and layout leases are checked immediately, then retry, publication and release. Two late-listener seed failures retain a healthy peer and exclude the failed listener from later publications. Eight stream-preload histories cross pending or successful streams, both source modes and a render deferral. The real React streaming test receives the on-demand premise while retaining server rows until -browser acquisition completes. -The pooled release owner adds twelve new/retained/released partition histories -through held loading/ready cleanup and two startup-cleanup reentry histories. -The subscription boundary owner adds 28 resumption/retirement histories crossing -loading, ready and acquiring notifications, cleanup, source mode and render gate. -The shared token-count model forbids restoring a retired owner. Deferred-demand -cancellation fixtures now create logical demand before explicit resumption; -render-owned queued loads remain covered by the DbClient fixture. These finite -extensions do not establish arbitrary async/provider schedules or new framework -error policy. The [follow-up audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) +browser acquisition completes. The pooled release owner adds twelve +new/retained/released partition histories through held loading/ready cleanup and +two startup-cleanup reentry histories. The subscription boundary owner adds 28 +resumption/retirement histories crossing loading, ready and acquiring +notifications, cleanup, source mode and render gate. The shared token-count +model forbids restoring a retired owner. Deferred-demand cancellation fixtures +now create logical demand before explicit resumption; render-owned queued loads +remain covered by the DbClient fixture. These finite extensions do not establish +arbitrary async/provider schedules or new framework error policy. The +[follow-up audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) records original RED, mutant controls, and the unchanged rejection decision. A subsequent current-head summary exposed cleanup during ownership transfer. @@ -795,16 +803,17 @@ active-interval model: retained routes share one ready facade, retired handles stay ready/empty, and a new interval gets a new handle. This assumes no facade-local optimistic mutations. Every shape declares active empty readiness and nonempty child receiving cuts. Six lifetime and four inline-value mutant -controls reject wrong observations. See [chunk 5](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). -Functional callback-input observations have a separate receiving boundary in +controls reject wrong observations. See +[chunk 5](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). Functional +callback-input observations have a separate receiving boundary in `virtual-row-fields-input-oracle.test.ts`, sharing the existing source metadata judgment. The maintainer extended O12 on 2026-10-30 from fn.select to fn.where and fn.having while preserving authored inline output. Thirty-three authoring -descriptions cross seven QueryRef/join/union paths, five callback forms -(select, where, having, where-select, having-select), and root, wrapped child, -direct child and real Effect receivers. Flat union children require a named -QueryRef for include correlation; direct flat union children remain inadmissible. -The finite grammar has 4,290 receiving histories and 20 direct-source controls. +descriptions cross seven QueryRef/join/union paths, five callback forms (select, +where, having, where-select, having-select), and root, wrapped child, direct +child and real Effect receivers. Flat union children require a named QueryRef +for include correlation; direct flat union children remain inadmissible. The +finite grammar has 4,290 receiving histories and 20 direct-source controls. Each receiver checks immutable callback captures and complete derived or predicate-filtered output at initial readiness, held mutation, settlement and @@ -926,26 +935,27 @@ The versioned audit records the user decision, original overwrite RED, GREEN, and guards that check only own/truthy properties or omit a reserved name. Other existing fixed type checks cover adjacent compile-time boundaries: -`packages/db/tests/mutation-handler-type.test-d.ts` (fixed tests; no oracle owner (tracked in #2264)) checks sibling -Collection/key/utilities in mutation handlers; and +`packages/db/tests/mutation-handler-type.test-d.ts` (fixed tests; no oracle +owner (tracked in #2264)) checks sibling Collection/key/utilities in mutation +handlers; and `packages/db/tests/query/virtual-row-fields-reference-types.test-d.ts` retains concrete helper examples. The generated public type owner is `packages/db/tests/query/virtual-row-fields-types-oracle.test.ts`. Its 24 finite row descriptions vary key domain, scalar type, nested optionality and discriminated union membership. Shared runtime/type authoring dimensions cross -five projection shapes and eight compositions; seven child shapes cross -the legal facade/toArray/materialize/findOne forms; array/Date facades are -rejected inputs. Each generated body also runs public query/index construction, -with an explicit index type and failure-preserving cleanup. Two admission -controls reject opaque facades and optionless indexes at their actual boundary. -A reusable recursive type signature checks -values, exact keys, required/optional and readonly modifiers per union member, -without rejecting duplicate equivalent union members. Public-source imports -compile through the actual TypeScript compiler; all diagnostics retain their -file, span and code. Seven mutant controls challenge value enrichment, -nullability, one-branch readonly loss, nested any/metadata/modifiers and Date -metadata any. Their rejection requires failed type-law constraints, not syntax -or setup errors. The normal test:oracles command selects this owner. +five projection shapes and eight compositions; seven child shapes cross the +legal facade/toArray/materialize/findOne forms; array/Date facades are rejected +inputs. Each generated body also runs public query/index construction, with an +explicit index type and failure-preserving cleanup. Two admission controls +reject opaque facades and optionless indexes at their actual boundary. A +reusable recursive type signature checks values, exact keys, required/optional +and readonly modifiers per union member, without rejecting duplicate equivalent +union members. Public-source imports compile through the actual TypeScript +compiler; all diagnostics retain their file, span and code. Seven mutant +controls challenge value enrichment, nullability, one-branch readonly loss, +nested any/metadata/modifiers and Date metadata any. Their rejection requires +failed type-law constraints, not syntax or setup errors. The normal test:oracles +command selects this owner. Chunk 24 extends this same owner with `virtual-row-fields-receiver-types-oracle.ts` and the native compiler support `virtual-row-fields-receiver-types-support.ts`. @@ -1053,42 +1063,45 @@ delete, and move parents, insert and delete children, and move the window, with executed fixed cases for each premise, and every checkpoint asserts `getWindow()`. A `findOne` keeps its compiled limit (1, or 0 for `.limit(0).findOne()`); an ordered `findOne` has a movable window, and an -unordered one is a fixed one-row result that accepts only its compiled limit -at offset 0. Rows must equal -a relational model. Preload may load only the children of the shown rows' -groups, and a step only those of rows that entered the window and were not -shown before it. A static filter creates static child demand before the -query runs only when no enclosing query is windowed or a `findOne`, and either the query is neither windowed nor a `findOne`, or it is a `findOne` that shows one row for one named correlation value, with no `offset`, and offset 0 at start if a window was requested before start. Fixed histories show each case -the rule closes: a window requested before start (12 child rows for a bound of -2 before the rule), grandchild demand below a windowed parent, a `findOne` with +unordered one is a fixed one-row result that accepts only its compiled limit at +offset 0. Rows must equal a relational model. Preload may load only the children +of the shown rows' groups, and a step only those of rows that entered the window +and were not shown before it. A static filter creates static child demand before +the query runs only when no enclosing query is windowed or a `findOne`, and +either the query is neither windowed nor a `findOne`, or it is a `findOne` that +shows one row for one named correlation value, with no `offset`, and offset 0 at +start if a window was requested before start. Fixed histories show each case the +rule closes: a window requested before start (12 child rows for a bound of 2 +before the rule), grandchild demand below a windowed parent, a `findOne` with `limit(0)`, and a `findOne` moved past its one named value before start; a detail page whose window before start is at offset 0 still starts its child acquisition attempt at once. A focused test requires `setWindow` to throw `SetWindowFindOneLimitError` for any other `findOne` limit and -`SetWindowRequiresOrderByError` for an offset move on an unordered `findOne`. A `findOne` detail page with on-demand parent and child starts -its child acquisition attempt beside its parent's. On main, a late page -delivered 120 child rows for a bound of 9. Routers that add the row after the -window fail the generated campaigns; routers that add every row in an edge tie -group fail a fixed collapse and the detail page; a child that takes over -`setWindow` fails the rows law. Focused tests require a window inside a `from` -or joined subquery not to answer `setWindow`. +`SetWindowRequiresOrderByError` for an offset move on an unordered `findOne`. A +`findOne` detail page with on-demand parent and child starts its child +acquisition attempt beside its parent's. On main, a late page delivered 120 +child rows for a bound of 9. Routers that add the row after the window fail the +generated campaigns; routers that add every row in an edge tie group fail a +fixed collapse and the detail page; a child that takes over `setWindow` fails +the rows law. Focused tests require a window inside a `from` or joined subquery +not to answer `setWindow`. Recorded limit (#2151): while an index-backed parent top-K refills within one graph run (a held row moved out, a tie group loaded at the window edge, a -forward window move), it can briefly hold a row outside the window it shows -once its ordered acquisition completes, and load that row's children; the -public snapshot stays correct. A refill checkpoint is a model observation that -combines an ordered acquisition and whether it completed: the parent Collection -delivers more change batches than the initial load or the step's own parent -write. The generated campaigns skip only its work bound. In the fixed campaign -(60 runs, seed 2137), the work bound checks 285 of 290 checkpoints strictly: -preload 60 of 60, parent inserts 45 of 45, parent deletes 34 of 35, rank moves -43 of 43, child inserts 36 of 36, child deletes 37 of 37, backward window moves -18 of 18, and forward window moves 12 of 16. Six fixed histories cover the known traces -and allow exactly the one extra child row each shows; with no overshoot -allowed, each fails. Four fixed forward moves (local and on-demand children, -with and without `distinct`) allow none. Each generated campaign counts the -forward moves its own runs check strictly and fails if it counts none. +forward window move), it can briefly hold a row outside the window it shows once +its ordered acquisition completes, and load that row's children; the public +snapshot stays correct. A refill checkpoint is a model observation that combines +an ordered acquisition and whether it completed: the parent Collection delivers +more change batches than the initial load or the step's own parent write. The +generated campaigns skip only its work bound. In the fixed campaign (60 runs, +seed 2137), the work bound checks 285 of 290 checkpoints strictly: preload 60 of +60, parent inserts 45 of 45, parent deletes 34 of 35, rank moves 43 of 43, child +inserts 36 of 36, child deletes 37 of 37, backward window moves 18 of 18, and +forward window moves 12 of 16. Six fixed histories cover the known traces and +allow exactly the one extra child row each shows; with no overshoot allowed, +each fails. Four fixed forward moves (local and on-demand children, with and +without `distinct`) allow none. Each generated campaign counts the forward moves +its own runs check strictly and fails if it counts none. Open in-scope paths, all owned by this includes-work owner unless noted: refill checkpoints need the #2151 fix (holding include demand until the @@ -1270,51 +1283,51 @@ accepting, rejecting, and absent outer predicates; a materialized-Collection formulation supplies matching and nonmatching controls. Other `singleResult` forms, join forms, and scheduling cuts remain outside these owners. -The offline scheduler owner (`packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts`) +The offline scheduler owner +(`packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts`) checks FIFO eligibility and retry order at the scheduler boundary, as the `oracleProperty` `offline.key-scheduler-fifo-ledger`. Before its port the generator drew histories of at most 12 commands (fast-check's default array size) with a biased selector, so 4 of 150 histories per campaign started any -transaction and 1 retried; generation now draws 1 to 50 unbiased commands, -and about a quarter of histories start work and a tenth retry. It does not -observe the executor's awaited outbox update between `updateTransaction` and +transaction and 1 retried; generation now draws 1 to 50 unbiased commands, and +about a quarter of histories start work and a tenth retry. It does not observe +the executor's awaited outbox update between `updateTransaction` and `markFailed`, or public caller settlement. Those receiving cuts belong with `packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts`. The transaction serializer owner uses controlled Temporal constructors and fake storage; a claim about native Temporal or browser storage needs a receiving -witness in those runtimes. -The offline settlement owner follows the README's success boundary: the -configured `mutationFn` has returned and the storage adapter has acknowledged -outbox deletion. Its controlled storage histories hold and fail deletion, -stop the executor and queued peers on a storage failure, reject new admission, -and restart after a persisted `deletion-pending` phase. A marked row skips provider replay -even when `beforeRetry` filters all ordinary pending work; an old unmarked row -still replays. Adapter tests prove that IndexedDB transaction failure and -localStorage `removeItem` failure reject rather than masquerade as deletion. -When a caller removes an active row with `removeFromOutbox` or `clearOutbox` -while its provider is held, the caller remains pending until that provider -returns. The controlled witness then checks provider calls, public settlement, -and the empty outbox. The leadership owner checks that an earlier -`deletion-pending` row with the same persisted timestamp is deleted before its -later peer reaches the provider. This restart witness uses storage insertion -order to distinguish creation order; independent storage writers and native -browser hosts remain outside its claim. -These witnesses do not establish physical power-loss durability, a real -provider's idempotency, or the crash window between provider completion and -the phase write. The provider must honor the supplied idempotency key in that -window. Older application versions that do not understand the phase can also -replay a marked row. Native browser restart and multi-owner handoff of the -marked phase remain receiving witnesses for this owner. - -The same owner also needs a captured failing seed-and-shrink-path replay for -its generated settlement property. The +witness in those runtimes. The offline settlement owner follows the README's +success boundary: the configured `mutationFn` has returned and the storage +adapter has acknowledged outbox deletion. Its controlled storage histories hold +and fail deletion, stop the executor and queued peers on a storage failure, +reject new admission, and restart after a persisted `deletion-pending` phase. A +marked row skips provider replay even when `beforeRetry` filters all ordinary +pending work; an old unmarked row still replays. Adapter tests prove that +IndexedDB transaction failure and localStorage `removeItem` failure reject +rather than masquerade as deletion. When a caller removes an active row with +`removeFromOutbox` or `clearOutbox` while its provider is held, the caller +remains pending until that provider returns. The controlled witness then checks +provider calls, public settlement, and the empty outbox. The leadership owner +checks that an earlier `deletion-pending` row with the same persisted timestamp +is deleted before its later peer reaches the provider. This restart witness uses +storage insertion order to distinguish creation order; independent storage +writers and native browser hosts remain outside its claim. These witnesses do +not establish physical power-loss durability, a real provider's idempotency, or +the crash window between provider completion and the phase write. The provider +must honor the supplied idempotency key in that window. Older application +versions that do not understand the phase can also replay a marked row. Native +browser restart and multi-owner handoff of the marked phase remain receiving +witnesses for this owner. + +The same owner also needs a captured failing seed-and-shrink-path replay for its +generated settlement property. The [deletion-settlement review record](oracle-reviews/2026-09-30-offline-deletion-settlement.md) preserves its earlier executable revision. The [fail-stop follow-up](oracle-reviews/2026-09-30-offline-fail-stop-followup.md) -records the revised storage-failure law, RED/GREEN witnesses, and limits. -The guarded replay owner (`packages/db/tests/oracle-replay.test.ts`) now checks -that named pagination and index campaigns execute only the requested replay -campaign. An unrelated failing fixed campaign no longer runs first. The +records the revised storage-failure law, RED/GREEN witnesses, and limits. The +guarded replay owner (`packages/db/tests/oracle-replay.test.ts`) now checks that +named pagination and index campaigns execute only the requested replay campaign. +An unrelated failing fixed campaign no longer runs first. The [prep-PR review record](oracle-reviews/2026-09-30-prep-pr-followup.md) records these four review repairs at their exact executable revision. @@ -1454,7 +1467,8 @@ is not browser evidence. `hydration.svelte.test.ts` separately receives normalized transport and normal warm-browser initialization through its native scheduler. -The [React SSR diagnostic receiver](../../packages/react-db/tests/ssr-effects-oracle.test.ts) +The +[React SSR diagnostic receiver](../../packages/react-db/tests/ssr-effects-oracle.test.ts) extends that preparation grammar and judgment with real matched React/ReactDOM 18.3.1 and 19.2.4 development server renderers. Separate paths cover provider and explicit-client ordinary hooks, completed non-streaming Suspense through @@ -1466,8 +1480,9 @@ prepared Suspense compares rows. Standalone/supplied paths make no hydration claim. Fixed/fresh campaigns retain per-runtime/path reach and direct replay; this does not establish streaming Suspense, actual browser hydration, arbitrary provider delivery or every React version. Client commit and abandonment remain -with the window receiving owner. [SSR effect evidence](oracle-reviews/2026-10-30-react-ssr-effects.md) -records pre-edit RED, repaired GREEN and mutant rejection. +with the window receiving owner. +[SSR effect evidence](oracle-reviews/2026-10-30-react-ssr-effects.md) records +pre-edit RED, repaired GREEN and mutant rejection. The shared admission owner also defines runtime ready/error outcomes, browser explicit preparation and defined/undefined causes. Its receiving drivers are @@ -1749,9 +1764,9 @@ React's 12; six mutant in-place mutations are rejected. Svelte's actual client-backed effect receives all 144 cached-option histories. All three native selectors pass. Portable first claims/failure/consumption are no longer merely proposed coverage. Remaining cache limits at that chunk were first registration -without an existing stream, arbitrary transport schedules, schema transforms -and native partial-construction cleanup. Svelte SSR/hydrateRoot/continuing publications are -not supplied by its browser admission receiver. +without an existing stream, arbitrary transport schedules, schema transforms and +native partial-construction cleanup. Svelte SSR/hydrateRoot/continuing +publications are not supplied by its browser admission receiver. Chunk 12 adds first registration without a transported query result. Core runs 4,320 histories: explicit preload, streaming observer preload and server read @@ -1775,65 +1790,63 @@ concurrent schedules and other framework first-registration receivers remain outside this bounded evidence. Native partial-construction evidence is recorded separately in chunk 11. -`packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` also checks -a two-source descriptor union when the first sync start fails: the second source +`packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` also checks a +two-source descriptor union when the first sync start fails: the second source still starts and reaches ready on direct preload, and a preparation error stays primary if a deferred source also throws during release. The controlled sources start synchronously. At the synchronous preload error cut, even a caller that opts to stream pending queries cannot dehydrate the failed query; a later same-hash preload over a healthy replacement publishes its own row. Asynchronous failure is also exercised with a later controlled `markError`: the pending -stream rejects and disappears from dehydration, the peer source is ready, and -a healthy same-hash replacement publishes its row. A mutant that dehydrates +stream rejects and disappears from dehydration, the peer source is ready, and a +healthy same-hash replacement publishes its row. A mutant that dehydrates errored records fails at that cut. Other asynchronous schedules and larger -source sets remain open. Retrying the same options after the -source entered terminal error reports that source error instead of reusing the -failed query's pending stream; it does not restart the source. -A replacement observer subscribed after the controlled asynchronous failure -can attach under the same hash and reads the healthy replacement row as ready, -without a stale stream error, before a new client preload. The prior -source-mismatch check fails that attachment assertion. The same history rejects -a different-source preload while its original stream is still pending. A second -fixed history crosses a synchronous source throw and then attaches a healthy -replacement observer before a new client preload; its settled snapshot contains -only the replacement row. Removing the failed-record source-claim guard fails -that observer attachment, while ignoring every existing stream's claim fails -the pending-stream rejection. The preload-identity owner also checks active -streams. These histories do not enumerate concurrent replacement observers, -other failure schedules, or later conflicting preloads while an observer has no -reusable client stream. -An observer preload over a config-free pooled view is also explicit demand: it +source sets remain open. Retrying the same options after the source entered +terminal error reports that source error instead of reusing the failed query's +pending stream; it does not restart the source. A replacement observer +subscribed after the controlled asynchronous failure can attach under the same +hash and reads the healthy replacement row as ready, without a stale stream +error, before a new client preload. The prior source-mismatch check fails that +attachment assertion. The same history rejects a different-source preload while +its original stream is still pending. A second fixed history crosses a +synchronous source throw and then attaches a healthy replacement observer before +a new client preload; its settled snapshot contains only the replacement row. +Removing the failed-record source-claim guard fails that observer attachment, +while ignoring every existing stream's claim fails the pending-stream rejection. +The preload-identity owner also checks active streams. These histories do not +enumerate concurrent replacement observers, other failure schedules, or later +conflicting preloads while an observer has no reusable client stream. An +observer preload over a config-free pooled view is also explicit demand: it starts an idle eager source once, does not restart a running source, and reaches a ready public snapshot in both cases, with or without an SSR cleanup client. This bounded witness does not claim the same path for on-demand or asynchronous -sources. -`packages/svelte-db/tests/descriptor-query-release.svelte.test.ts` (fixed tests; no oracle owner (tracked in #2264)) -checks Svelte's two-source release after a first startup error and a direct -reader after a second descriptor factory fails. It observes source start counts, -ready status, and failed-hook subscription release. A fresh effect root after -the failed root is retired consumes an independent healthy query and its row. -A controlled Svelte runtime probe showed that an uncaught effect error does not -rerun the same effect when its reactive query changes; in-place recovery after -that error is not promised. This owner does not establish two-client Svelte -provider binding or ordinary reactive query replacement. Concrete- -config descriptors, on-demand acquisition, and those Svelte paths remain open -for this binding law; a -receiving oracle needs the relevant public observation for each. Vue, Solid, -and Angular have no DbClient descriptor-binding contract in this work. Their -query callers use concrete Collections. -`packages/db/tests/live-query-options.test.ts` checks that a client-aware -consumer binds nested, union-branch, and include-child descriptors during final -preparation. Construction retains descriptor refs in those positions, including -direct, nested, materialized, and conditional includes. A prepared builder is a -bound snapshot; a later descriptor join remains unbound until another -client-aware consumption. The focused core tests check source materialization -and sync starts before and after release of a render deferral set, plus the -missing-client error for direct consumption. The React descriptor oracle checks -public rows after a committed hook consumes such a continuation and zero source -starts when a render containing one never commits. These bounded checks do not -compare union or include rows under separate clients, or concurrent render -schedules. The original standalone Query remains the reusable two-client plan; -rebinding a previously prepared snapshot to a different client is not claimed. +sources. `packages/svelte-db/tests/descriptor-query-release.svelte.test.ts` +(fixed tests; no oracle owner (tracked in #2264)) checks Svelte's two-source +release after a first startup error and a direct reader after a second +descriptor factory fails. It observes source start counts, ready status, and +failed-hook subscription release. A fresh effect root after the failed root is +retired consumes an independent healthy query and its row. A controlled Svelte +runtime probe showed that an uncaught effect error does not rerun the same +effect when its reactive query changes; in-place recovery after that error is +not promised. This owner does not establish two-client Svelte provider binding +or ordinary reactive query replacement. Concrete- config descriptors, on-demand +acquisition, and those Svelte paths remain open for this binding law; a +receiving oracle needs the relevant public observation for each. Vue, Solid, and +Angular have no DbClient descriptor-binding contract in this work. Their query +callers use concrete Collections. `packages/db/tests/live-query-options.test.ts` +checks that a client-aware consumer binds nested, union-branch, and +include-child descriptors during final preparation. Construction retains +descriptor refs in those positions, including direct, nested, materialized, and +conditional includes. A prepared builder is a bound snapshot; a later descriptor +join remains unbound until another client-aware consumption. The focused core +tests check source materialization and sync starts before and after release of a +render deferral set, plus the missing-client error for direct consumption. The +React descriptor oracle checks public rows after a committed hook consumes such +a continuation and zero source starts when a render containing one never +commits. These bounded checks do not compare union or include rows under +separate clients, or concurrent render schedules. The original standalone Query +remains the reusable two-client plan; rebinding a previously prepared snapshot +to a different client is not claimed. `packages/db/tests/query/collection-ref-compatibility.test.ts` fixes the exported `IR.CollectionRef` shape for concrete Collections: `collection` is an enumerable, writable own field that a spread copies. It also checks that an @@ -1905,50 +1918,52 @@ acquisitions and warning text, not later window moves or real-provider cost. ## Unit-test retirement transfers in PR #2116 -The [direct index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts) -owns the seven behaviors moved from index unit tests: an absent lower bound -with an exclusive flag; exact membership through comparator-equal updates; -`null` and `undefined` exact and `IN` membership, including repeated owners; -undefined endpoint and cursor traversal; and explicit, omitted, and -double-undefined range bounds. A source Map predicts exact key sets and -ordered reads after bounded direct actions on BasicIndex and BTreeIndex. A -full-build witness catches native `Array.sort` moving raw `undefined` last. -The [round 1](oracle-reviews/index-unit-retirement-round-1.md), +The +[direct index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts) +owns the seven behaviors moved from index unit tests: an absent lower bound with +an exclusive flag; exact membership through comparator-equal updates; `null` and +`undefined` exact and `IN` membership, including repeated owners; undefined +endpoint and cursor traversal; and explicit, omitted, and double-undefined range +bounds. A source Map predicts exact key sets and ordered reads after bounded +direct actions on BasicIndex and BTreeIndex. A full-build witness catches native +`Array.sort` moving raw `undefined` last. The +[round 1](oracle-reviews/index-unit-retirement-round-1.md), [round 2](oracle-reviews/index-unit-retirement-round-2.md), [round 3](oracle-reviews/index-unit-retirement-round-3.md), and [round 4](oracle-reviews/index-unit-retirement-round-4.md) ledgers retain each -retired assertion. The [build review](oracle-reviews/basic-index-nullish-build.md) -records the direct RED/GREEN repair. A separate mixed number/string grammar -checks that full direct traversals and one indexed, bounded public Collection -snapshot return each live source key once after deletion and after a direct -delete/update/re-add history. Two more direct histories keep an ordinary string -beside ordered ZonedDateTime or Duration keys and verify key conservation after -one Temporal deletion. An en-US locale and a custom string-collation cell compare -the first bounded public row through a scan and each index when an ordinary -string begins the internal encoding prefix. An `origin/main` probe failed the -default-locale public prefix case. The pre-classifier PR comparator failed all -four public prefix cases and both BTree Temporal-deletion histories. This owner -also compares complete forward and reverse reads through `IndexInterface`, -without prescribing index buckets or a BTree representative. A fixed churn -witness holds one live row while 16, 64, or 256 earlier distinct values are -added and removed; its public `takeFromStart(1)` result and comparator-call -growth reject retained empty buckets that add comparator work. This is a bounded -read-work check, not a memory bound or a general complexity proof. A deliberate -empty-bucket mutant failed at the intended comparator-count comparison. -The invalid-comparator owner checks rejection exactly when a comparison -returns a non-number or `NaN` and checks rejected removal atomicity; it does -not require a comparison merely because one comparator-equal row is removed. -The binary grammar checks fresh equal-content removal, re-addition, and -replacement beside another byte value. It compares complete forward and -reverse key reads to a source Map. Separate short and 8193-byte pairs check -exact membership and complete forward and reverse key conservation when only -the last byte differs. An intermediate PR BTree rewrite lost a surviving key -after one equal-content owner left; the expanded grammar rejected that result. -A separate two-value Date, Temporal.Instant, and Uint8Array callback grammar -checks legacy scalar input kinds on direct add only. Arbitrary callbacks and -update, build, and range paths are outside that grammar. -This owner does not prescribe mixed-kind order or cover binary range ordering, -all JavaScript value kinds, or general Collection publication histories. +retired assertion. The +[build review](oracle-reviews/basic-index-nullish-build.md) records the direct +RED/GREEN repair. A separate mixed number/string grammar checks that full direct +traversals and one indexed, bounded public Collection snapshot return each live +source key once after deletion and after a direct delete/update/re-add history. +Two more direct histories keep an ordinary string beside ordered ZonedDateTime +or Duration keys and verify key conservation after one Temporal deletion. An +en-US locale and a custom string-collation cell compare the first bounded public +row through a scan and each index when an ordinary string begins the internal +encoding prefix. An `origin/main` probe failed the default-locale public prefix +case. The pre-classifier PR comparator failed all four public prefix cases and +both BTree Temporal-deletion histories. This owner also compares complete +forward and reverse reads through `IndexInterface`, without prescribing index +buckets or a BTree representative. A fixed churn witness holds one live row +while 16, 64, or 256 earlier distinct values are added and removed; its public +`takeFromStart(1)` result and comparator-call growth reject retained empty +buckets that add comparator work. This is a bounded read-work check, not a +memory bound or a general complexity proof. A deliberate empty-bucket mutant +failed at the intended comparator-count comparison. The invalid-comparator owner +checks rejection exactly when a comparison returns a non-number or `NaN` and +checks rejected removal atomicity; it does not require a comparison merely +because one comparator-equal row is removed. The binary grammar checks fresh +equal-content removal, re-addition, and replacement beside another byte value. +It compares complete forward and reverse key reads to a source Map. Separate +short and 8193-byte pairs check exact membership and complete forward and +reverse key conservation when only the last byte differs. An intermediate PR +BTree rewrite lost a surviving key after one equal-content owner left; the +expanded grammar rejected that result. A separate two-value Date, +Temporal.Instant, and Uint8Array callback grammar checks legacy scalar input +kinds on direct add only. Arbitrary callbacks and update, build, and range paths +are outside that grammar. This owner does not prescribe mixed-kind order or +cover binary range ordering, all JavaScript value kinds, or general Collection +publication histories. The [comparison oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts) owns the transferred same-realm NaN and invalid-Date default-order cases, @@ -1987,17 +2002,19 @@ The deep-equality and Collection change-event history owners check a Duration field replacement and its subscriber mirror. These fixed histories do not establish arbitrary multirow Duration updates or live-query publication. -The [Temporal index order tests](https://github.com/TanStack/db/blob/main/packages/db/tests/temporal-index-order.test.ts) -(fixed tests; no oracle owner (tracked in #2264)) covers ZonedDateTime instant ordering and time-only Duration ordering for direct -BasicIndex/BTreeIndex reads and bounded public first-row reads. It separates -exact Duration fields from order ties, including exclusive cursor behavior. -An extended-year PlainDate grammar checks calendar order under lexical string -collation through ready scan and direct indexed snapshots, with eager live-query -preload as parity evidence. The old indexed direct path chose the wrong year. -An exception grammar requires ready scan, BasicIndex, and BTreeIndex direct reads -and eager live-query preloads to reject mixed PlainDate/PlainDateTime values and -distinct PlainMonthDays, as changelog #1519 specifies. The old indexed direct -path returned a row instead. Calendar-unit Duration order, other extended-year +The +[Temporal index order tests](https://github.com/TanStack/db/blob/main/packages/db/tests/temporal-index-order.test.ts) +(fixed tests; no oracle owner (tracked in #2264)) covers ZonedDateTime instant +ordering and time-only Duration ordering for direct BasicIndex/BTreeIndex reads +and bounded public first-row reads. It separates exact Duration fields from +order ties, including exclusive cursor behavior. An extended-year PlainDate +grammar checks calendar order under lexical string collation through ready scan +and direct indexed snapshots, with eager live-query preload as parity evidence. +The old indexed direct path chose the wrong year. An exception grammar requires +ready scan, BasicIndex, and BTreeIndex direct reads and eager live-query +preloads to reject mixed PlainDate/PlainDateTime values and distinct +PlainMonthDays, as changelog #1519 specifies. The old indexed direct path +returned a row instead. Calendar-unit Duration order, other extended-year histories, on-demand exception publication, arbitrary updates, and custom index callbacks remain outside this owner. @@ -2102,10 +2119,11 @@ eligibility as row kinds change, without warranting a duplicate auto-index. The [PR #2116 confirmed-fixes review](oracle-reviews/pr-2116-confirmed-fixes.md) records baseline failures, mutant controls, and the bounded scope of these -repairs. The [deferred findings record](oracle-reviews/pr-2116-deferred-findings.md) -tracks remaining design and coverage questions, including valid Temporal kinds -without public order and mixed-kind query order. Earlier PR-branch -counterexamples do not by themselves establish an `origin/main` failure. +repairs. The +[deferred findings record](oracle-reviews/pr-2116-deferred-findings.md) tracks +remaining design and coverage questions, including valid Temporal kinds without +public order and mixed-kind query order. Earlier PR-branch counterexamples do +not by themselves establish an `origin/main` failure. The Query DB ownership oracle also owns the explicit mutation-handler refresh contract ([evidence](oracle-reviews/pr-2171-explicit-query-refresh.md)). Its 18-history table crosses insert, update, and delete with no @@ -2239,24 +2257,24 @@ behavior. A caller using direct writes must accept the scoped-cache limits or request an explicit refetch. -The ownership oracle also checks five later boundaries from PR #2065 review: -a derived `select` still applies a held fetch's rows and settles eager -readiness after a direct insert; overlapping explicit refetch callers follow -the replacement request after a silent cancellation; a fetched result queued -behind a persisted-baseline scan cannot overwrite a later accepted write; a -zero-lease subset observer retained by a listener receives a direct cache -patch before reacquisition; and eight retired on-demand subsets leave no -fetch-start positions (removing the retirement call fails at eight retained -entries). The Query error, invalidation, and staleness checks now cover both -eager and on-demand modes. These are controlled Query Core and persistence -seams. They do not establish every derived response shape, arbitrary -overlapping refetch schedule, native SQLite timing for the queued-result cut, -or unbounded cache-memory growth. This owner retains those boundaries; the -persisted direct-write oracle owns native durable-order receiving cases. -The retained-Query witness holds an empty-cache fetch across subset unload and +The ownership oracle also checks five later boundaries from PR #2065 review: a +derived `select` still applies a held fetch's rows and settles eager readiness +after a direct insert; overlapping explicit refetch callers follow the +replacement request after a silent cancellation; a fetched result queued behind +a persisted-baseline scan cannot overwrite a later accepted write; a zero-lease +subset observer retained by a listener receives a direct cache patch before +reacquisition; and eight retired on-demand subsets leave no fetch-start +positions (removing the retirement call fails at eight retained entries). The +Query error, invalidation, and staleness checks now cover both eager and +on-demand modes. These are controlled Query Core and persistence seams. They do +not establish every derived response shape, arbitrary overlapping refetch +schedule, native SQLite timing for the queued-result cut, or unbounded +cache-memory growth. This owner retains those boundaries; the persisted +direct-write oracle owns native durable-order receiving cases. The +retained-Query witness holds an empty-cache fetch across subset unload and reacquisition. A direct delete then requires an explicit refetch to start a -post-write request and preserve the deleted public row at settlement; without -a later write, the same refetch may reuse the in-flight request. A separate +post-write request and preserve the deleted public row at settlement; without a +later write, the same refetch may reuse the in-flight request. A separate checkpoint keeps the detached fetch-start position until that request settles and retires it afterward. Two further shared-Query histories add A's sync restart after a direct insert/delete while B's pre-write request remains in @@ -2264,15 +2282,15 @@ flight. Without an explicit refetch, the old result must preserve the delete; with one, A must start a post-write request. Both were RED before physical fetch-start positions survived the restart and are GREEN afterward. These controlled histories do not establish every persisted adapter or Query -cache-removal schedule. -The [intermediate review at 74e399150](oracle-reviews/pr-2065-74e399-review.md) -adds ordinary-unload and unobserved-fetch histories to that request-order law. -The [final direct-write authority review](oracle-reviews/2026-10-12-query-direct-write-authority.md) -records the later law decisions, RED/GREEN evidence, and ten-item audit. -The [four-review follow-up](oracle-reviews/2026-10-12-query-authority-oracle-followup.md) +cache-removal schedule. The +[intermediate review at 74e399150](oracle-reviews/pr-2065-74e399-review.md) adds +ordinary-unload and unobserved-fetch histories to that request-order law. The +[final direct-write authority review](oracle-reviews/2026-10-12-query-direct-write-authority.md) +records the later law decisions, RED/GREEN evidence, and ten-item audit. The +[four-review follow-up](oracle-reviews/2026-10-12-query-authority-oracle-followup.md) audits legal subset predicates, cache-projection boundaries, scoped omissions, -and the generated cursor manual-write campaign against its repaired commit. -The ownership oracle also checks derived-select reacquisition and eager restart, +and the generated cursor manual-write campaign against its repaired commit. The +ownership oracle also checks derived-select reacquisition and eager restart, explicit subset refetch, a derived result held behind `deferDataRefresh`, a provisional cache row awaiting persisted application, aliased physical Queries, and the boundary between an in-flight pending Query and an idle empty cache @@ -2282,20 +2300,19 @@ patch, resets the physical Query so the next fetch repeats the patch's numeric update count, and requires B's load to settle on the new provider row. It runs with a new response array and with the patch's exact array reference. This checks the authority boundary at fetch settlement, but does not model every -Query Core reset schedule or custom-hash collision. -An outside-prefix custom-hash collision remains in Query Core's cache after a -direct write. The disabled reacquisition witness requires the load to wait for -an explicit provider result, then checks that result and ownerless-row -retirement. It covers one physical alias and an unbounded scope, not arbitrary -hash functions or windowed scopes. -The ordinary-unload, provisional-row, and alias tests reject their respective -mutant classifiers at public-row or cache checkpoints. These finite histories -do not establish every persistence schedule, every Query hash collision, or a -full offline replay; those remain owned by the Query DB ownership and offline -receiving oracles. Query Core 5.90.15 is the lowest version on which the full -Query DB suite passed; 5.90.14 failed error-recovery witnesses, and 5.85.2 -failed the post-write refetch witness. Older versions are excluded by the peer -minimum. +Query Core reset schedule or custom-hash collision. An outside-prefix +custom-hash collision remains in Query Core's cache after a direct write. The +disabled reacquisition witness requires the load to wait for an explicit +provider result, then checks that result and ownerless-row retirement. It covers +one physical alias and an unbounded scope, not arbitrary hash functions or +windowed scopes. The ordinary-unload, provisional-row, and alias tests reject +their respective mutant classifiers at public-row or cache checkpoints. These +finite histories do not establish every persistence schedule, every Query hash +collision, or a full offline replay; those remain owned by the Query DB +ownership and offline receiving oracles. Query Core 5.90.15 is the lowest +version on which the full Query DB suite passed; 5.90.14 failed error-recovery +witnesses, and 5.85.2 failed the post-write refetch witness. Older versions are +excluded by the peer minimum. The ownership oracle now checks a direct row's ownership lifetime across one and two subset claims, with and without Collection row metadata. A direct @@ -2607,16 +2624,17 @@ remain current. Custom parsers still read back. These finite witnesses do not establish recovery of an invalid writer key extractor or manual acceptance without a mutation Collection owner. -The [follow-up oracle review](oracle-reviews/2026-10-08-localstorage-final-review.md) +The +[follow-up oracle review](oracle-reviews/2026-10-08-localstorage-final-review.md) adds direct-owner idle, ready, and cleaned-up histories for module-level acceptance into a DbClient materialization. It checks the manual receipt, durable snapshot, active public snapshots, and fresh restore. A mutant subject that drops transaction commit work fails the held-receipt checkpoint. The peer owner checks a peer write admitted by a restore subscriber, custom-parser normalization of an authored insert and update without a version-token change, -invalid events followed by same-token repair, and malformed stored row shape -or key identity before startup opens a sync transaction. The order owner checks -a handler-free direct write at method return immediately after a synchronous +invalid events followed by same-token repair, and malformed stored row shape or +key identity before startup opens a sync transaction. The order owner checks a +handler-free direct write at method return immediately after a synchronous handler failure. These finite witnesses do not prove arbitrary reentrant subscriber histories or cross-tab atomicity. Default JSON intentionally keeps the writer's authored rich value until restore; a Date witness records the @@ -3038,20 +3056,21 @@ esbuild, an open from inside a worker, a Vite client injected without a script [PR #2103 review record](oracle-reviews/pr-2103-separate-wasm.md) classifies each calibration control and records every applicable requirement. -The [browser OPFS lifecycle owner](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts) +The +[browser OPFS lifecycle owner](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts) also generates explicit close histories: zero to three pending executes, how many answer while close is in flight and in which order, a successful close and every worker error classification, a concurrent close, an execute submitted -during close, and pagehide or a Worker terminal event before the close -response, which then rejects close and pending siblings with its own failure. At close settlement, answered executes keep their rows and the -rest reject with `AbortError`. Every close call shares the first close's -outcome. The worker receives exactly one close request, and work submitted -after close starts never reaches it. Rejecting at close start, posting a second -close, admitting work during close, deferring rejection past close settlement, -and the pre-fix hang each fail this grammar, as does ignoring pagehide or a Worker -error while close is in flight. The controlled worker -answers in any order, so these histories also cover a close response that -overtakes pending work. +during close, and pagehide or a Worker terminal event before the close response, +which then rejects close and pending siblings with its own failure. At close +settlement, answered executes keep their rows and the rest reject with +`AbortError`. Every close call shares the first close's outcome. The worker +receives exactly one close request, and work submitted after close starts never +reaches it. Rejecting at close start, posting a second close, admitting work +during close, deferring rejection past close settlement, and the pre-fix hang +each fail this grammar, as does ignoring pagehide or a Worker error while close +is in flight. The controlled worker answers in any order, so these histories +also cover a close response that overtakes pending work. The [OPFS request-order oracle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts) states that a connection settles requests in acceptance order with sequential @@ -3078,31 +3097,31 @@ than one other handle holder. The review record is The ordered-work owner also checks that an Effect with `skipInitial` waits for joined demand before ending initial callback suppression. A deterministic work counter rejects an extra joined-filter graph schedule on root readiness while -preserving the joined-source wake-up. Nonterminal per-demand failure recovery -is outside this owner: `failDemand` enters terminal live-query error while its -plan record remains unsettled until sync teardown. A future recovery contract -must reconcile plan and subscription pending state before reopening publication. -Its startup witness covers one synchronous rank update after an indexed page -load and before the last source subscription returns. It does not cover every -source ordering, multiple buffered mutations, or asynchronous startup changes. -The [ordered Effect startup review](oracle-reviews/gap-06-ordered-effect-startup.md) +preserving the joined-source wake-up. Nonterminal per-demand failure recovery is +outside this owner: `failDemand` enters terminal live-query error while its plan +record remains unsettled until sync teardown. A future recovery contract must +reconcile plan and subscription pending state before reopening publication. Its +startup witness covers one synchronous rank update after an indexed page load +and before the last source subscription returns. It does not cover every source +ordering, multiple buffered mutations, or asynchronous startup changes. The +[ordered Effect startup review](oracle-reviews/gap-06-ordered-effect-startup.md) records its baseline and mutant-mutant observations. The Collection lifecycle change-event history owner also checks eager auto-index -equality buckets and fresh indexed `eq` live queries against its independent -row Map. Its required same-turn delete/reinsert prefix reaches an already-built -index for both BasicIndex and BTreeIndex. In the batched grammar, public rows and -equality buckets are checked after each mutation returns, before any transaction -is awaited; the batched final state and each sequential settled prefix are also -checked. Fresh indexed queries are checked at the final checkpoint. -Fixed and random tails check nearby legal histories. One temporary mutant that -skipped index updates failed the fixed batched final count: one indexed key -versus two model rows. Another fault that preserved the final keys failed the -new returned-mutation check. The direct index refinement owner does not prove -this Collection mutation path. Callback-time index agreement, -the reported insert-seeded string-key setup, adapter cancellation, and -non-local-only persistence remain outside this grammar. -The [issue #1912 review record](oracle-reviews/issue-1912-eager-index-history.md) +equality buckets and fresh indexed `eq` live queries against its independent row +Map. Its required same-turn delete/reinsert prefix reaches an already-built +index for both BasicIndex and BTreeIndex. In the batched grammar, public rows +and equality buckets are checked after each mutation returns, before any +transaction is awaited; the batched final state and each sequential settled +prefix are also checked. Fresh indexed queries are checked at the final +checkpoint. Fixed and random tails check nearby legal histories. One temporary +mutant that skipped index updates failed the fixed batched final count: one +indexed key versus two model rows. Another fault that preserved the final keys +failed the new returned-mutation check. The direct index refinement owner does +not prove this Collection mutation path. Callback-time index agreement, the +reported insert-seeded string-key setup, adapter cancellation, and +non-local-only persistence remain outside this grammar. The +[issue #1912 review record](oracle-reviews/issue-1912-eager-index-history.md) records the grammar controls, exact reviewed commit, and ORC audit. The [live Electric immediate-reload witness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/electric-immediate-reload.opfs.spec.ts) @@ -3117,55 +3136,52 @@ pre-publication tick. This bounded history does not prove Windows/Edge behavior, offline recovery, arbitrary later re-inserts, or a durable-acknowledgment contract for `awaitTxId`. -The [optimistic-history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts) +The +[optimistic-history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts) now owns the held-row law for sync transactions deferred behind a wrapping -sync's durable step, and checks that every optimistic mutation is visible in -the tick that makes it. Its +sync's durable step, and checks that every optimistic mutation is visible in the +tick that makes it. Its [persisted production path](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts) -runs the shared grammar through `persistedCollectionOptions` with -`hold-store` and `release-store` steps and a handler that commits its own -server row. It was RED before the wrapper reported deferred sync keys to -core: when an earlier source transaction's store was held, a settled row -dropped to an older synced row before its deferred server row applied -(insert-then-update and insert-then-delete histories). It is green now, and -mutants that drop deferred keys from the hold, drop the held row at a drain, -or skip the held overlay after a drain each fail it. The grammar also -aborts a deferred transaction before its turn, and ends every history with a -source transaction on every key, so a stale hold or leaked local attribution -shows. That step was RED on the first fix: the wrapper marked an aborted -transaction as handed to the Collection, so its hold never ended, and a -non-optimistic write kept local attribution for a dropped transaction. A -campaign check requires each run to reach a hold across a drain and a hold -released by an abort. Open: wrapper cleanup or source restart while a -transaction is deferred has no grammar step, and a held-row release filtered -as a recent sync echo was not reproduced. The production path uses an in-memory -adapter. -Several tabs on one durable store each run their own source and store -every source transaction they receive. DB promises only eventual -consistency between them: while their streams interleave, a lagging tab's -copy of an older transaction can be stored and announced after a newer one, -so a tab can briefly show an older server state. Every tab delivers the -same log in order, so durable state and every tab converge once the streams -catch up. No production path checks this yet. -A counterexample bounds that convergence claim -([#2209](https://github.com/TanStack/db/issues/2209)). Two Browser tabs -share one SQLite file through the real coordinator, with a controlled -Electric stream per tab. Tab A's stream applies v1 then v2 at `30_0`. Tab B's -lagging stream applies v1 at `20_0`, and tab B closes before it reaches v2. -Tab A then shows v1, and the durable `electric:resume` offset is back at -`20_0`. Nothing redelivers v2 to tab A while it stays open, because its -stream is already past it. A tab that opens later resumes from `20_0` and -repairs the cache in that history. Restart formerly failed when a later commit advanced the resume position -over stale rows (#2281). The eager SQLite bridge now records source -continuity atomically and requires a fresh snapshot on reopen after a gap; -see the [repair evidence](oracle-reviews/issue-2281-investigation.md). -This preserves temporary arrival-order regressions and leaves #2209 open. The -multi-tab Electric oracle in `browser-db-sqlite-persistence` fixes this -history with `it.fails` beside a passing control in which the lagging tab -catches up. Ordering the copies would need a generic -source position on sync commits; Electric already stores `{ handle, offset }` -with each commit, PowerSync does not use this wrapper, and persisted Query -collections have no log position. +runs the shared grammar through `persistedCollectionOptions` with `hold-store` +and `release-store` steps and a handler that commits its own server row. It was +RED before the wrapper reported deferred sync keys to core: when an earlier +source transaction's store was held, a settled row dropped to an older synced +row before its deferred server row applied (insert-then-update and +insert-then-delete histories). It is green now, and mutants that drop deferred +keys from the hold, drop the held row at a drain, or skip the held overlay after +a drain each fail it. The grammar also aborts a deferred transaction before its +turn, and ends every history with a source transaction on every key, so a stale +hold or leaked local attribution shows. That step was RED on the first fix: the +wrapper marked an aborted transaction as handed to the Collection, so its hold +never ended, and a non-optimistic write kept local attribution for a dropped +transaction. A campaign check requires each run to reach a hold across a drain +and a hold released by an abort. Open: wrapper cleanup or source restart while a +transaction is deferred has no grammar step, and a held-row release filtered as +a recent sync echo was not reproduced. The production path uses an in-memory +adapter. Several tabs on one durable store each run their own source and store +every source transaction they receive. DB promises only eventual consistency +between them: while their streams interleave, a lagging tab's copy of an older +transaction can be stored and announced after a newer one, so a tab can briefly +show an older server state. Every tab delivers the same log in order, so durable +state and every tab converge once the streams catch up. No production path +checks this yet. A counterexample bounds that convergence claim +([#2209](https://github.com/TanStack/db/issues/2209)). Two Browser tabs share +one SQLite file through the real coordinator, with a controlled Electric stream +per tab. Tab A's stream applies v1 then v2 at `30_0`. Tab B's lagging stream +applies v1 at `20_0`, and tab B closes before it reaches v2. Tab A then shows +v1, and the durable `electric:resume` offset is back at `20_0`. Nothing +redelivers v2 to tab A while it stays open, because its stream is already past +it. A tab that opens later resumes from `20_0` and repairs the cache in that +history. Restart formerly failed when a later commit advanced the resume +position over stale rows (#2281). The eager SQLite bridge now records source +continuity atomically and requires a fresh snapshot on reopen after a gap; see +the [repair evidence](oracle-reviews/issue-2281-investigation.md). This +preserves temporary arrival-order regressions and leaves #2209 open. The +multi-tab Electric oracle in `browser-db-sqlite-persistence` fixes this history +with `it.fails` beside a passing control in which the lagging tab catches up. +Ordering the copies would need a generic source position on sync commits; +Electric already stores `{ handle, offset }` with each commit, PowerSync does +not use this wrapper, and persisted Query collections have no log position. The [PR #1914 follow-up review record](oracle-reviews/pr-1914-immediate-reload-follow-up.md) preserves the held-checkpoint and extra-POST mutant results at the exact @@ -3553,20 +3569,20 @@ to the supplied live scope and its erroneous scheduled call; temporal overlap alone is insufficient. Captured primary evidence survives cleanup failures. The default coordinator now uses the supplied scoped adapter for that call, -without caching it. The initial repair receipt on main at `a37e69ab` records -48 core routing assertion failures and eight real scheduler-cycle assertion +without caching it. The initial repair receipt on main at `a37e69ab` records 48 +core routing assertion failures and eight real scheduler-cycle assertion failures for the original production code. The latest review follow-up passes -640 persisted-oracle checks (with one existing todo) and 25 Chromium OPFS checks. -The original eight mutant repairs cover premature receipts, cached loans, -omitted data, reverse FIFO and suppressed/misclassified failure. New calibration -shows the old browser checks accepting cached loans and ordinary scheduling -bypass; the strengthened receiver rejects them at tail routing and the complete -scope boundary. A no-scope error-identity mutant is rejected by the restored -failure paths. The +640 persisted-oracle checks (with one existing todo) and 25 Chromium OPFS +checks. The original eight mutant repairs cover premature receipts, cached +loans, omitted data, reverse FIFO and suppressed/misclassified failure. New +calibration shows the old browser checks accepting cached loans and ordinary +scheduling bypass; the strengthened receiver rejects them at tail routing and +the complete scope boundary. A no-scope error-identity mutant is rejected by the +restored failure paths. The [loss audit and exact content bindings](oracle-reviews/issue-2046-hydration-loss-audit.md) -preserve all source recoveries, decisions, commands, calibration and limits; -the [initial review](oracle-reviews/issue-2046-hydration-commit-deadlock.md) -retains the original issue ledger. +preserve all source recoveries, decisions, commands, calibration and limits; the +[initial review](oracle-reviews/issue-2046-hydration-commit-deadlock.md) retains +the original issue ledger. - [ ] **Scoped lifecycle compositions.** The persisted-history owner needs distinct-scope witnesses for dependent and post-publication aborts, @@ -3699,50 +3715,50 @@ The post-merge review added three missing domains to existing owners: This is distinct from exactly-once execution across independent owners. - Settlement-time drop of optimistic state is owned by the [optimistic history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts) - and [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts). + and + [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts). A transaction's optimistic state drops when its mutation function settles. - Sync transactions committed while it persisted are held and publish with - that drop; `when('settled')` settles after that publication. Handler sync writes + Sync transactions committed while it persisted are held and publish with that + drop; `when('settled')` settles after that publication. Handler sync writes are generated at every cut, with and without awaiting their acceptance. Receipts stay pending until visible. With one persisting local mutation and no truncate, the first queued same-key source transaction consumes local attribution at settlement if that mutation succeeds. An absent-key delete consumes it without leaving a row. Repeated same-key writes in that first - atomic source transaction keep its attribution. Later source transactions - are `'remote'` without another local owner. A truncate can publish while the + atomic source transaction keep its attribution. Later source transactions are + `'remote'` without another local owner. A truncate can publish while the mutation remains active and label its same-key row `'local'` even if that - mutation later fails. Attribution uses key and timing; `SyncConfig.write` - has no causal client identity. A pending manual mutation does not hold source - writes; a same-key source row can retain local attribution after rollback. - A subscriber can commit a truncate and a later same-key transaction during + mutation later fails. Attribution uses key and timing; `SyncConfig.write` has + no causal client identity. A pending manual mutation does not hold source + writes; a same-key source row can retain local attribution after rollback. A + subscriber can commit a truncate and a later same-key transaction during another drain. The suffix truncate can use the still-active mutation's - attribution. The later same-key transaction in that suffix drain is remote. - An active mutation on a key the - truncate does not write still attributes that key's first later transaction - in the same drain. Bounded histories also cross two - persisting same-key mutations, one or two source batches, both settlement - orders, and success/failure combinations. The first source batch is local - when at least one mutation succeeds; two failures leave it remote. A mutant + attribution. The later same-key transaction in that suffix drain is remote. An + active mutation on a key the truncate does not write still attributes that + key's first later transaction in the same drain. Bounded histories also cross + two persisting same-key mutations, one or two source batches, both settlement + orders, and success/failure combinations. The first source batch is local when + at least one mutation succeeds; two failures leave it remote. A mutant failed-sibling mutant fails the one-success cases at settlement. The pending grammar rejects a mutant that ignores pending mutations at its source or settlement publication. Bounded manual histories now cross success, direct - rollback, and handler rejection with both optimistic visibility settings. - A later disjoint edit cannot supply the earlier manual request's handler - outcome. The original harness left rejected manual requests pending; the - new settlement check rejects that harness and a shared-handler variant. - Six pending-peer histories distinguish same-key rollback cascades from - secondary rollback and different-key controls, through public settlement - and publication. Longer manual interleavings and larger same-key cascades - remain outside these fixed histories. The original truncate implementation - failed the reentrant history at failed-mutation settlement. The earlier - accepted-snapshot retention law - and its truncate-capture ownership refinement were retired with this - contract. The + rollback, and handler rejection with both optimistic visibility settings. A + later disjoint edit cannot supply the earlier manual request's handler + outcome. The original harness left rejected manual requests pending; the new + settlement check rejects that harness and a shared-handler variant. Six + pending-peer histories distinguish same-key rollback cascades from secondary + rollback and different-key controls, through public settlement and + publication. Longer manual interleavings and larger same-key cascades remain + outside these fixed histories. The original truncate implementation failed the + reentrant history at failed-mutation settlement. The earlier accepted-snapshot + retention law and its truncate-capture ownership refinement were retired with + this contract. The [PR #1907 review record](oracle-reviews/pr-1907-accepted-delete-ownership.md) - is historical. The [settlement-drop review record](oracle-reviews/2026-10-03-settlement-drop.md) - holds the RED/GREEN and mutant evidence. A handler that awaits a visibility receipt held by its own - transaction, such as an on-demand load of its own Collection, waits for - itself; that history is outside the generated grammar. + is historical. The + [settlement-drop review record](oracle-reviews/2026-10-03-settlement-drop.md) + holds the RED/GREEN and mutant evidence. A handler that awaits a visibility + receipt held by its own transaction, such as an on-demand load of its own + Collection, waits for itself; that history is outside the generated grammar. Issue #2071 adds two focused checks to this acceptance map: @@ -3761,28 +3777,28 @@ Issue #2071 adds two focused checks to this acceptance map: restart. The persisted wrapper owner needs the bridge callback and write sequence to reach that path. -- The [optimistic publication owner](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) +- The + [optimistic publication owner](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) distinguishes one and two queued same-key source transactions during a successful local mutation, two same-key writes inside one atomic transaction, an absent-key delete before a source insert, a delete and reinsert in one - atomic source transaction, a truncate published before - a mutation fails, pending manual mutations, two overlapping same-key - mutations, a truncate followed by another same-key transaction, and a - truncate followed by a different-key transaction with an active mutation in - one reentrant drain. The persisted wrapper receives the one/two transaction - pair. The source grammar can now preserve mixed write order inside one - transaction; its atomic delete/reinsert witness is distinguished from a - later same-key transaction. These histories establish bounded key-and-timing - attribution; they do not establish every mixed operation sequence, causal - authorship, or every overlapping mutation schedule. A - causal `$origin` guarantee would require an explicit source signal and a new - core-owner model and provider receiving witness. The - [PR #2078 review](oracle-reviews/pr-2078-origin-attribution.md) records the - original same-drain RED, the repair, and mutant controls. The optimistic - history owner still needs controlled pending-plus-persisting same-key - histories, truncate with two active owners, and longer reentrant or mixed - source suffixes if those broader schedules are claimed. Such a witness must - compare origin and complete publication cuts, not only the final row. The + atomic source transaction, a truncate published before a mutation fails, + pending manual mutations, two overlapping same-key mutations, a truncate + followed by another same-key transaction, and a truncate followed by a + different-key transaction with an active mutation in one reentrant drain. The + persisted wrapper receives the one/two transaction pair. The source grammar + can now preserve mixed write order inside one transaction; its atomic + delete/reinsert witness is distinguished from a later same-key transaction. + These histories establish bounded key-and-timing attribution; they do not + establish every mixed operation sequence, causal authorship, or every + overlapping mutation schedule. A causal `$origin` guarantee would require an + explicit source signal and a new core-owner model and provider receiving + witness. The [PR #2078 review](oracle-reviews/pr-2078-origin-attribution.md) + records the original same-drain RED, the repair, and mutant controls. The + optimistic history owner still needs controlled pending-plus-persisting + same-key histories, truncate with two active owners, and longer reentrant or + mixed source suffixes if those broader schedules are claimed. Such a witness + must compare origin and complete publication cuts, not only the final row. The persisted wrapper owner still needs the private bridge callback and write sequence before claiming that reported path. @@ -4780,15 +4796,15 @@ reload after a named notice keeps a seed's metadata (decision 18). | [Query ownership lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts) receiving witnesses | Query DB claims, releases, and retention cleanup over core and `persistedCollectionOptions` | published owners and non-owner row metadata | | receiving expectations in the [persisted oracle](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), the Electric oracle, the Browser coordinator oracle, and the Electron IPC tests | persisted source inserts over a hydrated baseline; the durable mutation a source insert writes | public and durable rows; persisted mutation types | -On `30175be48`, 51 of 186 tests fail in the session, composition, and -retention files: held reads, commit order and outer writes after a held inner -commit, every seed rule, and the paired properties. The persisted production path fails -the held reads through core, reads of an enclosing open transaction, an -insert's implicit clear, equal re-inserts, inserts over seeds and cached rows -under partial row updates, and late seeds beside a store-held transaction. -Six Query witnesses fail, including two that only dropping Query's `??` -metadata fallback repairs. The review record's evidence block lists reach -counts, mutants, and GREEN runs. +On `30175be48`, 51 of 186 tests fail in the session, composition, and retention +files: held reads, commit order and outer writes after a held inner commit, +every seed rule, and the paired properties. The persisted production path fails +the held reads through core, reads of an enclosing open transaction, an insert's +implicit clear, equal re-inserts, inserts over seeds and cached rows under +partial row updates, and late seeds beside a store-held transaction. Six Query +witnesses fail, including two that only dropping Query's `??` metadata fallback +repairs. The review record's evidence block lists reach counts, mutants, and +GREEN runs. Two comparisons are narrowed on purpose, each recorded with its authority in the review record: @@ -4804,35 +4820,36 @@ the review record: The `persisted-peers` production path plays a second tab's source through the persistence coordinator: named row, metadata-only, delete, full-reload, and -reset notices, crossed with seeds, the cached row, nesting, and holds. A -reload that a notice causes is a storage read: its truncate replaces rows -without truncating source authority or row metadata, an unchanged row keeps -its status, and a seed's metadata survives a reload after a named notice -(decisions 9, 11, and 15 to 18). A reset is a source truncate, and every -later notice in its term reloads storage; on demand the reset keeps the -seeds this tab holds (decisions 19 and 20). After settlement the stored rows -must equal the model's, which a restart would read. -`persisted-peers-full` repeats the production path under full row updates, where an -update replaces its row in storage too; `persisted-peers-on-demand` repeats -it on demand with a demand for every row. A reset over metadata of a missing -row is too rare in generated histories to declare; a fixed history covers it. +reset notices, crossed with seeds, the cached row, nesting, and holds. A reload +that a notice causes is a storage read: its truncate replaces rows without +truncating source authority or row metadata, an unchanged row keeps its status, +and a seed's metadata survives a reload after a named notice (decisions 9, 11, +and 15 to 18). A reset is a source truncate, and every later notice in its term +reloads storage; on demand the reset keeps the seeds this tab holds (decisions +19 and 20). After settlement the stored rows must equal the model's, which a +restart would read. `persisted-peers-full` repeats the production path under +full row updates, where an update replaces its row in storage too; +`persisted-peers-on-demand` repeats it on demand with a demand for every row. A +reset over metadata of a missing row is too rare in generated histories to +declare; a fixed history covers it. An equal source re-insert keeps metadata without writing it (decisions 9 and 14). As with an update, that keep neither makes a DbClient seed durable nor sends it to peers (decision 18). Direct durable metadata is compared separately from this tab's source reads. The constructive durability grammar includes all 36 update/equal-reinsert/inner-reinsert × bare/non-bare seed × implicit/explicit -metadata × no-notice/named-notice/full-reload cases in each generated batch. Its partial/full/on-demand grammar run -through both the controlled persisted adapter and the real SQLite/coordinator -receiver, with 108 declared case/production path/notice states per host, each reached eight times -in both fixed and random campaigns. Seed row shape, seed timing and repeat -count vary; no campaign can omit the full reload. Explicit metadata writes are the -neighboring control. The original seed/re-insert/reload grammar, fixed/random -40 budgets and its completed fixed-branch guards remain; the new constructive -batch supplies guaranteed bare-key-1 implicit histories independently of seed. -Both hosts share the 40-run sibling and 8-run durability campaign mechanics in -`sync-metadata-session-campaign.ts`; production setup, recovery observations -and direct replay selection remain in their receiving files. +metadata × no-notice/named-notice/full-reload cases in each generated batch. Its +partial/full/on-demand grammar run through both the controlled persisted adapter +and the real SQLite/coordinator receiver, with 108 declared case/production +path/notice states per host, each reached eight times in both fixed and random +campaigns. Seed row shape, seed timing and repeat count vary; no campaign can +omit the full reload. Explicit metadata writes are the neighboring control. The +original seed/re-insert/reload grammar, fixed/random 40 budgets and its +completed fixed-branch guards remain; the new constructive batch supplies +guaranteed bare-key-1 implicit histories independently of seed. Both hosts share +the 40-run sibling and 8-run durability campaign mechanics in +`sync-metadata-session-campaign.ts`; production setup, recovery observations and +direct replay selection remain in their receiving files. The native on-demand path can receive a successful recovery response that crosses an earlier cache version without transaction replay details. The shared @@ -4911,35 +4928,36 @@ remains separate and open under this owner, with P8 investigating: property controlled-notice witness or the broader #2281 persistence reports. Remaining observation work under this owner: the read-hold and abandonment -production paths retain source-facing comparisons but do not provide the peer production paths' -direct durable transaction trace. A receiving witness must stage a wrong -durable write followed by repair while a read hold buffers a commit, including -abandonment, and reject it at the appropriate durable boundary. These additions -do not retire that obligation. Cross-host recovery schedules still need actual -Chromium OPFS and Electron IPC receiving witnesses; their owner remains this -session oracle with the broader receiving work tracked by #2272. +production paths retain source-facing comparisons but do not provide the peer +production paths' direct durable transaction trace. A receiving witness must +stage a wrong durable write followed by repair while a read hold buffers a +commit, including abandonment, and reject it at the appropriate durable +boundary. These additions do not retire that obligation. Cross-host recovery +schedules still need actual Chromium OPFS and Electron IPC receiving witnesses; +their owner remains this session oracle with the broader receiving work tracked +by #2272. Repaired counterexample, **META-CI-051-S001**: an enclosing update reads a cached row, an inner transaction deletes that row and sets its metadata while missing, then both commit. A named peer notice for another key drops the -retained metadata in the on-demand receiver, with and without a core hold. -Both the controlled adapter and native SQLite reproduce this source-read -failure under D16 before the production repair. The model correction separates -this run-only metadata from durable metadata when the outer update recreates the -row; the old model's false durable expectation masks the later source failure. -Owner: this session oracle. The generated -`sync-metadata-session.{persisted,sqlite}-missing-row-metadata` campaigns -cover twelve cases in all three peer production paths: implicit/explicit metadata, +retained metadata in the on-demand receiver, with and without a core hold. Both +the controlled adapter and native SQLite reproduce this source-read failure +under D16 before the production repair. The model correction separates this +run-only metadata from durable metadata when the outer update recreates the row; +the old model's false durable expectation masks the later source failure. Owner: +this session oracle. The generated +`sync-metadata-session.{persisted,sqlite}-missing-row-metadata` campaigns cover +twelve cases in all three peer production paths: implicit/explicit metadata, immediate/core-held application, a prior source truncate, same-key peer row or metadata replacement, unknown-write reloads, peer/source delete and source -truncate. All 36 case/production path states reach eight times in each fixed/random campaign -on both hosts. The wrapper distinguishes local metadata from a named peer -replacement, preserving D16 without defeating D11 or D15. Three wrong retention -policies fail the same source-read comparisons. The -[CI investigation](oracle-reviews/2026-11-02-p6-ci-missing-row.md) -records the pre-edit receiving RED, repaired GREEN, controls and bounded class -claim. This does not determine that the separate #2317/P8 witness has the same -cause or close its broader scope. +truncate. All 36 case/production path states reach eight times in each +fixed/random campaign on both hosts. The wrapper distinguishes local metadata +from a named peer replacement, preserving D16 without defeating D11 or D15. +Three wrong retention policies fail the same source-read comparisons. The +[CI investigation](oracle-reviews/2026-11-02-p6-ci-missing-row.md) records the +pre-edit receiving RED, repaired GREEN, controls and bounded class claim. This +does not determine that the separate #2317/P8 witness has the same cause or +close its broader scope. `persisted-reads-seeds` crosses read holds with seeds and the cached row and aborts a buffered commit's signal, which abandons it at the read's end @@ -5520,31 +5538,34 @@ The [sighted donor survey](oracle-reviews/2026-10-06-indexeddb-donor-survey.md) now has these receiving owners. The [port audit](oracle-reviews/2026-10-06-indexeddb-donor-port.md) records calibration, validation and the remaining limits. -- DP08: `compatibility.test.ts` (fixed tests; no oracle owner (tracked in #2264)) starts two/three native opens against - absent same/independent names, with identical complete declarations. It checks - schema, held restore/readiness, disjoint writes, convergence, restore and a later - upgrade with no orphan blocker. - `packages/db/e2e/indexed-db/value-oracle.spec.ts` receives three-open - histories in all engines. These histories settle all opens before writing; - they do not impose an order on open callbacks or union conflicting declarations. +- DP08: `compatibility.test.ts` (fixed tests; no oracle owner (tracked in + #2264)) starts two/three native opens against absent same/independent names, + with identical complete declarations. It checks schema, held + restore/readiness, disjoint writes, convergence, restore and a later upgrade + with no orphan blocker. `packages/db/e2e/indexed-db/value-oracle.spec.ts` + receives three-open histories in all engines. These histories settle all opens + before writing; they do not impose an order on open callbacks or union + conflicting declarations. - DP09: `persistence-values-oracle.test.ts` extends the persistence owner with independent tagged descriptions in `structured-clone-oracle.ts`. Seven value shapes cross scalar updates, peer delivery, export/import, first/middle/last - nested clone rejection, metadata rollback and fresh descriptor restore. - Type, offsets, backing bytes, dates and Blob content are observed inside the + nested clone rejection, metadata rollback and fresh descriptor restore. Type, + offsets, backing bytes, dates and Blob content are observed inside the receiving realm. Native receiving is in `packages/db/e2e/indexed-db/value-oracle.spec.ts`. - DP10: the compatibility owner substitutes six legal database/store names into - the same authored multi-store history. Prefix neighbors, typed keys in separate - transactions, version values, peers and restore distinguish routing mistakes. - `packages/db/e2e/indexed-db/host.spec.ts` (fixed tests; no oracle owner (tracked in #2264)) receives all six names - through single-transaction typed-key insertion, peer update, import, clear, export and fresh restore. + the same authored multi-store history. Prefix neighbors, typed keys in + separate transactions, version values, peers and restore distinguish routing + mistakes. `packages/db/e2e/indexed-db/host.spec.ts` (fixed tests; no oracle + owner (tracked in #2264)) receives all six names through single-transaction + typed-key insertion, peer update, import, clear, export and fresh restore. Prefix-neighbor and anchor snapshots must remain unchanged. - DP17: `packed-consumer.test.ts` supplements declaration tests with installed tarball ESM/CJS consumers and broken-export controls. The browser spec builds the same consumer without source aliases and executes it in all three engines. - Tarball overrides fix the installed dependency closure; registry range selection, - older Node/tool versions and every public export remain outside this smoke test. + Tarball overrides fix the installed dependency closure; registry range + selection, older Node/tool versions and every public export remain outside + this smoke test. The ordinary local Playwright WebKit profile rejects Blob preparation with `UnknownError`; its two Blob-containing cells require exact rejection and a @@ -5913,16 +5934,19 @@ scheduling and a live Electric service rather than controlled HTTP remain separate unproved cuts; these fixed receivers do not close every cache-eviction interleaving. -The [cache-generation oracle audit](oracle-reviews/issue-2069-cache-generation-oracle-audit-53ded62d8.md) -records the generated grammar controls, killed mutants, direct replays, and -the remaining handoff limits for code commit `53ded62d8ef70ccd63b75d0e6dbc0a964f14b17c`. -The [cache-claim gap audit](oracle-reviews/issue-2069-cache-claim-gap-audit-575fb9473.md) +The +[cache-generation oracle audit](oracle-reviews/issue-2069-cache-generation-oracle-audit-53ded62d8.md) +records the generated grammar controls, killed mutants, direct replays, and the +remaining handoff limits for code commit +`53ded62d8ef70ccd63b75d0e6dbc0a964f14b17c`. The +[cache-claim gap audit](oracle-reviews/issue-2069-cache-claim-gap-audit-575fb9473.md) records the added receiving witnesses, mutant checks, direct replay, and -remaining limits for code commit `575fb9473dd7d8eccbe469d0831dc9f71cffddd4`. -The [native SQLite index receiving audit](oracle-reviews/issue-2069-native-ddl-receiver-b4d57ced6.md) +remaining limits for code commit `575fb9473dd7d8eccbe469d0831dc9f71cffddd4`. The +[native SQLite index receiving audit](oracle-reviews/issue-2069-native-ddl-receiver-b4d57ced6.md) records the two reverse-order DDL witnesses, mutant checks, and remaining host-specific limit for code commit `b4d57ced63d513dd0dcb794b8e8bb2a840577758`. -The [follow-up review audit](oracle-reviews/issue-2069-native-ddl-review-followup-b42b94b73.md) +The +[follow-up review audit](oracle-reviews/issue-2069-native-ddl-review-followup-b42b94b73.md) records the exact recovery-settlement and target-table assertion repairs for code commit `b42b94b73ddba79a6a87fa93e71787dd126ea1bd`. @@ -5997,49 +6021,49 @@ and a demand started in that interval settles after its replacement subset applies. The ShapeStream callback schedule is controlled. An installed-SDK HTTP delivery at this exact intermediate cut and native-host execution are not established. -Applied-settlement owner: `packages/electric-db-collection/tests/electric-oracle.property.test.ts`. -Held optimistic application distinguishes readiness from subset completion for -demand both before and after readiness. A later same-run reset distinguishes a -current replacement obligation from a permanently fulfilled startup gate. -Concurrent initial-error and cleanup cuts observe settlement and lifecycle -authority; cleanup quietly settles in-flight adapter loads. A sibling-demand -abort cut requires one canceled demand to reject promptly while its sibling -and a replacement of the canceled demand wait for the full source snapshot and -succeed after its row applies. A separate external stream abort rejects its -current full-log demand and never certifies an identical later demand as loaded. -These full-log wait obligations still apply when scoped recovery is unavailable; -the scoped path uses SDK snapshots and applied receipts. -The changes-only path has the same refusal both when a subset transport returns -after stream retirement and when an applied receipt is held across that -retirement. -The full-mode retry histories now reject their pre-error demand, then require a -new demand to succeed after a same-run recovered snapshot, both with and -without a held applied receipt. An installed-SDK controlled-HTTP receiver -exhausts 503 backoff, invokes the user's retry -handler, and acquires a new demand while the retry HTTP request is open. It -requires that demand to wait, then succeed after the new full-log up-to-date. -A missing-header response checks that the SDK's refusal to retry rejects later -demands rather than leaving a renewed gate pending. The +Applied-settlement owner: +`packages/electric-db-collection/tests/electric-oracle.property.test.ts`. Held +optimistic application distinguishes readiness from subset completion for demand +both before and after readiness. A later same-run reset distinguishes a current +replacement obligation from a permanently fulfilled startup gate. Concurrent +initial-error and cleanup cuts observe settlement and lifecycle authority; +cleanup quietly settles in-flight adapter loads. A sibling-demand abort cut +requires one canceled demand to reject promptly while its sibling and a +replacement of the canceled demand wait for the full source snapshot and succeed +after its row applies. A separate external stream abort rejects its current +full-log demand and never certifies an identical later demand as loaded. These +full-log wait obligations still apply when scoped recovery is unavailable; the +scoped path uses SDK snapshots and applied receipts. The changes-only path has +the same refusal both when a subset transport returns after stream retirement +and when an applied receipt is held across that retirement. The full-mode retry +histories now reject their pre-error demand, then require a new demand to +succeed after a same-run recovered snapshot, both with and without a held +applied receipt. An installed-SDK controlled-HTTP receiver exhausts 503 backoff, +invokes the user's retry handler, and acquires a new demand while the retry HTTP +request is open. It requires that demand to wait, then succeed after the new +full-log up-to-date. A missing-header response checks that the SDK's refusal to +retry rejects later demands rather than leaving a renewed gate pending. The scoped reset history acquires a snapshot before must-refetch, then requires subset-end to apply its row before demand success without waiting for a full up-to-date. The neighboring full-mode reset retains that stronger wait. The -Electric durable fixture has an adversarial keys insert check that rejects retained -old columns or tag metadata; it is driver validation, not a new model owner. -The Electric adapter owner now holds an asynchronous user retry decision and -checks that a new full-recovery demand remains pending until source replacement, -while the pre-error demand rejects. It also checks caller abort before subset -transport settlement, while a request is queued behind the SDK cursor, and -while an applied receipt is held. Each original behavior failed at the caller -settlement cut; the fixed driver requires prompt `AbortError` without certifying -that demand. The explicit-resume neighbor keeps malformed durable metadata -untouched until source evidence arrives. The persistence owner now invokes a -retired scoped-recovery capability after cleanup and restart; the original -cleared the replacement Collection, and the lifecycle guard preserves its row. -These fixed schedules do not prove arbitrary overlapping retry decisions or -all failure paths through `startScopedRecovery`. A valid scoped-clear commit -failure with an exact public sync-error observation still needs a witness; -an attempted subscriber-throw fixture did not reach that callback during the -local clear and supplies no evidence for the review's error-report claim. +Electric durable fixture has an adversarial keys insert check that rejects +retained old columns or tag metadata; it is driver validation, not a new model +owner. The Electric adapter owner now holds an asynchronous user retry decision +and checks that a new full-recovery demand remains pending until source +replacement, while the pre-error demand rejects. It also checks caller abort +before subset transport settlement, while a request is queued behind the SDK +cursor, and while an applied receipt is held. Each original behavior failed at +the caller settlement cut; the fixed driver requires prompt `AbortError` without +certifying that demand. The explicit-resume neighbor keeps malformed durable +metadata untouched until source evidence arrives. The persistence owner now +invokes a retired scoped-recovery capability after cleanup and restart; the +original cleared the replacement Collection, and the lifecycle guard preserves +its row. These fixed schedules do not prove arbitrary overlapping retry +decisions or all failure paths through `startScopedRecovery`. A valid +scoped-clear commit failure with an exact public sync-error observation still +needs a witness; an attempted subscriber-throw fixture did not reach that +callback during the local clear and supplies no evidence for the review's +error-report claim. Remaining witnesses before broader closure: @@ -6261,28 +6285,29 @@ broad oracle assertion, not a production repair. ### RC Effect explicit source metadata (2026-10-28) -Owner: `packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts`, sharing -the small `virtual-row-fields-source-oracle.ts` source judgment with the root -metadata owner. It recomputes explicitly selected ordinary metadata fields and -full before/after deltas independently, then receives actual Effect onBatch and -named callbacks across 200 finite single-source, LEFT-join and populated-union -histories. It preserves previous values, multiplicity, field absence/undefined, -typed source keys, and opaque event-key continuity only within active intervals. -Held mutations, independent confirmations/outcomes, both settlement orders, -remote updates, peer exit/reentry and next-use reach 5,512 production path/channel/cut cells -exactly once. No implicit Effect-root metadata or output Collection ID is claimed. - -Original P09 evidence: all 64 LEFT-join histories emitted a startup update for -a never-entered unmatched occurrence after its load finished. Those initial +Owner: `packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts`, +sharing the small `virtual-row-fields-source-oracle.ts` source judgment with the +root metadata owner. It recomputes explicitly selected ordinary metadata fields +and full before/after deltas independently, then receives actual Effect onBatch +and named callbacks across 200 finite single-source, LEFT-join and +populated-union histories. It preserves previous values, multiplicity, field +absence/undefined, typed source keys, and opaque event-key continuity only +within active intervals. Held mutations, independent confirmations/outcomes, +both settlement orders, remote updates, peer exit/reentry and next-use reach +5,512 production path/channel/cut cells exactly once. No implicit Effect-root +metadata or output Collection ID is claimed. + +Original P09 evidence: all 64 LEFT-join histories emitted a startup update for a +never-entered unmatched occurrence after its load finished. Those initial comparisons were RED on unchanged `858b174a6`; histories continued and all later -comparisons passed. -The other 136 native histories and 16 controls pass. Independent mutant -overlays reject dispatch, previous-value, duplication, colliding-key and extra -undefined defects; lawful key renaming, fresh reentry and empty partitions retain -the baseline. Per-campaign omissions and seeded partial selections now fail reach. -The finite owner explicitly disables the irrelevant shared replay exemption; -O26 retains a broader campaign inventory for that harness class. See chunk 16 -in the RC chunk record for exact fingerprints, evidence and review. +comparisons passed. The other 136 native histories and 16 controls pass. +Independent mutant overlays reject dispatch, previous-value, duplication, +colliding-key and extra undefined defects; lawful key renaming, fresh reentry +and empty partitions retain the baseline. Per-campaign omissions and seeded +partial selections now fail reach. The finite owner explicitly disables the +irrelevant shared replay exemption; O26 retains a broader campaign inventory for +that harness class. See chunk 16 in the RC chunk record for exact fingerprints, +evidence and review. Limits: eager controlled core sources, scalar rows, one mutation per source, and these named paths. No real provider/framework, arbitrary scheduling, @@ -6641,12 +6666,13 @@ configurable/nonconfigurable source data. Authored nested objects replace their field; dotted aliases preserve siblings without writing through source objects. The four Core paths supply 120 path histories and 360 result cuts. Fourteen real framework groups supply another 336 histories and 1,008 result cuts. All compare -current and retained source rows and require the actual source descriptor at -all three stages. Existing 819 and 1,200 observations and the earlier receiving +current and retained source rows and require the actual source descriptor at all +three stages. Existing 819 and 1,200 observations and the earlier receiving histories remain. Unkeyed functional projections are not legal derived-identity -framework inputs, so they receive no framework handoff credit. Legal fresh-object -expression controls provide those neighboring receiving comparisons instead. -See the [second review record](oracle-reviews/2026-10-30-solid-oracle-review-round-two.md) +framework inputs, so they receive no framework handoff credit. Legal +fresh-object expression controls provide those neighboring receiving comparisons +instead. See the +[second review record](oracle-reviews/2026-10-30-solid-oracle-review-round-two.md) for complete intake, RED gates, GREEN, mutant variants, and bounded closure. The same review adds six ordinary/subclass-array overlap cells. Dotted writes @@ -6717,17 +6743,17 @@ unequal payloads. A last-input retraction mutant and a reused negative-key mutant fail the wider check while passing the prior two-member owner. `includes-oracle.property.test.ts` extends its existing full-tree recompute -owner with colliding numeric-group route strings and the shared distinct -support history grammar. Inline arrays and Collection facades each run -fixed/random 100-history campaigns and compare public rows after each applied -source transaction. The original operator fails Collection facade membership -at checkpoint 2. Inline arrays pass: `createActiveBuckets` is used only for +owner with colliding numeric-group route strings and the shared distinct support +history grammar. Inline arrays and Collection facades each run fixed/random +100-history campaigns and compare public rows after each applied source +transaction. The original operator fails Collection facade membership at +checkpoint 2. Inline arrays pass: `createActiveBuckets` is used only for Collection-valued includes. This is a receiving witness for route coexistence, shared support, partial/last removal and reactivation, not a claim about every facade lifecycle observation. Four-member buckets are exercised at D2; the -public includes witness has two colliding routes and one anchor. Neither production path -claims mutable values, arbitrary payload kinds, framework scheduling or -intermediate source-transaction publication cuts. +public includes witness has two colliding routes and one anchor. Neither +production path claims mutable values, arbitrary payload kinds, framework +scheduling or intermediate source-transaction publication cuts. Implicit distinct keys across separate builds remain unspecified (maintainer decision in the 2026-10-30 review); applications needing stable keys supply @@ -6780,7 +6806,26 @@ See the appended [review evidence](oracle-reviews/2026-10-30-distinct-collisions ### Adapter-owned page windows -The framework owner includes [window-history-oracle.ts](../../packages/db/tests/conformance/window-history-oracle.ts), registered through the shared conformance suite in React, Preact, Vue, Solid, Angular and Svelte. Maintainer decision 2026-10-30 for #2291: changing only `.limit()`/`.offset()` moves the existing outer window; it adds no hook option. The independent sort/filter/slice model checks ordered selected rows and keyed-map values after each framework flush, Collection retention for page moves, replacement for source/filter/order/projection/nested-window changes, and retained upstream ownership. Direct and nested paths have separate reach, as do changing/stable/opaque keys where the driver supports keys. Keyed source swaps include distinct source objects under one Collection ID. Generated source updates check liveness after reuse. The bare-callback grammar also vary a Collection option together with a page change and check its applied value. The pending grammar observes a move during initial loading, oldest settlement while the latest remains pending, initial readiness, overlapping held acquisitions, final latest rows, and unmount before settlement. The publication-callback grammar changes offset, limit or both from a public change callback, then checks the retained Collection after the host settles. It controls demand settlement over already installed source rows; the core pagination owner continues to own source delivery, failures and ordered repair. +The framework owner includes +[window-history-oracle.ts](../../packages/db/tests/conformance/window-history-oracle.ts), +registered through the shared conformance suite in React, Preact, Vue, Solid, +Angular and Svelte. Maintainer decision 2026-10-30 for #2291: changing only +`.limit()`/`.offset()` moves the existing outer window; it adds no hook option. +The independent sort/filter/slice model checks ordered selected rows and +keyed-map values after each framework flush, Collection retention for page +moves, replacement for source/filter/order/projection/nested-window changes, and +retained upstream ownership. Direct and nested paths have separate reach, as do +changing/stable/opaque keys where the driver supports keys. Keyed source swaps +include distinct source objects under one Collection ID. Generated source +updates check liveness after reuse. The bare-callback grammar also vary a +Collection option together with a page change and check its applied value. The +pending grammar observes a move during initial loading, oldest settlement while +the latest remains pending, initial readiness, overlapping held acquisitions, +final latest rows, and unmount before settlement. The publication-callback +grammar changes offset, limit or both from a public change callback, then checks +the retained Collection after the host settles. It controls demand settlement +over already installed source rows; the core pagination owner continues to own +source delivery, failures and ordered repair. The [React receiving witness](../../packages/react-db/tests/window-render-oracle.test.tsx) reuses this model/checker at suspended, cancelled and committed render cuts for eager and finite on-demand sources, with derived and explicit keys. It checks the captured committed Collection and provider acquisition count while a render is abandoned. The [Preact receiving witness](../../packages/preact-db/tests/window-render-oracle.test.tsx) checks an aborted render under a real error boundary while a public subscription keeps the committed Collection active. Both run separate offset-only, limit-only and combined-move campaigns with declared reach per category. These do not establish arbitrary concurrent framework schedules. The retained-work observation checks continuous upstream ownership and every public subscription-count transition during a retained move; add-before-remove replacement fails, while retired Collections may complete GC. This is not a bound on CPU time or every possible internal compilation strategy. DbClient/hydration and Suspense shared-result caches, supplied Collections, opaque-plan optimization, queries without an outer limit and findOne retain their existing contracts and paths; this fix does not retarget their shared Collections. The generated retention guarantee covers finite bounds. An explicit Infinity limit can also move to a finite target under the resolver guard, but this evidence does not establish that additional domain or promise replacement outside the finite domain. The existing identity, deferred-acquisition, window-controller and includes owners remain authoritative for their neighboring laws. @@ -6788,7 +6833,15 @@ Evidence, per-path reach counts, reviewer reconciliation and mutation outcomes a The [fresh independent review](oracle-reviews/2026-10-30-adapter-page-windows-round-2.md) records observation and failure-fidelity repairs. Shared and receiving campaigns preserve the first violated law, checkpoint and input trace through shrinking. Later action and cleanup failures remain distinguishable; non-reproducing shrink candidates supply no successful product evidence. The original late-coverage process gap remains documented. -The existing [top-K support-window owner](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts) also has a numeric-window grammar for safe-integer maximum offsets and widths, exact omitted-bound inheritance on partial moves, and required demand messages when installed ranges are empty. It permits additional messages. It observes one-group settled operator rows, size and demand signals; real framework source growth belongs to the shared receiving owner above. [Numeric evidence](oracle-reviews/2026-10-30-topk-numeric-windows.md) records RED, GREEN, reach, replay and mutants. +The existing +[top-K support-window owner](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts) +also has a numeric-window grammar for safe-integer maximum offsets and widths, +exact omitted-bound inheritance on partial moves, and required demand messages +when installed ranges are empty. It permits additional messages. It observes +one-group settled operator rows, size and demand signals; real framework source +growth belongs to the shared receiving owner above. +[Numeric evidence](oracle-reviews/2026-10-30-topk-numeric-windows.md) records +RED, GREEN, reach, replay and mutants. ### Projection and negative-window gaps from removal review (2026-11-02) @@ -6803,12 +6856,13 @@ identical offset/limit across projection changes, including positive offsets. Native cuts require raw shape; settled cuts also compare selected values and keyed state. It is one `oracleProperty` definition, registered by each framework's conformance suite with that framework's driver as -`framework..result-shape`; two constructed histories enter its twelve required -states on every production path, the former fixed examples are fixed histories, and the previous-mode -projection and stale rows are calibrations. Reintroducing the Angular defect -below fails 32 of its tests, each at law `current query projection`. This does not promise synchronous row freshness or identical host -scheduling. Disabled inputs, errors, supplied Collections, DbClient and broad -host/AOT behavior remain outside this grammar. +`framework..result-shape`; two constructed histories enter its twelve +required states on every production path, the former fixed examples are fixed +histories, and the previous-mode projection and stale rows are calibrations. +Reintroducing the Angular defect below fails 32 of its tests, each at law +`current query projection`. This does not promise synchronous row freshness or +identical host scheduling. Disabled inputs, errors, supplied Collections, +DbClient and broad host/AOT behavior remain outside this grammar. **Repaired receiving defect:** Angular previously returned the old query's raw projection at its synchronous Signal read before effects. Both directions @@ -6975,8 +7029,9 @@ premise and one had it (81 initiated/80 accepted histories). Earlier final "lagging rewrite" counts are historical divergence evidence. A fourth terminal regime constructs the actual overwrite. Original arbitrary/reset/stop regimes, 24-step prefix, 8–24-step arbitrary tail, models, comparisons, known limits, -80-run budgets and minima remain. New lagging suffix length is 12/15 for two/three -tabs. Final commit omission fails the ordinary durable/public comparison. +80-run budgets and minima remain. New lagging suffix length is 12/15 for +two/three tabs. Final commit omission fails the ordinary durable/public +comparison. The deferred-acquisition owner adds 32 finite histories over a real same-source LEFT self-join, crossing subscriber/preload, one/two rows, literal/Promise diff --git a/docs/contributing/oracle-rubric.md b/docs/contributing/oracle-rubric.md index cb6f3ac0d6..398e2c55ce 100644 --- a/docs/contributing/oracle-rubric.md +++ b/docs/contributing/oracle-rubric.md @@ -157,8 +157,8 @@ presence of a preferred heading, class, comment template, or helper. - **Trigger:** An important generated property. - **Obligation:** The package's oracle campaign MUST execute the same property, - generators, observation recorder, refinement check, and run budget twice: - once with a documented campaign seed and once without a seed. A replay interface + generators, observation recorder, refinement check, and run budget twice: once + with a documented campaign seed and once without a seed. A replay interface MUST accept both the seed and shrink path and MUST run the requested replay directly. When the property uses `fc.commands`, the interface MUST also accept the commands arbitrary's reported `replayPath` and pass it back to @@ -227,21 +227,21 @@ presence of a preferred heading, class, comment template, or helper. ### ORC-012: Review evidence -- **Trigger:** A change claims that an oracle is new, repaired, or comprehensively - audited against this guide. +- **Trigger:** A change claims that an oracle is new, repaired, or + comprehensively audited against this guide. - **Obligation:** The review evidence MUST record the outcome of every other - applicable numbered requirement and MUST identify the requirements that do - not apply. + applicable numbered requirement and MUST identify the requirements that do not + apply. - **Acceptance evidence:** Each applicable requirement has concrete evidence or - an explicit unresolved limit. Each non-applicable requirement has a reason tied - to its trigger. + an explicit unresolved limit. Each non-applicable requirement has a reason + tied to its trigger. - **Conditional bug-class closure:** When a change claims to eliminate a bug class, the review evidence MUST name its contract × history × production-path × observation boundary, identify the original and adjacent distinguishing witnesses, show a relevant wrong design rejected at the intended checkpoint, - and list unresolved in-scope cells with their coverage-map oracle owner. A known - reachable counterexample prevents a closure claim. One oracle need not own - every boundary. + and list unresolved in-scope cells with their coverage-map oracle owner. A + known reachable counterexample prevents a closure claim. One oracle need not + own every boundary. - **Evidence location:** At closeout, verdict-critical evidence outside the executable oracle MUST live in a versioned review record tied to the exact reviewed head. An editable pull-request description alone does not satisfy @@ -257,8 +257,9 @@ presence of a preferred heading, class, comment template, or helper. - **Obligation:** The evidence MUST show a legal witness that reaches the law's premise and a nearby in-domain witness whose observation at the claimed checkpoint distinguishes the stated law from a plausible wrong boundary or - consequence. If those witnesses are unavailable, the claim MUST be limited - to the tested cases, and the reusable-law limit and its oracle owner MUST be recorded. + consequence. If those witnesses are unavailable, the claim MUST be limited to + the tested cases, and the reusable-law limit and its oracle owner MUST be + recorded. - **Acceptance evidence:** The test or review record names the wrong rule and shows which existing case rejects it. For a numeric threshold, cases on opposite sides are insufficient if a plausible misplaced threshold still @@ -312,15 +313,15 @@ presence of a preferred heading, class, comment template, or helper. - **Trigger:** A file defines an oracle, its input grammar, or a generated campaign, including a compile-time type oracle. -- **Obligation:** The file MUST NOT contain ordinary example-based unit tests - or standalone concrete type assertions. Fixed regressions MUST be inputs to - the same independent model, production driver, and comparison used by the - broader campaign. They MUST NOT restate their expected answers. A type - oracle MUST apply a reusable independent type law to generated legal inputs. +- **Obligation:** The file MUST NOT contain ordinary example-based unit tests or + standalone concrete type assertions. Fixed regressions MUST be inputs to the + same independent model, production driver, and comparison used by the broader + campaign. They MUST NOT restate their expected answers. A type oracle MUST + apply a reusable independent type law to generated legal inputs. - **Acceptance evidence:** A reviewer can vary the input without adding an - expectation. Every retained regression and receiving production driver uses the shared - judgment. Checker calibration supplies deliberately wrong observations to - that judgment. + expectation. Every retained regression and receiving production driver uses + the shared judgment. Checker calibration supplies deliberately wrong + observations to that judgment. - **Does not satisfy:** Renaming a unit test, adding contract prose, enumerating fixtures with separate assertions, or wrapping fixed expectations in a property runner. @@ -347,7 +348,10 @@ Before writing the loop, answer six questions: | What will we compare, and when? | Rows, order, events or other promised behavior, at a named checkpoint. | | Why should we trust this check? | Evidence the path ran, the comparison rejects the relevant wrong answer, and failures can be replayed. | -These are responsibilities, not six required classes. A small property may answer them in a comment and twenty lines of code. A lifecycle suite may need a separate model and production driver. Do not build a framework just to fill the table. +These are responsibilities, not six required classes. A small property may +answer them in a comment and twenty lines of code. A lifecycle suite may need a +separate model and production driver. Do not build a framework just to fill the +table. An evidence card is an optional way to collect this information; it is not a required file format: @@ -430,21 +434,23 @@ module is linted on its own, so the rule does not report a call to it. An rule judges a call to its helpers as it judges an ordinary module's. ORC-016 keeps ordinary unit tests and concrete type assertions out of oracle -files. Fixed regressions run through the shared law; supplemental examples live in -ordinary test files. +files. Fixed regressions run through the shared law; supplemental examples live +in ordinary test files. An ordinary example or regression does not need this suffix merely because it asserts a contract. The same applies to concrete type assertions, fixtures, -registration wrappers, and generic runner utilities. A production driver that only invokes -production or records observations remains support. Coverage-map inclusion, -parameterization, and contract prose alone do not establish oracle ownership. - -Count oracle owners, model/checker definitions, production drivers, and other support separately. -Several files may execute one shared oracle, and one file may contain several -oracle mechanisms. A filename is a discovery aid, not evidence of guide -conformance. Preserve runner suffixes such as `.property.test.ts`, `.test-d.ts`, -and `.spec.ts`. Update imports, test commands, replay selectors, and current -coverage links whenever an oracle file moves. +registration wrappers, and generic runner utilities. A production driver that +only invokes production or records observations remains support. Coverage-map +inclusion, parameterization, and contract prose alone do not establish oracle +ownership. + +Count oracle owners, model/checker definitions, production drivers, and other +support separately. Several files may execute one shared oracle, and one file +may contain several oracle mechanisms. A filename is a discovery aid, not +evidence of guide conformance. Preserve runner suffixes such as +`.property.test.ts`, `.test-d.ts`, and `.spec.ts`. Update imports, test +commands, replay selectors, and current coverage links whenever an oracle file +moves. ### Keep five responsibilities distinguishable @@ -615,12 +621,13 @@ Treat the layers as separate claims. Compare each pair during review: 1. Does every promise in the prose have a model rule? 2. Does the model retain every distinction that a legal next action can expose? 3. Can the history grammar reach each modeled transition? -4. Does the production driver exercise the named production path and event boundary? +4. Does the production driver exercise the named production path and event + boundary? 5. Does each modeled output reach an assertion at the promised checkpoint? 6. Can the recorder represent duplicate, missing, reordered, or partial output? 7. Does any assertion impose behavior that the contract and model do not state? -8. Do prose, model, production driver, and production use the glossary's canonical term - and transition grammar for each shared concept? +8. Do prose, model, production driver, and production use the glossary's + canonical term and transition grammar for each shared concept? 9. Does every model-only term declare how it maps to production, or that it has no production counterpart? @@ -628,8 +635,8 @@ This comparison is a useful audit instrument. ORC-012 requires outcomes for the applicable numbered requirements, not these questions or their answers in the test file. If the prose cannot explain an assertion through the model, the model may be incomplete. If the model predicts a fact that the test never observes, -the test may be false green. If the production driver cannot create a named phase, the -prose claims more reach than the test has. +the test may be false green. If the production driver cannot create a named +phase, the prose claims more reach than the test has. ### Keep the prose proportional @@ -699,7 +706,8 @@ Take two rows: `(id: 1, rank: 2)` and `(id: 2, rank: 1)`, with width two. The ex That is a history: a starting state followed by actions. The generator can produce many such histories while the same reference computes each answer. -The integration loop below is pseudocode; the named production driver operations are not TanStack APIs: +The integration loop below is pseudocode; the named production driver operations +are not TanStack APIs: ```text create independent model and production fixture @@ -727,7 +735,11 @@ After choosing an action kind, choose its key from the applicable set. This is c Shrinking means reducing a failing input or action list while keeping its failure. Design action dependencies so the smaller histories remain meaningful; don't disable shrinking merely because the test is stateful. The [fast-check model guide](https://fast-check.dev/docs/advanced/model-based-testing/) describes commands and replay; check the installed version before copying an API recipe. -Include fixed histories for the structural cases that matter: no rows, width zero, a boundary tie, and repeated changes to one key. Exhaust a small domain where that is cheap. Then randomize values and longer legal histories. Fixed cases, bounded enumeration and random exploration do different jobs; overlap between them is not a defect. +Include fixed histories for the structural cases that matter: no rows, width +zero, a boundary tie, and repeated changes to one key. Exhaust a small domain +where that is cheap. Then randomize values and longer legal histories. Fixed +cases, bounded enumeration and random exploration do different jobs; overlap +between them is not a defect. ### Run fixed and random campaigns with direct replay @@ -761,13 +773,14 @@ may differ. This keeps a random failure eligible for promotion into a fixed history or the fixed-seed campaign. [`fifo-retry-oracle.property.test.ts`](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) -shows this shape. Its fixed-seed campaign preserves one scheduler campaign. Its second -campaign uses a random campaign seed by default. To replay its recorded failure, set -`TANSTACK_DB_ORACLE_PROPERTY=offline.fifo-retry` with -`TANSTACK_DB_ORACLE_SEED` and `TANSTACK_DB_ORACLE_PATH`. The fixed-seed campaign stands -aside, and the random campaign runs the recorded path once. The offline -oracle owners' other properties use the same variables and name their own properties, -such as `offline.leadership.stale-read` and `offline.settlement.retry-decision`. +shows this shape. Its fixed-seed campaign preserves one scheduler campaign. Its +second campaign uses a random campaign seed by default. To replay its recorded +failure, set `TANSTACK_DB_ORACLE_PROPERTY=offline.fifo-retry` with +`TANSTACK_DB_ORACLE_SEED` and `TANSTACK_DB_ORACLE_PATH`. The fixed-seed campaign +stands aside, and the random campaign runs the recorded path once. The offline +oracle owners' other properties use the same variables and name their own +properties, such as `offline.leadership.stale-read` and +`offline.settlement.retry-decision`. Do not use a larger random run count as a substitute for structural reach. Fixed histories force rare boundaries. Bounded enumeration proves small finite @@ -874,7 +887,15 @@ test releases write delivery and lets production apply it test records the visible state at the promised applied checkpoint ``` -The fixture must really hold write delivery. If it delivers the writes and only delays returning from its load function, it controls a different event. Whether the returned Promise settles before or after application is a separate contract question. Record publications during the held interval if the promise concerns what users can see then. A scheduler is helpful only within the boundaries it controls; a campaign seed cannot schedule arbitrary external I/O. The [fast-check scheduler documentation](https://fast-check.dev/docs/advanced/race-conditions/) explains the distinction between an operation's lifecycle and delivery of its result. +The fixture must really hold write delivery. If it delivers the writes and only +delays returning from its load function, it controls a different event. Whether +the returned Promise settles before or after application is a separate contract +question. Record publications during the held interval if the promise concerns +what users can see then. A scheduler is helpful only within the boundaries it +controls; a campaign seed cannot schedule arbitrary external I/O. The +[fast-check scheduler documentation](https://fast-check.dev/docs/advanced/race-conditions/) +explains the distinction between an operation's lifecycle and delivery of its +result. ### Treat production driver timing helpers as premises @@ -883,13 +904,13 @@ blocked" or "let the message reach the worker". Each helper establishes a premise. If a helper does less than its name says, every production driver that uses it can go green without reaching the history. -In one [recorded case](oracle-reviews/pr-2213-opfs-close.md), a production driver loop -shared by the Node and Chromium production drivers gave back a lock in the same -turn in which it submitted close. The close message was still crossing a task -boundary, so the earlier request finished first. Close never ran during the -wait, and both worker mutants passed both production drivers. The loop had a -premise check for "a request waits for the lock", but it had none for "close -arrives during that wait". +In one [recorded case](oracle-reviews/pr-2213-opfs-close.md), a production +driver loop shared by the Node and Chromium production drivers gave back a lock +in the same turn in which it submitted close. The close message was still +crossing a task boundary, so the earlier request finished first. Close never ran +during the wait, and both worker mutants passed both production drivers. The +loop had a premise check for "a request waits for the lock", but it had none for +"close arrives during that wait". For each timing helper, do one of these: @@ -953,19 +974,19 @@ restate the expected results. The OPFS request-order oracle shows the layout. [`opfs-request-order-oracle.ts`](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts) -owns the law, the model, the fixed histories, the production driver loop, and the -comparison. The +owns the law, the model, the fixed histories, the production driver loop, and +the comparison. The [Node driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.test.ts) and the [Chromium page driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order.opfs.ts) supply the entry point, access-handle control, database names, and result reporting. The Node driver also generates random histories from the same -grammar. Neither production driver states expected results, and both feed the same -comparison. The +grammar. Neither production driver states expected results, and both feed the +same comparison. The [Chromium spec](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts) checks for page errors, a completed run, one result per example, and no -violations. It states no expected results either. A witness -that restates expected results needs the record that ORC-014 describes. +violations. It states no expected results either. A witness that restates +expected results needs the record that ORC-014 describes. ## Observe what the contract promises @@ -1054,7 +1075,10 @@ Original trace; reduced trace; reproduction result Primary failure; separate cleanup diagnostics ``` -Check replay commands against the installed tool. A campaign seed is not a recording of the network, operating system or every Promise. If a challenge influenced the design, keep it as a regression or tuning case; it is no longer a held-out test of that design. +Check replay commands against the installed tool. A campaign seed is not a +recording of the network, operating system or every Promise. If a challenge +influenced the design, keep it as a regression or tuning case; it is no longer a +held-out test of that design. ## Review and maintain the portfolio @@ -1141,8 +1165,8 @@ or a claimed repair, ask: Adapter and lifecycle oracles should consider these laws when the contract has the corresponding boundary. They are prompts, not universal requirements. State -why an inapplicable law does not belong to the oracle owner instead of adding a vacuous -case. +why an inapplicable law does not belong to the oracle owner instead of adding a +vacuous case. - **Real-provider conformance:** freeze representative values from each supported provider version. Prove the fixture accepts those values before it diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 0e22d52802..2f1a36f9e4 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -430,8 +430,8 @@ produce. The exported `compileQuery` receiver checks both compilation orders through FROM, JOIN and union placement at initial graph drain. Its raw output comparison is differential and supplies no independent Effect-root attribution policy. Optimizer cloning can prevent an original raw-key cache hit in union -placement; that path checks propagation and transparency instead. -Separate static receiving-type limits remain in the +placement; that path checks propagation and transparency instead. Separate +static receiving-type limits remain in the [deferred oracle record](../../../../../docs/contributing/oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md). D2 hashes enumerable symbol keys and uses exact local-symbol identity plus diff --git a/packages/db/tests/indexed-db/ORACLE.md b/packages/db/tests/indexed-db/ORACLE.md index dfe26817d2..66b6334372 100644 --- a/packages/db/tests/indexed-db/ORACLE.md +++ b/packages/db/tests/indexed-db/ORACLE.md @@ -42,12 +42,13 @@ membership. Empty-store delete first inserts. Import chooses empty or one-row replacement. Manual acceptance writes overlapping keys with distinct values in two stores. Every history ends with fresh restores. -The authored reconstruction history visits every action in both stores. -Fixed histories retain generated update/clear and repeated no-op histories. -Matrices reconstruct CRUD × decision, persistence-entry × bad-row-position, -and remote delete/clear × local rollback/commit. Transport cases retain the old +The authored reconstruction history visits every action in both stores. Fixed +histories retain generated update/clear and repeated no-op histories. Matrices +reconstruct CRUD × decision, persistence-entry × bad-row-position, and remote +delete/clear × local rollback/commit. Transport cases retain the old rapid/concurrent and routing coverage and add the twelve histories below. -Missing-key updates and duplicate imported keys have explicit rejection witnesses. +Missing-key updates and duplicate imported keys have explicit rejection +witnesses. Each axis contributes independently: @@ -204,12 +205,12 @@ introduced. ## Campaigns and direct replay -The normal test command runs the same property, generator, recorder, production driver and -30-run budget twice: fixed campaign seed 1179001, then no supplied campaign seed. Each campaign -reports its actual campaign seed, run count and outcome. Fast-check reports counterexample -and shrink path; verbose output retains failed histories. A reduced trace proves -its reported checkpoint; do not label a reduction a reproduction of a different -facet without replaying both. +The normal test command runs the same property, generator, recorder, production +driver and 30-run budget twice: fixed campaign seed 1179001, then no supplied +campaign seed. Each campaign reports its actual campaign seed, run count and +outcome. Fast-check reports counterexample and shrink path; verbose output +retains failed histories. A reduced trace proves its reported checkpoint; do not +label a reduction a reproduction of a different facet without replaying both. From this package directory: diff --git a/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md b/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md index a1451565b8..15c20edb89 100644 --- a/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md +++ b/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md @@ -138,8 +138,8 @@ accepted data-before-reset and reset-before-data in the protocol model. `electric-sdk-framing.test.ts` independently uses the real SDK with controlled HTTP responses. Both normal and stale-row-bearing 409 bodies produce singleton reset callbacks, as the generated protocol histories now require. This fixes the -installed SDK's HTTP reset path, not an exhaustive specification of every possible -server response or SSE path. +installed SDK's HTTP reset path, not an exhaustive specification of every +possible server response or SSE path. The synthetic partition property remains as extra adapter robustness coverage; it deliberately tests more callback shapes than the SDK-framed differential @@ -160,10 +160,10 @@ contiguous callback partition is tested. Expected rows follow tag membership and ordinary row updates, without consulting adapter transaction state. The bug was RED in two progressive fixed histories and the random property (seed -`-1632249566`, path `0:1`); seven fixed controls passed. Previous tag tests began -after readiness, while the initial-sync generators lacked tag move-outs followed -by later live work. This was also reproduced by the full service-backed Electric -E2E suite, not only a synthetic callback driver. +`-1632249566`, path `0:1`); seven fixed controls passed. Previous tag tests +began after readiness, while the initial-sync generators lacked tag move-outs +followed by later live work. This was also reproduced by the full service-backed +Electric E2E suite, not only a synthetic callback driver. Use this focused form while iterating: @@ -209,9 +209,10 @@ after user callbacks fence both the waiter loop and the remaining message batch. All three extensions have fixed-seed and random properties, with the shared oracle multiplier and seed/path replay controls. These are ordinary assertions, -not expected-failure classifiers. At `049cc9a5d`, fixed campaign seeds `42711`, `42712`, -and `42713` reproduce the three failures respectively. Binding and reentry use -epoch/ownership guards; persisted membership uses the approved refetch contract. +not expected-failure classifiers. At `049cc9a5d`, fixed campaign seeds `42711`, +`42712`, and `42713` reproduce the three failures respectively. Binding and +reentry use epoch/ownership guards; persisted membership uses the approved +refetch contract. The separate mixed `[insert, must-refetch, up-to-date]` report exposed the protocol-model gap corrected in section 12. It remains outside the verified diff --git a/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md b/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md index 486c0160ed..8198d182d5 100644 --- a/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md +++ b/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md @@ -39,9 +39,9 @@ metadata withdrawal, acquisition/fallback failure and healthy peer recovery. ## What the failures taught us The first candidate used the latest response's `hasMore` for all consumers and -treated missing metadata as false. It failed seven of nine tests. Campaign seed `863` -shrunk one failure to two source rows, backend page size one, a one-row window, -and missing metadata. It incorrectly announced the end. +treated missing metadata as false. It failed seven of nine tests. Campaign seed +`863` shrunk one failure to two source rows, backend page size one, a one-row +window, and missing metadata. It incorrectly announced the end. The tested repair has four rules: @@ -103,9 +103,9 @@ replacement for the production controller's lifecycle and error handling. ## Verification - First red: seven assertion failures, two passes; no setup failure or timeout. -- Final fixed-seed campaign: all 66 tests across both experiments passed. - The new no-peek property ran 15,000 histories at multiplier 100, campaign seed `863`; - the preceding cursor experiment ran another 35,000 generated cases. +- Final fixed-seed campaign: all 66 tests across both experiments passed. The + new no-peek property ran 15,000 histories at multiplier 100, campaign seed + `863`; the preceding cursor experiment ran another 35,000 generated cases. - A separate default random campaign covers the no-peek files. - Package TypeScript and lint on all experiment TypeScript passed. diff --git a/packages/query-db-collection/tests/cursor-pagination/README.md b/packages/query-db-collection/tests/cursor-pagination/README.md index bee1ff6e05..d7934dede5 100644 --- a/packages/query-db-collection/tests/cursor-pagination/README.md +++ b/packages/query-db-collection/tests/cursor-pagination/README.md @@ -101,8 +101,8 @@ observe production publications, not manufacture them in the fixture. retaining old rows. The Query-owned cache with shared-prefix invalidation passes backend growth, shrink, empty and regrowth plus later page expansion. - The frozen candidate passed 87 tests across five files with multiplier 100, - campaign seed 863: 35,000 cursor histories, 10,000 cache histories and 15,000 no-peek - experiment histories. The latter are retained experiments, not shipped + campaign seed 863: 35,000 cursor histories, 10,000 cache histories and 15,000 + no-peek experiment histories. The latter are retained experiments, not shipped metadata behavior. A loss audit and final package gates follow. The cache oracle uses a full source snapshot plus a freshness deadline, not a @@ -212,11 +212,11 @@ safety of removing peek-ahead or adding a public metadata API. unlimited read: the reader returned only the first row. - Negative control: removing the post-response cancellation/generation check failed both held-response abort and reset cases. The rejection control still - passed. Restoring the check restored green; these were assertion failures, - not timeouts. -- Final green: 48 tests, including 32 backend/UI page-size integration cells - and a held-response shared-consumer case. Four generated properties ran - 35,000 cases in total at multiplier 100 with campaign seed `863`. A separate normal + passed. Restoring the check restored green; these were assertion failures, not + timeouts. +- Final green: 48 tests, including 32 backend/UI page-size integration cells and + a held-response shared-consumer case. Four generated properties ran 35,000 + cases in total at multiplier 100 with campaign seed `863`. A separate normal random campaign also passed. - Package TypeScript check and lint on all changed TypeScript files passed. diff --git a/packages/trailbase-db-collection/tests/ORACLE.md b/packages/trailbase-db-collection/tests/ORACLE.md index 5dd13499c2..11b234f9c4 100644 --- a/packages/trailbase-db-collection/tests/ORACLE.md +++ b/packages/trailbase-db-collection/tests/ORACLE.md @@ -11,107 +11,105 @@ The same interpreter runs a fixed corpus and generated histories. ## Laws and scope - Published inserts, updates and deletes agree with the model after each event. -- Eager startup waits for its list; on-demand startup does not claim to load rows. +- Eager startup waits for its list; on-demand startup does not claim to load + rows. - A list starts only after the change subscription is established, in both - modes, so a change committed during or after a fetch reaches the listener. - An on-demand subset load that arrives before the listener runs waits until - `start` reports that the listener runs or cannot start. - Since #2115 a live query is ready when its subset load settles, not at the - source's `markReady()`, so the load itself must carry this ordering. The - wait still honors its caller: an abort rejects a waiting load with - `AbortError`, cleanup ends it quietly, and a listener that cannot start - rejects it with that failure instead of fetching. Because a load waits for - the listener and not for the subscription, it does not depend on the order - in which `start` and the load resume; a mutant that adds an await before - `start` subscribes is equivalent. + modes, so a change committed during or after a fetch reaches the listener. An + on-demand subset load that arrives before the listener runs waits until + `start` reports that the listener runs or cannot start. Since #2115 a live + query is ready when its subset load settles, not at the source's + `markReady()`, so the load itself must carry this ordering. The wait still + honors its caller: an abort rejects a waiting load with `AbortError`, cleanup + ends it quietly, and a listener that cannot start rejects it with that failure + instead of fetching. Because a load waits for the listener and not for the + subscription, it does not depend on the order in which `start` and the load + resume; a mutant that adds an await before `start` subscribes is equivalent. - The early-demand grammar checks that law with its own service model: `list` - answers from the service rows at the moment it is called, and a change - reaches only subscriptions that exist when it commits. Its 16 histories - cross the subscription outcome (listen, subscribe rejects, reader fails, - abort, cleanup), a service change committed before the subscription, and a - real live query or the core subset boundary. Checkpoints after the demand, - after the service change, after the outcome, and after a later change - through the listener compare list calls, load or query state, status, - first-ready rows, and current rows. - Required startup failures reject. Later stream failures retain rows and report - the error, matching the adapter's current policy. -- Once a page and every change event for its rows have arrived, the - Collection's rows equal the service's rows. A page was read before any - change that the listener applies while the page is in flight, so the page - skips rows that such an event touched. Every page row is written as a - full-row `update`, which creates a missing row and replaces a held one, so - a page never collides with an event that core accepted but still holds. The page-event grammar - enumerates 15 histories: eager startup or an on-demand load; an update, - delete, or insert; committed before the list call (on-demand only), while - the page is in flight, or after it applies. It compares the Collection's - rows with the service after the page settles and after a later change. A - generated grammar (`trailbase.page-event`) extends the same law: 258 service - rows read in two pages of 256, cursor or offset paging, one or two - overlapping on-demand loads, and up to four inserts, updates, and deletes - among page releases. After every step a changed key holds the service's - value; after the history drains, every returned or changed key does. Four - fixed histories cover an update between pages, an update then a delete during a - page, overlapping loads around an update, and offset paging with two - updates. After the history drains, the Collection holds exactly the rows - that a page returned or an event touched and that the service still has. - The page fence orders events by a sequence that each sync run counts, so a - fresh run never skips a row because of an earlier run's event. The fence - keeps per-key sequences only while a load is in flight and clears them when - the last load ends; no public observation measures that bound. + answers from the service rows at the moment it is called, and a change reaches + only subscriptions that exist when it commits. Its 16 histories cross the + subscription outcome (listen, subscribe rejects, reader fails, abort, + cleanup), a service change committed before the subscription, and a real live + query or the core subset boundary. Checkpoints after the demand, after the + service change, after the outcome, and after a later change through the + listener compare list calls, load or query state, status, first-ready rows, + and current rows. Required startup failures reject. Later stream failures + retain rows and report the error, matching the adapter's current policy. +- Once a page and every change event for its rows have arrived, the Collection's + rows equal the service's rows. A page was read before any change that the + listener applies while the page is in flight, so the page skips rows that such + an event touched. Every page row is written as a full-row `update`, which + creates a missing row and replaces a held one, so a page never collides with + an event that core accepted but still holds. The page-event grammar enumerates + 15 histories: eager startup or an on-demand load; an update, delete, or + insert; committed before the list call (on-demand only), while the page is in + flight, or after it applies. It compares the Collection's rows with the + service after the page settles and after a later change. A generated grammar + (`trailbase.page-event`) extends the same law: 258 service rows read in two + pages of 256, cursor or offset paging, one or two overlapping on-demand loads, + and up to four inserts, updates, and deletes among page releases. After every + step a changed key holds the service's value; after the history drains, every + returned or changed key does. Four fixed histories cover an update between + pages, an update then a delete during a page, overlapping loads around an + update, and offset paging with two updates. After the history drains, the + Collection holds exactly the rows that a page returned or an event touched and + that the service still has. The page fence orders events by a sequence that + each sync run counts, so a fresh run never skips a row because of an earlier + run's event. The fence keeps per-key sequences only while a load is in flight + and clears them when the last load ends; no public observation measures that + bound. - Events for a change committed before a list call can arrive after the page - applies, because the stream and the list are separate requests. An - `Insert` event is a full row, so the listener writes it as an `update`: a - late insert for a row that a page already holds no longer tears the stream - down with `DuplicateKeySyncError`. The page-event grammar crosses changes - committed before the list with delivery at once, while the page is in - flight, or after it applies, including an insert followed by an update; - the generated grammar delivers each change at once or later, in commit order. - Both check that a later change still reaches the listener. A late older - event shows its older value until the newer event that follows it arrives. - The adapter has no row version to order an event against a page, so the - grammars check a row only once every event for it has arrived. + applies, because the stream and the list are separate requests. An `Insert` + event is a full row, so the listener writes it as an `update`: a late insert + for a row that a page already holds no longer tears the stream down with + `DuplicateKeySyncError`. The page-event grammar crosses changes committed + before the list with delivery at once, while the page is in flight, or after + it applies, including an insert followed by an update; the generated grammar + delivers each change at once or later, in commit order. Both check that a + later change still reaches the listener. A late older event shows its older + value until the newer event that follows it arrives. The adapter has no row + version to order an event against a page, so the grammars check a row only + once every event for it has arrived. - A page never fails a load for an older event that core accepted but holds - while a user mutation persists. TrailBase mutation handlers wait for their - own change event, so core holds sync commits for the round trip of every - mutation. Four histories cross a persisting mutation (or none) with an - older insert or update event queued before the list call and a page that - lists a newer value. While the mutation holds commits the load stays - pending; once the newer event and the mutation settle, the load is - fulfilled and the rows equal the service. + while a user mutation persists. TrailBase mutation handlers wait for their own + change event, so core holds sync commits for the round trip of every mutation. + Four histories cross a persisting mutation (or none) with an older insert or + update event queued before the list call and a page that lists a newer value. + While the mutation holds commits the load stays pending; once the newer event + and the mutation settle, the load is fulfilled and the rows equal the service. - The adapter subscribes once per sync run and does not resubscribe. After the - stream closes or fails, the Collection keeps its rows and stays ready, a - later service change does not reach it, and a later on-demand load lists - the service's current rows without waiting. The Collection's node shows an - `error` from the end onward, with a stream failure as its cause, and only - `reload()` or a restart clears it. Reconnecting is left to the TrailBase - provider. A mutant that reports the end as a `failureReason` fails 29 - lifecycle cells. Fixed tests in `lifecycle.test.ts` (no oracle owner - (tracked in #2264)) cover the reload and aborted-load scenarios below. One - checks that a reload after a failed startup settles on the new subscription's outcome while the Collection still shows - `error`. Another, `a reload of a ready eager Collection`, enumerates 10 - histories (one or two pages; no change, an update, a delete, or an insert - between the runs, or an insert whose event arrives while the reload's last - page is held). The reload keeps the rows (maintainer decision F19: only a - snapshot or the run's own truncate replaces them), so the pages update the - kept rows and the eager load then deletes every held row that it did not - list and that no change event touched. While the last page is held, no - kept row the service still has disappears; after it, the reload resolves - and the rows equal the service's. On the parent core, whose reload replaced - every row at the new run's first commit, the two-page histories fail (the - second page's rows vanish while it is held). Mutants that skip the sweep or - let it delete an event-touched row fail the `delete` and `insert-event` - histories. `an unchanged row survives an on-demand reload` checks the - on-demand reload: replayed pages update the kept rows, an untouched row - survives, and a row the service deleted while no stream listened stays, - because core does not sweep after a reload (snapshot-api §8, issue #2269) - and a page cannot say a row is gone. `an aborted load writes no rows and -its late result changes nothing` checks that a subset load whose signal - aborts while its list is in flight rejects with `AbortError` and writes - no row when the list answers. Two histories make this limit - executable; they do not claim it is the right product behavior. They are - fixed histories of one stream end followed by one change and one load. A page whose newer - event never arrives because the stream ended keeps the value the fence - allowed; that follows from the same no-resubscribe limit and has no cell. + stream closes or fails, the Collection keeps its rows and stays ready, a later + service change does not reach it, and a later on-demand load lists the + service's current rows without waiting. The Collection's node shows an `error` + from the end onward, with a stream failure as its cause, and only `reload()` + or a restart clears it. Reconnecting is left to the TrailBase provider. A + mutant that reports the end as a `failureReason` fails 29 lifecycle cells. + Fixed tests in `lifecycle.test.ts` (no oracle owner (tracked in #2264)) cover + the reload and aborted-load scenarios below. One checks that a reload after a + failed startup settles on the new subscription's outcome while the Collection + still shows `error`. Another, `a reload of a ready eager Collection`, + enumerates 10 histories (one or two pages; no change, an update, a delete, or + an insert between the runs, or an insert whose event arrives while the + reload's last page is held). The reload keeps the rows (maintainer decision + F19: only a snapshot or the run's own truncate replaces them), so the pages + update the kept rows and the eager load then deletes every held row that it + did not list and that no change event touched. While the last page is held, no + kept row the service still has disappears; after it, the reload resolves and + the rows equal the service's. On the parent core, whose reload replaced every + row at the new run's first commit, the two-page histories fail (the second + page's rows vanish while it is held). Mutants that skip the sweep or let it + delete an event-touched row fail the `delete` and `insert-event` histories. + `an unchanged row survives an on-demand reload` checks the on-demand reload: + replayed pages update the kept rows, an untouched row survives, and a row the + service deleted while no stream listened stays, because core does not sweep + after a reload (snapshot-api §8, issue #2269) and a page cannot say a row is + gone. `an aborted load writes no rows and its late result changes nothing` + checks that a subset load whose signal aborts while its list is in flight + rejects with `AbortError` and writes no row when the list answers. Two + histories make this limit executable; they do not claim it is the right + product behavior. They are fixed histories of one stream end followed by one + change and one load. A page whose newer event never arrives because the stream + ended keeps the value the fence allowed; that follows from the same + no-resubscribe limit and has no cell. - Graceful closure drains buffered events before releasing the reader. - Processing failures cancel an open source. Terminal paths release readers and timers without detached rejections, including same-turn cleanup. @@ -127,29 +125,31 @@ its late result changes nothing` checks that a subset load whose signal - Error and detached-rejection observations are append-only through final gate release and cleanup. Epoch tags record when an observation arrives, not the hidden sync run that caused it. Exact console output checks cover the - adapter's - current compatibility policy, not a general service reporting contract. - -Histories contain one to three Collection sync runs, eager/on-demand modes, delayed startup -and list resolve/reject, zero to eight row edits, five stream endings, and -immediate versus settled cleanup. Thirty-two fixed histories cover the boundaries; -ordinary runs add 50 histories from a fixed-seed campaign and 50 from a random campaign, with shrinking. -Additional controls cover rejecting late cancellation in both modes and mutate -provider-owned list, event, and buffered rows before parsing. Model rows derive -separately from command scalars, so provider mutation cannot change the answer. - -This is not a model of filtered subsets, optimistic mutation -acknowledgements, or service reconnects. Offset paging takes updates only: an -insert or delete between pages moves rows across a position boundary at the -service, which the adapter cannot correct. An eager load cannot overlap an -on-demand one, because an eager source has no subset loader. The page-event grammars -use one change listener per run; more than two concurrent loads and pages -larger than two remain outside the generated grammar. -The generated event phase starts after loading; existing loading-time unit -matrices and the two held-cancellation startup controls remain valuable. Those -controls were both RED when listener failure awaited cancellation, and GREEN -when cancellation was observed separately. The lifecycle production driver calls the core subset boundary; -the early-demand grammar also drives a real live query. Service-backed E2E coverage remains separate. + adapter's current compatibility policy, not a general service reporting + contract. + +Histories contain one to three Collection sync runs, eager/on-demand modes, +delayed startup and list resolve/reject, zero to eight row edits, five stream +endings, and immediate versus settled cleanup. Thirty-two fixed histories cover +the boundaries; ordinary runs add 50 histories from a fixed-seed campaign and 50 +from a random campaign, with shrinking. Additional controls cover rejecting late +cancellation in both modes and mutate provider-owned list, event, and buffered +rows before parsing. Model rows derive separately from command scalars, so +provider mutation cannot change the answer. + +This is not a model of filtered subsets, optimistic mutation acknowledgements, +or service reconnects. Offset paging takes updates only: an insert or delete +between pages moves rows across a position boundary at the service, which the +adapter cannot correct. An eager load cannot overlap an on-demand one, because +an eager source has no subset loader. The page-event grammars use one change +listener per run; more than two concurrent loads and pages larger than two +remain outside the generated grammar. The generated event phase starts after +loading; existing loading-time unit matrices and the two held-cancellation +startup controls remain valuable. Those controls were both RED when listener +failure awaited cancellation, and GREEN when cancellation was observed +separately. The lifecycle production driver calls the core subset boundary; the +early-demand grammar also drives a real live query. Service-backed E2E coverage +remains separate. ## Run and replay @@ -223,18 +223,19 @@ The stream tears down, and a later change never arrives. A listener that writes `insert` fails both the single-page history and the generated grammar's named fixed history at that assertion. -The generated grammar and its four fixed histories all fail on the adapter before the -fence and on the fence-less mutant; both properties shrink to one update while -the first page is in flight. Always inserting survives that grammar, because a -page can only meet a different stored value after a missed event, which only -the stream-end grammar produces. A wall-clock fence failed the fresh-run cell: an -earlier run's event in the same millisecond hid the row from the new run. -The 10x campaign passed 800 generated page histories. +The generated grammar and its four fixed histories all fail on the adapter +before the fence and on the fence-less mutant; both properties shrink to one +update while the first page is in flight. Always inserting survives that +grammar, because a page can only meet a different stored value after a missed +event, which only the stream-end grammar produces. A wall-clock fence failed the +fresh-run cell: an earlier run's event in the same millisecond hid the row from +the new run. The 10x campaign passed 800 generated page histories. Before the stale-startup fix, both campaigns independently shrank to: cancel a -pending subscription, restart and load a healthy stream, reject the old subscribe. -The old startup canceled the replacement reader. The fixed corpus covers this in -both modes and sends further edits through the replacement stream. +pending subscription, restart and load a healthy stream, reject the old +subscribe. The old startup canceled the replacement reader. The fixed corpus +covers this in both modes and sends further edits through the replacement +stream. Five isolated source-transform mutations were each rejected by the fixed corpus: @@ -248,5 +249,5 @@ Five isolated source-transform mutations were each rejected by the fixed corpus: These targeted mutations test known failure classes, not overall mutation coverage or a proof of completeness. The 10× campaign passed 800 generated -histories (fixed campaign seed 714203, random campaign seed 127535183) plus the 32 corpus cases. -No service-backed E2E run is claimed. +histories (fixed campaign seed 714203, random campaign seed 127535183) plus the +32 corpus cases. No service-backed E2E run is claimed. From c75911cf60d3de6a66d68ea6e012bef529d02b6c Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 15:27:54 -0700 Subject: [PATCH 033/168] feat(oracles): probe hook takes a name and a condition `__oracleProbe(name, condition)` records whether a probe was reached and whether its probe-bound state was entered. Reach agreement compares the prediction with entry, and its failures say whether the probe was never reached or was reached with a false condition. Forced calibrations force entry, which also marks the probe reached. The pure-model lint rule now covers the `predict` part. Law: reach agreement compares predicted entry with observed entry. RED: the self-oracle failed 10 of 34 on the old hook (determinism). GREEN: 34 of 34. Mutant: agreement reading reached instead of entered fails 8 of 34. Co-authored-by: Isaac --- docs/contributing/glossary.md | 5 + .../oracle-property-self-oracle-fixture.ts | 98 ++++- .../tests/oracle-property-self-oracle.test.ts | 371 ++++++++++++++++-- packages/db/tests/oracle-property.ts | 342 ++++++++++++---- .../eslint-plugin-oracle/rules/pure-model.js | 12 +- .../seed-and-purity.test.js | 6 + 6 files changed, 736 insertions(+), 98 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 737aae8eaa..a8fc9b14e4 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -212,6 +212,11 @@ crossing subsystem boundaries; these comparisons are not interchangeable. | reach witness | Evidence that the production driver reached the claimed boundary, such as a real host, a provider call, or a protocol step. It concerns the production path. Compare reach, which concerns the history grammar. | | declared state | A state that a law depends on. The oracle property names it, and its reach is counted. | | reach | The number of histories that entered a declared state. A missed required reach fails the run. Reach counts states that were actually entered, not labels that the grammar assigns. Compare reach witness. | +| state probe | A development-only call in production code, `process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.('package#name', condition)`, at the point where production decides whether it enters a declared state. The name is a string literal; the condition is a side-effect-free expression that is truthy when production enters the state. The declared state names the probe as `{ name, probe }`. The model and the check never see its hits. Compare a coverage probe, which only has to be hit. | +| probe reached | A state probe was called in a history's window, whatever its condition. Compare entered. | +| entered (probe-bound state) | A probe-bound declared state is entered in a history when its state probe was called with a truthy condition in that history's window. Compare probe reached: a probe can be reached without entering its state. Compare reach, which counts the histories that entered a declared state. | +| prediction | What the property's `predict` says, from the history alone, about whether a history enters each probe-bound declared state. It is part of the model and never reads the trace. | +| reach agreement | The law that a history's predicted entries of probe-bound declared states match the probes' entries, in both directions. A probe-bound state counts as reached only where they agree. A predicted entry that did not happen says whether its probe was never reached or was reached with a false condition. A call after the history's window closed is an error, because the driver must await the work it starts. | | versioned review record | Review evidence stored at a stable location with the exact reviewed head and append-only entries, so later edits cannot silently change the recorded verdict. | | mutant | A deliberate wrong answer or implementation used to test oracle sensitivity: a corrupted trace, a hostile production driver, or a production code change. Do not call it a hostile mutant; every mutant is hostile. | | calibration | A test that applies a mutant and requires the check to report a violation of the named law. A calibration skips a candidate whose unaltered trace already fails. | diff --git a/packages/db/tests/oracle-property-self-oracle-fixture.ts b/packages/db/tests/oracle-property-self-oracle-fixture.ts index 305a45878e..0b0ac0e0e1 100644 --- a/packages/db/tests/oracle-property-self-oracle-fixture.ts +++ b/packages/db/tests/oracle-property-self-oracle-fixture.ts @@ -19,6 +19,17 @@ * property's `reached` part or, as the framework's default, through the * driver's `observe("reach", state)`. A `slow` spec's driver takes about * 300 ms per history, so a small `timeout` fails every test that runs one. + * A spec with a `probe` binds `saw-0` to the production probe `synthetic#saw-0` + * and predicts that a history enters it when it holds a 0. The driver calls + * that probe first, with a condition: `agree` calls it with `true` when the + * history holds a 0 and `false` otherwise, `always` calls it with `true` in + * every history, `never` calls it with `false` in every history, `unreached` + * never calls it, and `late` calls it as `agree` does and once more, with + * `true`, from a timer it does not await. With `overReport`, the spec also reports `saw-0` + * as reached in every history, so only the agreement can keep its count + * right. `predict` is passed when the spec binds a probe, unless + * `predictMode` is `missing`; with `extra` it is passed although no state is + * bound. * * The synthetic ids come from a fixed pool, registered in oracle-config. */ @@ -66,6 +77,14 @@ export type SyntheticSpec = { timeout?: number /** Registers under an id no oracle configuration knows. */ unregistered?: boolean + /** How the driver calls the probe bound to `saw-0`; absent, no probe. */ + probe?: `agree` | `always` | `never` | `unreached` | `late` + /** Reports `saw-0` as reached in every history. */ + overReport?: boolean + /** Calls an unbound probe on every run, entering it on every other one, so only determinism sees it. */ + noisyProbe?: boolean + /** `missing` omits `predict` for a bound probe; `extra` passes it unbound. */ + predictMode?: `missing` | `extra` } /** The synthetic language: one to three items, mostly ordinary ones. */ @@ -78,10 +97,37 @@ export const syntheticArbitrary: fc.Arbitrary = fc.array( ) const allStates = [`saw-0`, `saw-1`, `saw-2`] +const syntheticProbe = `synthetic#saw-0` let nonce = 0 +let noise = 0 -const reachedStates = (history: SyntheticHistory) => +const enteredStates = (history: SyntheticHistory) => allStates.filter((state) => history.includes(Number(state.slice(4)))) +const reachedStates = (spec: SyntheticSpec) => (history: SyntheticHistory) => + spec.overReport + ? [...new Set([...enteredStates(history), `saw-0`])] + : enteredStates(history) + +/** + * The condition the synthetic driver passes to the probe for this history, or + * undefined when it makes no call. + */ +const probeCondition = ( + spec: SyntheticSpec, + history: SyntheticHistory, +): boolean | undefined => { + switch (spec.probe) { + case `always`: + return true + case `never`: + return false + case `agree`: + case `late`: + return history.includes(0) + default: + return undefined + } +} /** * The synthetic driver. `mutant` records a 3 in place of the first item, the @@ -99,8 +145,35 @@ function syntheticDriver(spec: SyntheticSpec, mutant = false) { `${JSON.stringify({ id: spec.id, test: expect.getState().currentTestName ?? ``, history })}\n`, ) if (spec.slow) await new Promise((resolve) => setTimeout(resolve, 300)) + const condition = probeCondition(spec, history) + if (condition !== undefined) + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`synthetic#saw-0`, condition) + if (spec.noisyProbe) + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`synthetic#noise`, noise++ % 2 === 0) + // Production work the driver does not await, calling its probe after its history. + if (spec.probe === `late` && history.includes(0)) + setTimeout( + () => + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`synthetic#saw-0`, true), + 0, + ) if (spec.reachVia === `observe`) - for (const state of reachedStates(history)) observe(`reach`, state) + for (const state of reachedStates(spec)(history)) observe(`reach`, state) for (const [index, item] of history.entries()) { observe(`item ${index}`, mutant && index === 0 ? 3 : item) if (item === 6) @@ -162,7 +235,15 @@ export function syntheticProperty(spec: SyntheticSpec): void { const parts = { model: () => undefined, // Without `reached`, the framework counts the driver's `reach` records. - ...(spec.reachVia === `reached` ? { reached: reachedStates } : {}), + ...(spec.reachVia === `reached` ? { reached: reachedStates(spec) } : {}), + ...((spec.probe && spec.predictMode !== `missing`) || + spec.predictMode === `extra` + ? { + predict: (history: SyntheticHistory) => ({ + 'saw-0': history.includes(0), + }), + } + : {}), driver: syntheticDriver(spec), calibrations: syntheticCalibrations(spec), ...(spec.timeout !== undefined ? { timeout: spec.timeout } : {}), @@ -185,13 +266,20 @@ export function syntheticProperty(spec: SyntheticSpec): void { }) return } + // With a probe, `saw-0` is a probe-bound state wherever it is declared. + const declare = (state: string) => + spec.probe && state === `saw-0` + ? { name: state, probe: syntheticProbe } + : state oracleProperty({ id, law, histories: { arbitrary: syntheticArbitrary, - states: spec.states, - measured: allStates.filter((state) => !spec.states.includes(state)), + states: spec.states.map(declare), + measured: allStates + .filter((state) => !spec.states.includes(state)) + .map(declare), runs: spec.source.runs, fixedSeeds: [spec.source.seed], constructed: spec.constructed.map(({ name, prefix, enters }) => ({ diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts index e54581a282..e9d1674abb 100644 --- a/packages/db/tests/oracle-property-self-oracle.test.ts +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -26,6 +26,21 @@ * mutant driver) fails with the named law. * - `determinism`: the determinism test runs every fixed and constructed history * and three generated ones, and fails for a driver whose trace changes. + * - `agreement`: for a probe-bound state, a history whose `predict` says it + * enters the state while the history does not enter it, or does not enter + * it while the history does, violates law `reach agreement` at checkpoint + * `states`, after a driver error and a law violation and before a + * constructed history's missed state. A history enters the state when its + * probe was called with a truthy condition, and a probe is reached when it + * was called at all. A predicted entry that fails says why: the probe was + * never reached, or it was reached with a false condition. The state counts + * as reached only where the prediction and the entry agree, whatever the + * property's own reach reports. The property registers two forced-entry + * calibrations, which pass exactly when a candidate whose clean trace passes + * disagrees with the forced entry. A probe-bound state without `predict`, or + * `predict` without one, fails to load. A call after its history ended is an + * unhandled error of the run, and a driver whose probe records differ between + * two runs fails the determinism test. * - `timeout`: a property's `timeout` limits every test it registers. * - `replay`: replaying a failing campaign's seed and path runs only that * property's random campaign, starts from the counterexample itself, and @@ -35,7 +50,8 @@ * property, the seed it runs, and no other test logs one; a replay tool * reads that line. * - * Authority: the contract in the header of `oracle-property.ts`; the + * Authority: the contract in the header of `oracle-property.ts`, including + * its state probes (user decision of 2026-11-04 adopting differential reach); the * maintainer decisions of 2026-11-03 on calibration candidates, failure * precedence, and determinism scope; and the oracle rubric's replay * (ORC-007), calibration (ORC-006), and declared-reach (ORC-015) rules. @@ -46,7 +62,9 @@ * (its constructed histories) first, in order. A replay or rerun probe covers * every failing campaign but only the first violating fixed or enumerated history of each * property. The gap report's observation hook, `forwardCut`, `withinTurns`, - * and `differs` have no law here. + * and `differs` have no law here. Only generated sources bind a probe, and only + * `saw-0`. A late probe is judged by whether the run reports it, not by which + * test Vitest blames. */ import { spawnSync } from 'node:child_process' import { @@ -96,6 +114,11 @@ type Draft = { calibrations: Array<(typeof calibrationKinds)[number]> /** An id no oracle configuration registers. */ unregistered: boolean + /** How the driver calls the probe bound to `saw-0`; `none` binds no probe. */ + probe: `none` | `agree` | `always` | `never` | `unreached` | `late` + overReport: boolean + noisyProbe: boolean + predictMode: `match` | `missing` | `extra` } type Suite = { drafts: Array } @@ -169,6 +192,34 @@ const draft: fc.Arbitrary = fc.record({ { weight: 9, arbitrary: fc.constant(false) }, { weight: 1, arbitrary: fc.constant(true) }, ), + probe: fc.oneof( + { weight: 5, arbitrary: fc.constant(`none` as const) }, + { weight: 2, arbitrary: fc.constant(`agree` as const) }, + { + weight: 2, + arbitrary: fc.constantFrom( + `always` as const, + `never` as const, + `unreached` as const, + `late` as const, + ), + }, + ), + overReport: fc.oneof( + { weight: 4, arbitrary: fc.constant(false) }, + { weight: 1, arbitrary: fc.constant(true) }, + ), + noisyProbe: fc.oneof( + { weight: 9, arbitrary: fc.constant(false) }, + { weight: 1, arbitrary: fc.constant(true) }, + ), + predictMode: fc.oneof( + { weight: 12, arbitrary: fc.constant(`match` as const) }, + { + weight: 1, + arbitrary: fc.constantFrom(`missing` as const, `extra` as const), + }, + ), }) const suite: fc.Arbitrary = fc.record({ drafts: fc.array(draft, { minLength: 1, maxLength: 2 }), @@ -221,6 +272,10 @@ function resolveSuite({ drafts }: Suite): Plan { nondeterministic: d.nondeterministic, reachVia: d.reachVia, ...(d.unregistered ? { unregistered: true } : {}), + ...(generated && d.probe !== `none` ? { probe: d.probe } : {}), + ...(d.overReport ? { overReport: true } : {}), + ...(d.noisyProbe ? { noisyProbe: true } : {}), + ...(d.predictMode !== `match` ? { predictMode: d.predictMode } : {}), ...(d.timing === `slow` ? { slow: true, timeout: 100 } : {}), calibrations: d.calibrations.map((kind, at) => ({ name: `cal-${at}`, @@ -258,11 +313,15 @@ type Kind = | `timeout` type Outcome = { kind: Kind - law?: `three` | `four` + law?: `three` | `four` | `reach agreement` /** A violation whose driver threw afterwards carries that error. */ cause?: `driver` + /** The checkpoint a framework-made violation names. */ + checkpoint?: string + /** For a predicted entry that did not happen: whether the probe was called. */ + diagnosis?: `never-reached` | `false-condition` } -type LogEntry = { id: string; test: string; history: SyntheticHistory } +type LogEntry ={ id: string; test: string; history: SyntheticHistory } const allowed = (item: number) => [0, 1, 2, 3, 4, 6].includes(item) const member = (history: SyntheticHistory) => @@ -285,8 +344,90 @@ function verdictOf(history: SyntheticHistory): Outcome { } const statesOf = (history: SyntheticHistory) => allStates.filter((state) => history.includes(Number(state.slice(4)))) -const reaches = (history: SyntheticHistory, states: ReadonlyArray) => - states.every((state) => statesOf(history).includes(state)) +const reaches = ( + spec: SyntheticSpec, + history: SyntheticHistory, + states: ReadonlyArray, +) => states.every((state) => reachedOf(spec, history).includes(state)) +// Model law for a probe-bound `saw-0`: the model predicts entry when the +// history holds a 0. The synthetic driver calls its probe by its mode (the +// fixture says how), with a condition. A history enters the state when the +// probe is called with a truthy condition; the probe is reached when it is +// called at all. A disagreement violates `reach agreement` once the history has +// no driver error and no law violation of its own. +const agreementLaw = `reach agreement` +const syntheticProbe = `synthetic#saw-0` +const predictsZero = (history: SyntheticHistory) => history.includes(0) +// The condition of the driver's call in this history, or undefined when it +// makes no call. +const callFor = (spec: SyntheticSpec, history: SyntheticHistory) => { + switch (spec.probe) { + case `always`: + return true + case `never`: + return false + case `agree`: + case `late`: + return predictsZero(history) + default: + return undefined + } +} +// A forced calibration forces entry, and a forced entry is a call with that +// condition, so it is reached. +const reachedFor = ( + spec: SyntheticSpec, + history: SyntheticHistory, + forced?: boolean, +) => forced !== undefined || callFor(spec, history) !== undefined +const enteredFor = ( + spec: SyntheticSpec, + history: SyntheticHistory, + forced?: boolean, +) => forced ?? callFor(spec, history) === true +function judged( + spec: SyntheticSpec, + history: SyntheticHistory, + forced?: boolean, +): Outcome { + const verdict = verdictOf(history) + if (verdict.kind !== `pass` || !spec.probe) return verdict + const predicts = predictsZero(history) + if (enteredFor(spec, history, forced) === predicts) + return verdict + // A predicted entry that did not happen says whether the probe was called. + return { + kind: `violation`, + law: agreementLaw, + checkpoint: `states`, + ...(predicts + ? { + diagnosis: reachedFor(spec, history, forced) + ? `false-condition` + : `never-reached`, + } + : {}), + } +} +// A probe-bound `saw-0` counts only where prediction and entry agree. An +// over-reporting spec claims `saw-0` in every history; only the agreement can +// take that claim back. +const reachedOf = (spec: SyntheticSpec, history: SyntheticHistory) => + [ + ...new Set([...statesOf(history), ...(spec.overReport ? [`saw-0`] : [])]), + ].filter( + (state) => + !spec.probe || + state !== `saw-0` || + (predictsZero(history) && enteredFor(spec, history)), + ) +const forcedCalibrationNames = (spec: SyntheticSpec) => + spec.probe + ? [true, false].map( + (entered) => + `calibration: probe ${syntheticProbe} ${entered ? `forced entered` : `forced not entered`} (state "saw-0")`, + ) + : [] const label = (history: SyntheticHistory) => `items ${history.join(` | `)}` const isCampaign = (name: string) => name.startsWith(`fixed seed`) || name === `random seed` @@ -320,6 +461,13 @@ function predictSpec( const generated = spec.source.kind === `generated` // Every source, enumerated or generated, must name a registered property. if (spec.unregistered) return { loads: false } + // `predict` and a probe-bound state come together, and only a generated + // source declares states. + if ( + (spec.probe && spec.predictMode === `missing`) || + (!spec.probe && spec.predictMode === `extra`) + ) + return { loads: false } if (generated) { const declared = new Set(spec.states) const entered = new Set( @@ -344,6 +492,7 @@ function predictSpec( const all = [ ...names, ...spec.calibrations.map(({ name }) => `calibration: ${name}`), + ...forcedCalibrationNames(spec), `determinism`, ] // A slow driver outlives the 100 ms limit in every test that runs a @@ -357,7 +506,7 @@ function predictSpec( // the corruption applies to every candidate. const calibrate = (name: string, kind: (typeof calibrationKinds)[number]) => { const used = ran(`calibration: ${name}`).find( - (history) => verdictOf(history).kind === `pass`, + (history) => judged(spec, history).kind === `pass`, ) if (!used) return { kind: `calibration-none` } as Outcome if (kind === `right` || kind === `driver`) @@ -371,11 +520,14 @@ function predictSpec( // fails it even without fixed or constructed histories. const determinism = (): Outcome => { const histories = ran(`determinism`).filter((_, at) => at % 2 === 0) + // An unbound probe that enters on every other run changes only the probe + // records a run keeps, which the determinism test also compares. + const varies = spec.nondeterministic || spec.noisyProbe === true for (const history of histories) { if (history.includes(6)) return { kind: `setup` } - if (spec.nondeterministic) return { kind: `determinism` } + if (varies) return { kind: `determinism` } } - if (spec.nondeterministic && generated) return { kind: `determinism` } + if (varies && generated) return { kind: `determinism` } return { kind: `pass` } } // A driver error decides a history first, then a law violation, then a @@ -389,10 +541,10 @@ function predictSpec( ) return { kind: `constructed-membership` } for (const [at, history] of ran(test).entries()) { - const { cause: _shrinkable, ...verdict } = verdictOf(history) + const { cause: _shrinkable, ...verdict } = judged(spec, history) if (verdict.kind !== `pass`) return verdict const construction = spec.constructed[at] - if (construction && !reaches(history, construction.enters)) + if (construction && !reaches(spec, history, construction.enters)) return { kind: `constructed-reach` } } return { kind: `pass` } @@ -404,14 +556,14 @@ function predictSpec( tests.set(names[0]!, campaign(names[0]!)) tests.set(`random seed`, campaign(`random seed`)) for (const fixedHistory of spec.fixed) { - const verdict = verdictOf(fixedHistory.history) + const verdict = judged(spec, fixedHistory.history) tests.set( `fixed history: ${fixedHistory.name}`, !member(fixedHistory.history) ? { kind: `fixed-membership` } : verdict.kind !== `pass` ? verdict - : !reaches(fixedHistory.history, fixedHistory.enters ?? []) + : !reaches(spec, fixedHistory.history, fixedHistory.enters ?? []) ? { kind: `fixed-reach` } : { kind: `pass` }, ) @@ -419,6 +571,22 @@ function predictSpec( } for (const { name, kind } of spec.calibrations) tests.set(`calibration: ${name}`, calibrate(name, kind)) + // A forced-probe calibration needs a candidate whose clean trace passes and + // whose prediction differs from the forced probe; the check must then report + // reach agreement. + for (const [at, name] of forcedCalibrationNames(spec).entries()) { + const forced = at === 0 + tests.set( + name, + ran(name).some( + (history) => + judged(spec, history).kind === `pass` && + predictsZero(history) !== forced, + ) + ? { kind: `pass` } + : { kind: `calibration-none` }, + ) + } tests.set(`determinism`, determinism()) // The determinism and calibration tests build constructed histories too, so a // constructed history outside the language fails them the same way it fails a campaign. @@ -441,8 +609,9 @@ function predictSpec( Object.fromEntries( allStates.map((state) => [ state, - ran(name).filter((history) => statesOf(history).includes(state)) - .length, + ran(name).filter((history) => + reachedOf(spec, history).includes(state), + ).length, ]), ), ) @@ -480,6 +649,17 @@ function suiteModel( return new Map(plan.specs.map((spec) => [spec.id, predictSpec(spec, logged)])) } +// A late spec calls its probe again, unawaited, after every history holding a +// 0 that a loaded property ran, and each such firing throws. +const lateProbeOwed = (plan: Plan, logged: ReadonlyArray) => + plan.specs.some( + (spec) => + spec.probe === `late` && + spec.predictMode !== `missing` && + !spec.unregistered && + logged.some((entry) => entry.id === spec.id && entry.history.includes(0)), + ) + // The self-oracle's own reach: states of the framework that its laws are // about. Each fixed history names the state it witnesses, so every one is reached on // every run; campaigns count them. @@ -501,6 +681,13 @@ const frameworkStates = [ `observed-reach`, `timeout`, `random-campaign`, + `agreement-violation`, + `agreement-never-reached`, + `agreement-false-condition`, + `reached-not-entered`, + `forced-probe-calibration-applied`, + `late-probe-error`, + `over-reported-reach`, ] as const function frameworkReach( @@ -526,6 +713,16 @@ function frameworkReach( if (outcome.kind === `timeout`) reached.add(`timeout`) if (name === `random seed`) reached.add(`random-campaign`) if (outcome.kind === `determinism`) reached.add(`determinism-caught`) + if (outcome.law === agreementLaw) reached.add(`agreement-violation`) + if (outcome.diagnosis === `never-reached`) + reached.add(`agreement-never-reached`) + if (outcome.diagnosis === `false-condition`) + reached.add(`agreement-false-condition`) + if ( + forcedCalibrationNames(spec).includes(name) && + outcome.kind === `pass` + ) + reached.add(`forced-probe-calibration-applied`) const failure = { 'constructed-reach': `constructed-reach-failure`, 'fixed-reach': `fixed-reach-failure`, @@ -548,6 +745,24 @@ function frameworkReach( if (spec.reachVia === `observe` && owed.reach.size > 0) reached.add(`observed-reach`) } + if (lateProbeOwed(plan, run?.log ?? [])) reached.add(`late-probe-error`) + // A history whose probe is reached without entering the state passes the + // agreement wherever the model predicts no entry, so the state is reached. + for (const spec of plan.specs) + if ( + spec.probe && + expected.get(spec.id)!.loads && + (run?.log ?? []).some( + (entry) => + entry.id === spec.id && + reachedFor(spec, entry.history) && + !enteredFor(spec, entry.history), + ) + ) + reached.add(`reached-not-entered`) + for (const spec of plan.specs) + if (spec.overReport && spec.probe && expected.get(spec.id)!.loads) + reached.add(`over-reported-reach`) if (trace.some(({ checkpoint }) => checkpoint === `replay`)) reached.add(`replay-probe`) if (trace.some(({ checkpoint }) => checkpoint === `rerun`)) @@ -584,10 +799,16 @@ type ReportedFile = { errors: Array tests: Array } -type Report = { files: Array; stdout: string } +type Report = { + files: Array + stdout: string + unhandled: Array +} type Failure = { kind: Kind | `other` law?: string + checkpoint?: string + diagnosis?: `never-reached` | `false-condition` cause?: `driver` counterexample?: string seed?: number @@ -610,6 +831,8 @@ type SuiteRun = { } > log: Array + /** The run's unhandled errors, by message. */ + unhandled: Array } type ProbeRun = { id: string @@ -645,6 +868,12 @@ function classify(error: ReportedError | undefined): Failure { const counterexample = /Counterexample: (.*)/.exec(outer)?.[1] const rerun = /Rerun: -t ("(?:[^"\\]|\\.)*")/.exec(text)?.[1] const law = /\(law: ([^)]+)\)/.exec(text)?.[1] + const checkpoint = /violated at checkpoint "([^"]+)"/.exec(text)?.[1] + const diagnosis = /was never reached/.test(text) + ? (`never-reached` as const) + : /was reached with a false condition/.test(text) + ? (`false-condition` as const) + : undefined const cause = inner.name === `OracleViolation` && (inner.cause?.message ?? ``).includes(`synthetic driver failed`) @@ -679,6 +908,8 @@ function classify(error: ReportedError | undefined): Failure { return { kind, ...(law ? { law } : {}), + ...(checkpoint ? { checkpoint } : {}), + ...(diagnosis ? { diagnosis } : {}), ...(cause ? { cause } : {}), ...(seed !== undefined ? { seed: Number(seed) } : {}), ...(path !== undefined ? { path } : {}), @@ -732,10 +963,10 @@ function runVitest( ) // The framework prints reach counts straight to stdout, past Vitest's // console capture, so they arrive in the child's own output. - const { files: reported } = JSON.parse(readFileSync(report, `utf8`)) as { - files: Array - } - return { files: reported, stdout: run.stdout } + const { files: reported, unhandled } = JSON.parse( + readFileSync(report, `utf8`), + ) as { files: Array; unhandled?: Array } + return { files: reported, stdout: run.stdout, unhandled: unhandled ?? [] } } const short = (id: string, name: string) => name.replace(`${id} > `, ``) @@ -775,7 +1006,11 @@ async function driveSuite( return file }) const report = runVitest(directory, files, { SELF_ORACLE_LOG: log }) - const run: SuiteRun = { files: {}, log: readLog(log) } + const run: SuiteRun = { + files: {}, + log: readLog(log), + unhandled: report.unhandled.map((error) => error.message ?? ``), + } for (const [index, spec] of plan.specs.entries()) { const reported = report.files.find((file) => file.file === files[index]) // A passing campaign prints ` reach {...}`. @@ -940,6 +1175,9 @@ function checkSuite( if ( observedKind !== outcome.kind || (outcome.law !== undefined && failure?.law !== outcome.law) || + (outcome.checkpoint !== undefined && + failure?.checkpoint !== outcome.checkpoint) || + failure?.diagnosis !== outcome.diagnosis || (!isCampaign(test.name) && failure?.cause !== outcome.cause) ) return violation( @@ -990,7 +1228,7 @@ function checkSuite( } if (failure?.counterexample && outcome.law) { const shrunk = JSON.parse(failure.counterexample) as [SyntheticHistory] - if (verdictOf(shrunk[0]).law !== outcome.law) + if (judged(spec, shrunk[0]).law !== outcome.law) return violation( `run`, `${spec.id} "${test.name}" reported counterexample ${failure.counterexample}, which does not violate law ${outcome.law}`, @@ -1033,6 +1271,18 @@ function checkSuite( `rerun`, ) } + // A late probe surfaces as an unhandled error of the run. + const late = run.unhandled.some((message) => + message.includes(`was called after its history ended`), + ) + if (late !== lateProbeOwed(plan, run.log)) + return violation( + `run`, + late + ? `the run reported a late probe that no late spec owed` + : `a late spec called its probe after its history, but the run reported no error`, + `agreement`, + ) for (const { checkpoint, probe } of probes) { const probed = checkpoint === `replay` ? `random seed` : probe.target const others = probe.states.filter( @@ -1098,6 +1348,10 @@ const generatedDraft = (overrides: Partial): Draft => ({ timing: `normal`, calibrations: [], unregistered: false, + probe: `none`, + overReport: false, + noisyProbe: false, + predictMode: `match`, ...overrides, }) const fixedHistory = ( @@ -1238,6 +1492,79 @@ oracleProperty({ constructed: [{ prefix: [0, 0, 0], enters: [`saw-0`] }], }), ), + fixedHistory( + `an agreeing state probe passes and its forced calibrations apply`, + [`forced-probe-calibration-applied`], + generatedDraft({ probe: `agree`, fixed: [at([0]), at([1])] }), + ), + fixedHistory( + `a probe entered without the prediction violates reach agreement`, + [`agreement-violation`], + generatedDraft({ probe: `always`, fixed: [at([1])] }), + ), + fixedHistory( + `a predicted state reached with a false condition violates reach agreement`, + [`agreement-violation`, `agreement-false-condition`], + generatedDraft({ + probe: `never`, + constructed: [{ prefix: [0], enters: [`saw-0`] }], + }), + ), + fixedHistory( + `a predicted state whose probe is never reached violates reach agreement`, + [`agreement-violation`, `agreement-never-reached`], + generatedDraft({ + probe: `unreached`, + constructed: [{ prefix: [0], enters: [`saw-0`] }], + }), + ), + fixedHistory( + `a probe reached with a false condition is not entered`, + [`reached-not-entered`], + generatedDraft({ probe: `agree`, fixed: [at([1])] }), + ), + fixedHistory( + `a law violation outranks a probe that disagrees`, + [`campaign-violation`], + generatedDraft({ probe: `always`, fixed: [at([3])] }), + ), + fixedHistory( + `only agreement counts a probe-bound state as reached`, + [`over-reported-reach`, `fixed-reach-failure`], + generatedDraft({ + probe: `agree`, + overReport: true, + fixed: [{ history: [1], enters: [`saw-0`] }], + }), + ), + fixedHistory( + `a probe-bound state needs predict`, + [`registration-error`], + generatedDraft({ probe: `agree`, predictMode: `missing` }), + ), + fixedHistory( + `predict needs a probe-bound state`, + [`registration-error`], + generatedDraft({ predictMode: `extra` }), + ), + fixedHistory( + `predict on an enumerated source fails to load`, + [`registration-error`], + { + ...generatedDraft({ predictMode: `extra` }), + source: { kind: `enumerated`, histories: [[0]] }, + }, + ), + fixedHistory( + `the determinism test compares the probes a driver records`, + [`determinism-caught`], + generatedDraft({ noisyProbe: true, fixed: [at([1])] }), + ), + fixedHistory( + `a probe called after its history ended is an error`, + [`late-probe-error`], + generatedDraft({ probe: `late`, fixed: [at([0])] }), + ), fixedHistory( `a property timeout limits every test it registers`, [`timeout`], diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 3ab0fa4482..1b744b8c6e 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -53,6 +53,28 @@ * can name, such as "a failure while the seed is active", comes from * `reached(history, trace, expected)` instead. Each campaign prints how many * histories entered each declared state, including none (ORC-015). + * - A **state probe** binds a declared state, written `{ name, probe }` in + * `states` or `measured`, to a production probe. Production calls + * `process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.("package#name", condition)` + * where it decides whether it enters that state; `condition` is truthy when + * production enters it. The framework collects the calls each history makes + * inside that history's async context, and never shows them to the model or + * the check. Two laws read them. A probe is **reached** in a history when + * it was called at all, whatever its condition. A probe-bound state is + * **entered** in a history when its probe was called with a truthy + * condition. `predict(plan)` says, from the plan alone, whether the history + * enters each probe-bound state. A history whose prediction disagrees with + * its entries, in either direction, violates law `reach agreement` at + * checkpoint `states`, after a driver error and the check's own violation, + * so it shrinks and replays like any violation. A predicted entry that does + * not happen says why: its probe was never reached, or it was reached with a + * false condition, which need different fixes. A probe-bound state counts as + * reached only where the prediction and the entry agree, whatever `reached` + * or the driver's `reach` records say. Two calibrations per probe force its + * entry to be true and to be false; the check must reject both. The + * determinism test compares the probe calls each run makes. A call that + * arrives after its history's driver settled throws where it happens: the + * driver must await the work it starts. * - A **constructed history** is built from a generated history so that it * reaches states that the laws need, and it names those declared states. * Every campaign runs every constructed history once as an example, and a @@ -105,6 +127,7 @@ * oracle test whose parts are its arguments, when its options are an object * literal with a `check`. The property is its own declaration. */ +import { AsyncLocalStorage } from 'node:async_hooks' import { appendFileSync } from 'node:fs' import { isDeepStrictEqual } from 'node:util' import fc from 'fast-check' @@ -328,6 +351,13 @@ export interface ConstructedHistory { build: (generated: History) => History } +/** + * A declared state: its name, or its name and the production probe that marks + * it (`package#name`). A probe-bound state's reach is the agreement between + * the property's `predict` and the probe's entries. + */ +export type DeclaredState = string | { name: string; probe: string } + export interface GeneratedHistories { arbitrary: fc.Arbitrary /** @@ -335,12 +365,12 @@ export interface GeneratedHistories { * constructed history, so every campaign reaches it. Each campaign reports * how many histories entered each one, including none. */ - states?: ReadonlyArray + states?: ReadonlyArray /** * States that are counted but not required. A reached state outside * `states` and `measured` fails, so a misspelled state cannot pass unseen. */ - measured?: ReadonlyArray + measured?: ReadonlyArray /** The base run budget of each campaign, scaled by the run multiplier. */ runs: number /** One fixed-seed campaign per seed, beside the random campaign. */ @@ -402,6 +432,11 @@ export interface OraclePropertyOptions { expected: Expected, ) => Verdict calibrations?: ReadonlyArray> + /** + * Whether the history enters each probe-bound state, from the plan alone: + * a prediction never sees the trace, so it cannot follow production. + */ + predict?: (plan: Plan) => Readonly> /** Each registered test's limit in milliseconds; 120 seconds by default. */ timeout?: number } @@ -410,11 +445,72 @@ declare global { // Installed by the gap report's test setup, absent otherwise. var __oracleObserve: ((checkpoint: string, value: unknown) => void) | undefined + // Production probes call this in development builds; see `probes`. + var __oracleProbe: + | ((name: string, condition: unknown) => void) + | undefined + // Installed by the gap report's test setup, absent otherwise. It receives + // every law violation the framework raises, so a mutant's kill is + // classified by the law that rejected it rather than by its message. + // Installed by the gap report's test setup, absent otherwise. Each + // registered test reports its route before it runs. + var __oracleRoute: ((route: string) => void) | undefined + var __oracleVerdict: + | ((verdict: { property: string; law: string; checkpoint: string }) => void) + | undefined +} + +/** The probe calls one history made: which probes it reached and which it entered. */ +interface ProbeRecord { + reached: ReadonlySet + entered: ReadonlySet +} +/** A history's probe record, open until its driver settles. */ +interface ProbeWindow { + reached: Set + entered: Set + open: boolean } +const probeWindows = new AsyncLocalStorage() +// Outside any history, such as in an ordinary test, a probe does nothing. A +// call that arrives after its history's driver settled throws where it +// arrives: the driver did not await work that production started. +globalThis.__oracleProbe = (name, condition) => { + const window = probeWindows.getStore() + if (!window) return + if (!window.open) + throw new Error( + `probe ${name} was called after its history ended. The driver must await the work it starts, so every probe call belongs to one history.`, + ) + window.reached.add(name) + if (condition) window.entered.add(name) +} + +/** A calibration's forced entry: the probe is reached, and its entry is forced to the stated value. */ +interface ForcedProbe { + probe: string + entered: boolean +} +const agreementLaw = `reach agreement` + +/** A property's options with the probes its declared states bind, by state. */ +type BoundOptions = OraclePropertyOptions< + History, + Expected, + Plan +> & { probes?: ReadonlyMap } + +const stateName = (state: DeclaredState) => + typeof state === `string` ? state : state.name /** One test that a property registers. */ interface PropertyTest { name: string + /** + * The test's route, which the gap report reads to tell a reproducible kill + * from a random-only one or a framework check (`__oracleRoute`). + */ + route: string run: () => Promise /** Path replays this test runs during; empty stands aside during every one. */ replays: ReadonlyArray @@ -427,6 +523,10 @@ export function oracleProperty( // entries exist; an enumerated source has no random campaign to check it. assertRegisteredOracleProperty(options.id) const { id, histories } = options + if (Array.isArray(histories) && options.predict) + throw new Error( + `${id}: \`predict\` needs probe-bound states, which only a generated source declares.`, + ) recordRegistration(id, options.law, histories) // The tests are built as data first, so every one registers unconditionally // (oracle/no-disabled). @@ -437,7 +537,10 @@ export function oracleProperty( for (const test of tests) oracleReplayTest(it, ...test.replays)( test.name, - test.run, + () => { + globalThis.__oracleRoute?.(test.route) + return test.run() + }, options.timeout ?? 120_000, ) }) @@ -474,6 +577,7 @@ function enumeratedTest( const name = options.label?.(history) ?? JSON.stringify(history) return { name, + route: `enumerated`, replays: [], run: async () => { const { verdict } = await runOne(options, history) @@ -498,8 +602,24 @@ async function recordTrace( plan: Plan, ): Promise> { const observations: Array = [] - await driver(plan, (checkpoint, value) => { - observations.push({ checkpoint, value }) + const window: ProbeWindow = { + reached: new Set(), + entered: new Set(), + open: true, + } + await probeWindows.run(window, () => + driver(plan, (checkpoint, value) => { + observations.push({ checkpoint, value }) + }), + ) + window.open = false + // The probe calls a history makes are part of what a deterministic driver repeats. + observations.push({ + checkpoint: `probes`, + value: { + reached: [...window.reached].sort(), + entered: [...window.entered].sort(), + }, }) return observations } @@ -529,11 +649,12 @@ function traceText(value: unknown): string { } function determinismTest( - options: OraclePropertyOptions, + options: BoundOptions, histories: () => ReadonlyArray<{ name: string; history: History }>, ): PropertyTest { return { name: `determinism`, + route: `determinism`, replays: [], run: async () => { for (const { name, history } of histories()) { @@ -572,17 +693,22 @@ function determinismTest( /** Runs one history: the driver records, the model predicts, the check judges. */ async function runOne( - options: OraclePropertyOptions, + options: BoundOptions, history: History, driver: OraclePropertyOptions< History, Expected, Plan >[`driver`] = options.driver, + forced?: ForcedProbe, ): Promise<{ verdict: Verdict; reached: Set }> { const plan = planOf(options, history) - const { observations, driverError } = await recordGuarded(driver, plan) - return judge(options, plan, observations, driverError) + const { observations, driverError, probes } = await recordGuarded( + driver, + plan, + forced, + ) + return judge(options, plan, observations, driverError, probes) } /** Runs the driver and keeps an error that escapes it beside the trace. */ @@ -592,34 +718,56 @@ async function recordGuarded( observe: (checkpoint: string, value: unknown) => void, ) => Promise | void, plan: Plan, + forced?: ForcedProbe, ): Promise<{ observations: Array driverError?: { error: unknown } + probes: ProbeRecord }> { const observations: Array = [] + const window: ProbeWindow = { + reached: new Set(), + entered: new Set(), + open: true, + } + const close = (): ProbeRecord => { + window.open = false + // A forced entry is a call with that condition, so the probe is reached. + if (forced) { + window.reached.add(forced.probe) + if (forced.entered) window.entered.add(forced.probe) + else window.entered.delete(forced.probe) + } + return window + } try { - await driver(plan, (checkpoint, value) => { - observations.push({ checkpoint, value }) - globalThis.__oracleObserve?.(checkpoint, value) - }) + await probeWindows.run(window, () => + driver(plan, (checkpoint, value) => { + observations.push({ checkpoint, value }) + globalThis.__oracleObserve?.(checkpoint, value) + }), + ) } catch (error) { - return { observations, driverError: { error } } + return { observations, driverError: { error }, probes: close() } } - return { observations } + return { observations, probes: close() } } /** The model predicts and the check judges a recorded trace. */ function judge( - options: OraclePropertyOptions, + options: BoundOptions, plan: Plan, observations: ReadonlyArray, driverError?: { error: unknown }, + probes: ProbeRecord = { reached: new Set(), entered: new Set() }, ): { verdict: Verdict; reached: Set } { let expected: Expected let verdict: Verdict + let predicted: Readonly> = {} try { expected = options.model(plan, observations) verdict = options.check(plan, observations, expected) + predicted = options.predict?.(plan) ?? {} } catch (error) { // A partial trace the model or check cannot read is still a setup error. throw driverError ? driverError.error : error @@ -640,6 +788,22 @@ function judge( .filter((entry) => entry.checkpoint === `reach`) .map((entry) => String(entry.value)), ) + // Agreement: the model's prediction and the probes' entries name the same + // probe-bound states. Only an agreed entry counts as reach. + for (const [state, probe] of options.probes ?? []) { + const predicts = predicted[state] === true + const entered = probes.entered.has(probe) + reached.delete(state) + if (predicts && entered) reached.add(state) + if (predicts === entered || verdict.kind === `violation`) continue + verdict = violation( + `states`, + predicts + ? `the model predicts that this history enters "${state}", but probe ${probe} ${probes.reached.has(probe) ? `was reached with a false condition` : `was never reached`}` + : `probe ${probe} was entered, but the model predicts that this history does not enter "${state}"`, + agreementLaw, + ) + } return { verdict, reached } } @@ -651,56 +815,84 @@ const lawOf = (verdict: Extract) => * wrong answer applies, and requires the check to report the named law. */ function calibrationTests( - options: OraclePropertyOptions, + options: BoundOptions, candidates: () => ReadonlyArray<{ name: string; history: History }>, ): Array { const { id } = options - return (options.calibrations ?? []).map((calibration) => ({ - name: `calibration: ${calibration.name}`, - replays: [], - run: async () => { - const seen: Array = [] - for (const { name, history } of candidates()) { - const plan = planOf(options, history) - if (`corrupt` in calibration) { - const { observations, driverError } = await recordGuarded( - options.driver, - plan, - ) - const corrupted = calibration.corrupt(observations, plan) - if (corrupted === undefined) continue - // A calibration is meaningful only where the true trace passes, so - // a candidate whose clean trace fails, or whose driver throws, is - // skipped; that history fails in its own test. + // Each probe-bound state also gets two forced-entry calibrations. + const forcedCalibrations = [...(options.probes ?? [])].flatMap( + ([state, probe]) => + [true, false].map((entered) => ({ + name: `probe ${probe} forced ${entered ? `entered` : `not entered`} (state "${state}")`, + law: agreementLaw, + forced: { probe, entered }, + })), + ) + return [...(options.calibrations ?? []), ...forcedCalibrations].map( + (calibration) => ({ + name: `calibration: ${calibration.name}`, + route: `forced` in calibration ? `probe calibration` : `calibration`, + replays: [], + run: async () => { + const seen: Array = [] + for (const { name, history } of candidates()) { + const plan = planOf(options, history) + if (`corrupt` in calibration) { + const { observations, driverError, probes } = await recordGuarded( + options.driver, + plan, + ) + const corrupted = calibration.corrupt(observations, plan) + if (corrupted === undefined) continue + // A calibration is meaningful only where the true trace passes, so + // a candidate whose clean trace fails, or whose driver throws, is + // skipped; that history fails in its own test. + if ( + driverError || + judge(options, plan, observations, undefined, probes).verdict + .kind !== `pass` + ) + continue + const wrong = judge( + options, + plan, + corrupted, + undefined, + probes, + ).verdict + if (wrong.kind === `violation` && lawOf(wrong) === calibration.law) + return + throw new Error( + `${id}: calibration "${calibration.name}" corrupted the trace of "${name}", but the check ${wrong.kind === `pass` ? `passed it` : `reported law "${lawOf(wrong)}"`}. It must report law "${calibration.law}".`, + ) + } + // The real driver's trace of the candidate must pass first. + const clean = await recordGuarded(options.driver, plan) if ( - driverError || - judge(options, plan, observations).verdict.kind !== `pass` + clean.driverError || + judge(options, plan, clean.observations, undefined, clean.probes) + .verdict.kind !== `pass` ) continue - const wrong = judge(options, plan, corrupted).verdict - if (wrong.kind === `violation` && lawOf(wrong) === calibration.law) - return - throw new Error( - `${id}: calibration "${calibration.name}" corrupted the trace of "${name}", but the check ${wrong.kind === `pass` ? `passed it` : `reported law "${lawOf(wrong)}"`}. It must report law "${calibration.law}".`, - ) + const { verdict } = + `forced` in calibration + ? await runOne( + options, + history, + options.driver, + calibration.forced, + ) + : await runOne(options, history, calibration.driver) + if (verdict.kind === `pass`) continue + if (lawOf(verdict) === calibration.law) return + seen.push(`"${name}" reported law "${lawOf(verdict)}"`) } - // The real driver's trace of the candidate must pass first. - const clean = await recordGuarded(options.driver, plan) - if ( - clean.driverError || - judge(options, plan, clean.observations).verdict.kind !== `pass` + throw new Error( + `${id}: calibration "${calibration.name}" found no history whose clean trace passes and where the check reports law "${calibration.law}"${seen.length ? `; ${seen.slice(0, 3).join(`, `)}` : ``}. Add a passing fixed or constructed history that reaches the state its wrong answer changes.`, ) - continue - const { verdict } = await runOne(options, history, calibration.driver) - if (verdict.kind === `pass`) continue - if (lawOf(verdict) === calibration.law) return - seen.push(`"${name}" reported law "${lawOf(verdict)}"`) - } - throw new Error( - `${id}: calibration "${calibration.name}" found no history whose clean trace passes and where the check reports law "${calibration.law}"${seen.length ? `; ${seen.slice(0, 3).join(`, `)}` : ``}. Add a passing fixed or constructed history that reaches the state its wrong answer changes.`, - ) - }, - })) + }, + }), + ) } function failure( @@ -710,6 +902,11 @@ function failure( rerun: string, ): OracleViolation { const { id, law } = options + globalThis.__oracleVerdict?.({ + property: id, + law: lawOf(verdict), + checkpoint: verdict.checkpoint, + }) const error = new OracleViolation( [ `${id} violated at checkpoint "${verdict.checkpoint}"${verdict.law ? ` (law: ${verdict.law})` : ``}.`, @@ -737,21 +934,32 @@ function rerunByName(name: string): string { } function generatedTests( - options: OraclePropertyOptions, + unbound: OraclePropertyOptions, source: GeneratedHistories, ): Array { - const { id } = options + const { id } = unbound + const declaredStates = [...(source.states ?? []), ...(source.measured ?? [])] + const probes = new Map( + declaredStates.flatMap((state) => + typeof state === `string` ? [] : [[state.name, state.probe] as const], + ), + ) + if (probes.size > 0 !== (unbound.predict !== undefined)) + throw new Error( + `${id}: a probe-bound state needs \`predict\`, and \`predict\` needs a probe-bound state. Declare the state as { name, probe }.`, + ) + const options: BoundOptions = + probes.size > 0 ? { ...unbound, probes } : unbound + const required = (source.states ?? []).map(stateName) const constructions = source.constructed ?? [] const declared = source.states || source.measured - ? new Set([...(source.states ?? []), ...(source.measured ?? [])]) + ? new Set(declaredStates.map(stateName)) : undefined const named = new Set( constructions.flatMap((construction) => construction.enters), ) - const unconstructed = (source.states ?? []).filter( - (state) => !named.has(state), - ) + const unconstructed = required.filter((state) => !named.has(state)) if (unconstructed.length > 0) throw new Error( `${id}: no constructed history enters ${unconstructed.join(`, `)}. A required state needs a constructed history; a state that is only counted belongs in \`measured\`.`, @@ -770,8 +978,7 @@ function generatedTests( ) for (const construction of constructions) { const undeclared = construction.enters.filter( - (state) => - declared !== undefined && !(source.states ?? []).includes(state), + (state) => declared !== undefined && !required.includes(state), ) if (undeclared.length > 0) throw new Error( @@ -868,6 +1075,7 @@ function generatedTests( } const fixedCampaigns = source.fixedSeeds.map((seed): PropertyTest => ({ name: `fixed seed ${seed}`, + route: `fixed seed`, replays: [], run: () => runCampaign(`fixed seed ${seed}`, seed, (examples) => @@ -881,6 +1089,7 @@ function generatedTests( // rebuilds the same constructed histories, which the replay path counts. const randomCampaign: PropertyTest = { name: `random seed`, + route: `random seed`, replays: [id], run: () => { // One call selects and logs the seed; a replay reads that log line. @@ -892,6 +1101,7 @@ function generatedTests( } const fixedTests = (source.fixed ?? []).map((fixed): PropertyTest => ({ name: `fixed history: ${fixed.name}`, + route: `fixed history`, replays: [], run: async () => { if (!source.arbitrary.canShrinkWithoutContext(fixed.history)) diff --git a/scripts/eslint-plugin-oracle/rules/pure-model.js b/scripts/eslint-plugin-oracle/rules/pure-model.js index 17e1cb4951..ad758300f0 100644 --- a/scripts/eslint-plugin-oracle/rules/pure-model.js +++ b/scripts/eslint-plugin-oracle/rules/pure-model.js @@ -1,11 +1,11 @@ /** * An `oracleProperty` keeps its parts' jobs separate (oracle rubric, ORC-003): * the driver touches production and the environment, and the model, the - * check, and the reach classifier are functions of the history and the - * recorded trace. A model that reads a clock or draws a random number gives a + * check, the reach classifier, and the state prediction are functions of the + * history and the recorded trace. A model that reads a clock or draws a random number gives a * different expectation for the same history, so a failure stops replaying * and the expectation is no longer independent of when it ran. This rule - * reports a `model`, `check`, or `reached` part that reads a clock, draws + * reports a `model`, `check`, `reached`, or `predict` part that reads a clock, draws * ambient randomness, or schedules a timer, directly or through a function * declared in the same file. * @@ -16,7 +16,7 @@ import { findVariable } from '../registration.js' -const pureParts = new Set([`model`, `check`, `reached`]) +const pureParts = new Set([`model`, `check`, `reached`, `predict`]) const ambientMembers = new Set([ `Date.now`, `Math.random`, @@ -156,7 +156,9 @@ export default { ? `verdict` : part === `reached` ? `set of reached states` - : `expectation`, + : part === `predict` + ? `state prediction` + : `expectation`, }, }) } diff --git a/scripts/eslint-plugin-oracle/seed-and-purity.test.js b/scripts/eslint-plugin-oracle/seed-and-purity.test.js index 30362060a8..58f838edd3 100644 --- a/scripts/eslint-plugin-oracle/seed-and-purity.test.js +++ b/scripts/eslint-plugin-oracle/seed-and-purity.test.js @@ -77,5 +77,11 @@ ruleTester.run(`pure-model`, pureModel, { `const reached = () => { setTimeout(() => {}); return [] }; oracleProperty({ id: 'x', model: () => 1, check: () => pass, reached })`, errors: [{ messageId: `impure` }], }, + { + code: + header + + `oracleProperty({ id: 'x', model: () => 1, check: () => pass, predict: () => ({ s: Math.random() > 0.5 }) })`, + errors: [{ messageId: `impure` }], + }, ], }) From e0608c959ac97b0f27b369a34894f967468b2703 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 15:28:15 -0700 Subject: [PATCH 034/168] feat(lint): require the decided state-probe form in production source `oracle/probe-form` accepts only `process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.(name, condition)` in production files, with a literal `package#name` and a side-effect-free condition. The gap report leaves out exactly that form as probe code, so any other shape is judged as production. Its oracle, `lint.probe-form`, generates probe shapes and checks the rule against a model of the form. Co-authored-by: Isaac --- eslint.config.js | 1 + scripts/eslint-plugin-oracle/index.js | 18 + .../probe-form-oracle.property.test.js | 478 ++++++++++++++++++ .../eslint-plugin-oracle/rules/probe-form.js | 184 +++++++ 4 files changed, 681 insertions(+) create mode 100644 scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js create mode 100644 scripts/eslint-plugin-oracle/rules/probe-form.js diff --git a/eslint.config.js b/eslint.config.js index 8a93d67d2f..8e54c76a66 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -48,4 +48,5 @@ export default [ }, }, oraclePlugin.configs.recommended, + oraclePlugin.configs.production, ] diff --git a/scripts/eslint-plugin-oracle/index.js b/scripts/eslint-plugin-oracle/index.js index e06ecf1273..87d3d94d8b 100644 --- a/scripts/eslint-plugin-oracle/index.js +++ b/scripts/eslint-plugin-oracle/index.js @@ -1,5 +1,6 @@ import noDisabled from './rules/no-disabled.js' import noUnitTest from './rules/no-unit-test.js' +import probeForm from './rules/probe-form.js' import pureModel from './rules/pure-model.js' import replayableSeed from './rules/replayable-seed.js' @@ -30,6 +31,7 @@ const plugin = { 'no-disabled': noDisabled, 'no-unit-test': noUnitTest, 'pure-model': pureModel, + 'probe-form': probeForm, 'replayable-seed': replayableSeed, }, } @@ -46,6 +48,22 @@ plugin.configs = { 'oracle/replayable-seed': `error`, }, }, + // Production source, not oracle files: a state probe is the only development + // code that the production build must not carry (AGENTS.md, probe form). + production: { + name: `oracle/production`, + files: [ + `packages/*/src/**/*.ts`, + `packages/*/src/**/*.tsx`, + `packages/*/src/**/*.js`, + `packages/*/src/**/*.jsx`, + `packages/*/src/**/*.mjs`, + ], + plugins: { oracle: plugin }, + rules: { + 'oracle/probe-form': `error`, + }, + }, } export default plugin diff --git a/scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js b/scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js new file mode 100644 index 0000000000..cbdf902ccf --- /dev/null +++ b/scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js @@ -0,0 +1,478 @@ +/** + * Law (lint rule `oracle/probe-form`): a state probe in production source is + * one expression statement of this exact form, and every other mention of + * `__oracleProbe` is reported: + * + * process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.(name, condition) + * + * The guard is the comparison on the left of `&&`, so a production build drops + * the probe. The guard's `'production'` may be written with any of the three + * quote styles, because the repository writes strings with backticks. The + * receiver is `globalThis`, or `globalThis` cast with `as`. The name is a + * string literal or a template literal without substitutions, and it names a + * state as `package#name`. The statement has exactly two arguments, + * and the condition contains no assignment, update, `new`, `delete`, `await`, + * `yield`, or function or class expression. A call is allowed in a condition: + * a lint cannot prove that a call is pure, which is a limit of this law. + * + * Authority: the maintainer's decision on the probe form, which replaces the + * `devBuild()` guard ("this is a standalone framework now so we can't use the + * devBuild() stuff", "our default lint forces the guard"), and the state probe + * entry in docs/contributing/glossary.md, which names the call + * `globalThis.__oracleProbe?.("package#name")`. AGENTS.md "Lint feedback while + * you work" requires that an oracle-owned shortcut is reported, not disabled. + * + * Structure, following ORC-003: + * 1. The grammar draws a program of sites. Each site has a kind: a legal + * probe, or one near-miss that breaks exactly one part of the form. + * 2. The model predicts the reports from the drawn kinds alone. A legal + * site has no report. A near-miss has one report for each mention of + * `__oracleProbe` on its line. The model does not read the rule. + * 3. The drivers render the program as one TypeScript function, one site per + * line, and lint it on two receiving paths: the repository's root ESLint + * config, which is what CI and `pnpm lint` use, and the plugin's + * `production` config alone. The root config also lints the same program + * under a test file name, which must report nothing: the rule covers + * production source only. + * 4. The comparison checks that each path's reports equal the prediction, + * as sorted (line, message) pairs. + * 5. A census lints every kind and every pool value once. Random campaigns + * draw programs with fixed and random seeds. Declared reach fails when a + * legal form or a near-miss kind is never generated. + */ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import tsParser from '@typescript-eslint/parser' +import { ESLint, Linter } from 'eslint' +import fc from 'fast-check' +import { afterAll, describe, expect, it } from 'vitest' +import { declareOracle } from '../../packages/db/tests/oracle-declaration.js' +import { + oraclePropertyOptions, + oracleRuns, +} from '../../packages/db/tests/oracle-config.js' +import { createOracleReach } from '../../packages/db/tests/oracle-reach.js' +import oraclePlugin from './index.js' + +const PROPERTY = `lint.probe-form` +const RULE = `oracle/probe-form` +const MESSAGE_ID = `probeForm` +const FIXED_SEED = 2234 +const BASE_RUNS = 40 +const repoRoot = fileURLToPath(new URL(`../../`, import.meta.url)) +// Neither file has to exist: ESLint lints the text under this name. The +// production name must match the production glob, and the test name must not. +// Both sit under packages/db, whose tsconfig includes `src` and `tests`, so +// the typed rules of the root config can parse them. +const PRODUCTION_FILE = join(repoRoot, `packages/db/src/probe-form-scratch.ts`) +const TEST_FILE = join(repoRoot, `packages/db/tests/probe-form-scratch.test.ts`) + +// -- Kinds: the model's spec ------------------------------------------------- +// +// A kind is legal, or it is a near-miss that breaks one part of the form. The +// table below is the law's spec: the grammar draws kinds from it, the model +// predicts reports from it, and the renderer below writes each kind's text. +// `mentions` counts the `__oracleProbe` references that a near-miss writes: a +// cast receiver adds one, the cast's type member. + +const castMentions = (site) => 1 + (site.cast ? 1 : 0) +const one = () => 1 +const two = () => 2 + +const MODEL = { + probe: { legal: true, mentions: () => 0 }, + 'if-guard': { legal: false, mentions: castMentions }, + 'devbuild-and-guard': { legal: false, mentions: castMentions }, + 'devbuild-only': { legal: false, mentions: castMentions }, + 'no-guard': { legal: false, mentions: castMentions }, + 'or-guard': { legal: false, mentions: castMentions }, + 'equal-guard': { legal: false, mentions: castMentions }, + 'reversed-guard': { legal: false, mentions: castMentions }, + 'computed-env-guard': { legal: false, mentions: castMentions }, + 'optional-receiver': { legal: false, mentions: one }, + 'non-optional-call': { legal: false, mentions: castMentions }, + 'one-argument': { legal: false, mentions: castMentions }, + 'three-arguments': { legal: false, mentions: castMentions }, + 'spread-argument': { legal: false, mentions: castMentions }, + 'bad-condition': { legal: false, mentions: castMentions }, + 'bad-name': { legal: false, mentions: castMentions }, + 'receiver-window': { legal: false, mentions: one }, + 'cast-other-receiver': { legal: false, mentions: two }, + 'double-cast': { legal: false, mentions: two }, + 'computed-key': { legal: false, mentions: castMentions }, + alias: { legal: false, mentions: castMentions }, + 'declared-type-key': { legal: false, mentions: one }, + 'variable-initializer': { legal: false, mentions: castMentions }, + sequence: { legal: false, mentions: castMentions }, + ternary: { legal: false, mentions: castMentions }, +} + +// -- 1. Grammar --------------------------------------------------------------- +// +// Each site draws every pool, and its kind decides which pools apply. The +// pools are small on purpose: the census enumerates them, and the random +// campaigns combine them. + +const QUOTES = [`'`, `"`, '`'] + +// Legal names, as written without their quotes. Each is `package#name`. +const LEGAL_NAMES = [ + `db#multiset.consolidate`, + `db-ivm#multiset`, + `offline-transactions#key-scheduler.retry`, + `db#indexedDb.open`, +] + +// Illegal names, as raw source. Each breaks the name form: no `#`, an empty +// package or name, an uppercase package, a second `#`, a space, or an +// expression instead of a literal. +const BAD_NAMES = [ + `'probe'`, + `'db#'`, + `'#name'`, + `'Db#multiset'`, + `'db#multiset#x'`, + `'db#name with space'`, + `nameVar`, + '`db#${name}`', +] + +// Conditions that the rule must accept. Some contain calls, which are allowed. +const LEGAL_CONDITIONS = [ + { text: `flag`, calls: false }, + { text: `input > 2`, calls: false }, + { text: `helper(input)`, calls: true }, + { text: `obj.value !== undefined`, calls: false }, + { text: `flag ? 1 : 0`, calls: false }, + { text: `typeof flag === 'boolean'`, calls: false }, + { text: `[input].length > 0`, calls: false }, + { text: `(flag || input === 3)`, calls: false }, + { text: `input?.toString() !== ''`, calls: true }, + { text: `probeCount(input, flag)`, calls: true }, + { text: `!flag`, calls: false }, + { text: `-input`, calls: false }, + { text: `void 0`, calls: false }, +] + +// Each expression is a forbidden form: an assignment, an update, `new`, +// `delete`, `await`, `yield`, a function, an arrow, or a class expression. +// The generated program is an async generator, so `await` and `yield` parse. +const FORBIDDEN_CONDITIONS = [ + `(flag = true)`, + `(input += 1)`, + `(input++)`, + `(--input)`, + `new Set()`, + `delete obj.key`, + `(await flag)`, + `(yield input)`, + `(() => flag)()`, + `(function () { return flag })()`, + `(class {})`, +] + +// A forbidden form can sit inside a larger condition, which the rule must +// still find. `X` marks where the form goes. +const WRAPS = [`X`, `helper(X)`, `[X]`, `{ key: X }`, `flag ? X : 0`, `(X, 0)`] + +const CAST = `(globalThis as { __oracleProbe?: (name: string, condition: unknown) => void })` + +const quoteArb = fc.constantFrom(...QUOTES) +const siteArbitrary = fc.record({ + kind: fc.constantFrom(...Object.keys(MODEL)), + quote: quoteArb, + cast: fc.boolean(), + paren: fc.boolean(), + guardParens: fc.boolean(), + name: fc.constantFrom(...LEGAL_NAMES), + badName: fc.constantFrom(...BAD_NAMES), + cond: fc.constantFrom(...LEGAL_CONDITIONS), + badCond: fc.constantFrom(...FORBIDDEN_CONDITIONS), + wrap: fc.constantFrom(...WRAPS), +}) + +// A program is a list of sites. Each site is one line in the rendered function. +const programArbitrary = fc + .array(siteArbitrary, { minLength: 1, maxLength: 6 }) + .map((sites) => ({ sites })) + +// The census gives every kind every pool value once, by index, so the +// declared reach does not depend on random draws. It is a fixed history, and +// it runs through the same model, drivers, and comparison. +function censusProgram() { + const sites = [] + const width = Math.max( + LEGAL_NAMES.length, + BAD_NAMES.length, + LEGAL_CONDITIONS.length, + FORBIDDEN_CONDITIONS.length, + WRAPS.length, + QUOTES.length, + 2, + ) + for (const kind of Object.keys(MODEL)) { + for (let i = 0; i < width; i++) { + sites.push({ + kind, + quote: QUOTES[i % QUOTES.length], + cast: i % 2 === 0, + paren: i % 3 === 0, + guardParens: i % 2 === 1, + name: LEGAL_NAMES[i % LEGAL_NAMES.length], + badName: BAD_NAMES[i % BAD_NAMES.length], + cond: LEGAL_CONDITIONS[i % LEGAL_CONDITIONS.length], + badCond: FORBIDDEN_CONDITIONS[i % FORBIDDEN_CONDITIONS.length], + wrap: WRAPS[i % WRAPS.length], + }) + } + } + return { sites } +} + +// -- 2. Model ----------------------------------------------------------------- +// +// The model is the spec, written from the law. A kind is legal or it is not, +// and a near-miss predicts reports on its own line. `mentions` counts the +// references to `__oracleProbe` that the site writes, which the grammar knows +// from the kind and the cast flag. A legal probe exempts its own mentions: the +// callee property, and the cast's type member when it casts. + +// The header is line 1, so site `index` is on line `index + 2`. +const siteLine = (index) => index + 2 + +function predictReports(program) { + return program.sites.flatMap((site, index) => { + const form = MODEL[site.kind] + if (form.legal) return [] + return Array.from({ length: form.mentions(site) }, () => ({ + line: siteLine(index), + messageId: MESSAGE_ID, + })) + }) +} + +// The root config's scope: production source has the rule as an error, and a +// test file has it off. Production source is `packages/*/src`, as the +// production config's globs say. +function predictSeverity(filePath) { + return filePath.includes(`/src/`) ? `error` : `off` +} + +// -- 3. Drivers --------------------------------------------------------------- + +const guardText = (site) => + `process.env.NODE_ENV !== ${site.quote}production${site.quote}` +const receiverText = (site) => { + if (!site.cast) return `globalThis` + return site.paren ? `(${CAST})` : CAST +} +const nameText = (site) => `${site.quote}${site.name}${site.quote}` +const legalCall = (site) => + `${receiverText(site)}.__oracleProbe?.(${nameText(site)}, ${site.cond.text})` +const withBadCondition = (site) => site.wrap.split(`X`).join(site.badCond) + +// Each kind renders one site, on one line, ending in a semicolon. +const RENDER = { + probe: (s) => + `${s.guardParens ? `(${guardText(s)})` : guardText(s)} && ${legalCall(s)};`, + 'if-guard': (s) => `if (${guardText(s)}) ${legalCall(s)};`, + 'devbuild-and-guard': (s) => + `devBuild() && ${guardText(s)} && ${legalCall(s)};`, + 'devbuild-only': (s) => `devBuild() && ${legalCall(s)};`, + 'no-guard': (s) => `${legalCall(s)};`, + 'or-guard': (s) => `${guardText(s)} || ${legalCall(s)};`, + 'equal-guard': (s) => + `process.env.NODE_ENV === ${s.quote}production${s.quote} && ${legalCall(s)};`, + 'reversed-guard': (s) => + `${s.quote}production${s.quote} !== process.env.NODE_ENV && ${legalCall(s)};`, + 'computed-env-guard': (s) => + `process.env[${s.quote}NODE_ENV${s.quote}] !== ${s.quote}production${s.quote} && ${legalCall(s)};`, + 'optional-receiver': (s) => + `${guardText(s)} && globalThis?.__oracleProbe?.(${nameText(s)}, ${s.cond.text});`, + 'non-optional-call': (s) => + `${guardText(s)} && ${receiverText(s)}.__oracleProbe(${nameText(s)}, ${s.cond.text});`, + 'one-argument': (s) => + `${guardText(s)} && ${receiverText(s)}.__oracleProbe?.(${nameText(s)});`, + 'three-arguments': (s) => + `${guardText(s)} && ${receiverText(s)}.__oracleProbe?.(${nameText(s)}, ${s.cond.text}, 0);`, + 'spread-argument': (s) => + `${guardText(s)} && ${receiverText(s)}.__oracleProbe?.(...[${nameText(s)}, ${s.cond.text}]);`, + 'bad-condition': (s) => + `${guardText(s)} && ${receiverText(s)}.__oracleProbe?.(${nameText(s)}, ${withBadCondition(s)});`, + 'bad-name': (s) => + `${guardText(s)} && ${receiverText(s)}.__oracleProbe?.(${s.badName}, ${s.cond.text});`, + 'receiver-window': (s) => + `${guardText(s)} && window.__oracleProbe?.(${nameText(s)}, ${s.cond.text});`, + 'cast-other-receiver': (s) => + `${guardText(s)} && (window as { __oracleProbe?: (name: string, condition: unknown) => void }).__oracleProbe?.(${nameText(s)}, ${s.cond.text});`, + 'double-cast': (s) => + `${guardText(s)} && (globalThis as unknown as { __oracleProbe?: (name: string, condition: unknown) => void }).__oracleProbe?.(${nameText(s)}, ${s.cond.text});`, + 'computed-key': (s) => + `${guardText(s)} && ${receiverText(s)}[${s.quote}__oracleProbe${s.quote}]?.(${nameText(s)}, ${s.cond.text});`, + alias: (s, index) => + `const probe${index} = ${receiverText(s)}.__oracleProbe;`, + 'declared-type-key': (s, index) => + `const declared${index}: { __oracleProbe?: () => void } = {};`, + 'variable-initializer': (s, index) => + `const result${index} = ${guardText(s)} && ${legalCall(s)};`, + sequence: (s) => `${guardText(s)} && ${legalCall(s)}, 0;`, + ternary: (s) => `${guardText(s)} ? ${legalCall(s)} : undefined;`, +} + +function renderProgram(program) { + return [ + `export async function* run(input: number, flag: boolean): AsyncGenerator {`, + ...program.sites.map( + (site, index) => ` ${RENDER[site.kind](site, index)}`, + ), + `}`, + ].join(`\n`) +} + +// The rule's behavior is linted through the plugin's production config alone: +// the root config also enables typed rules, which need each file on disk, and +// the scratch text is never written. The root config's enablement is checked +// by asking it which rules apply to a file name, which reads nothing. +const eslint = new ESLint({ cwd: repoRoot }) +const linter = new Linter({ configType: `flat`, cwd: repoRoot }) + +function probeReports(messages) { + const fatal = messages.find((message) => message.fatal) + if (fatal) { + throw new Error(`the rendered program does not parse: ${fatal.message}`) + } + return messages + .filter((message) => message.ruleId === RULE) + .map((message) => ({ line: message.line, messageId: message.messageId })) +} + +function lintWithProductionConfig(code, filePath) { + return probeReports( + linter.verify( + code, + [ + { languageOptions: { parser: tsParser } }, + oraclePlugin.configs.production, + ], + { filename: filePath }, + ), + ) +} + +async function severityInRepository(filePath) { + const config = await eslint.calculateConfigForFile(filePath) + const entry = config.rules?.[RULE] + const severity = Array.isArray(entry) ? entry[0] : entry + return severity === 2 || severity === `error` ? `error` : `off` +} + +// -- 4. Comparison ------------------------------------------------------------ + +const reportKeys = (reports) => + reports.map((report) => `${report.line}:${report.messageId}`).sort() + +function compareReports(path, actual, expected) { + expect(reportKeys(actual), path).toEqual(reportKeys(expected)) +} + +function compareSeverity(path, actual, expected) { + expect(actual, path).toBe(expected) +} + +// -- 5. Reach and campaigns --------------------------------------------------- + +const LEGAL_SUBSTATES = [ + `legal:cast`, + `legal:parenthesized-cast`, + `legal:template-name`, + `legal:guard-parens`, + `legal:call-condition`, +] +const STATES = [...Object.keys(MODEL), ...LEGAL_SUBSTATES] +const reach = createOracleReach(PROPERTY, STATES, { minimum: 1 }) + +function recordStates(program) { + for (const site of program.sites) { + reach.reach(site.kind) + if (!MODEL[site.kind].legal) continue + if (site.cast) reach.reach(`legal:cast`) + if (site.cast && site.paren) reach.reach(`legal:parenthesized-cast`) + if (site.quote === '`') reach.reach(`legal:template-name`) + if (site.guardParens) reach.reach(`legal:guard-parens`) + if (site.cond.calls) reach.reach(`legal:call-condition`) + } +} + +async function checkProgram(program) { + reach.beginHistory() + recordStates(program) + const code = renderProgram(program) + compareReports( + `production file, production config`, + lintWithProductionConfig(code, PRODUCTION_FILE), + predictReports(program), + ) + // The root config enables the rule for production source and nowhere else. + compareSeverity( + `production file, root config`, + await severityInRepository(PRODUCTION_FILE), + predictSeverity(PRODUCTION_FILE), + ) + compareSeverity( + `test file, root config: the rule covers production source only`, + await severityInRepository(TEST_FILE), + predictSeverity(TEST_FILE), + ) +} + +function campaign(name) { + it(`${name}: fixed-seed campaign`, async () => { + await fc.assert(fc.asyncProperty(programArbitrary, checkProgram), { + numRuns: oracleRuns(BASE_RUNS), + seed: FIXED_SEED, + }) + }, 600_000) + it(`${name}: random-seed campaign`, async () => { + await fc.assert( + fc.asyncProperty(programArbitrary, checkProgram), + oraclePropertyOptions(BASE_RUNS, PROPERTY), + ) + }, 600_000) +} + +describe(`probe form in production source`, () => { + it(`census: every kind and every pool value is linted as predicted`, async () => { + await checkProgram(censusProgram()) + }, 600_000) + campaign(`random programs`) + afterAll(() => { + if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) + console.log(`REACH`, JSON.stringify([...reach.counts()])) + reach.assertReached() + }) +}) + +// The comparison must reject a wrong observation: a report on the wrong line, +// and a missing report. Both feed the same check that the campaigns use. +describe(`the comparison rejects wrong reports`, () => { + it(`rejects a report on a neighboring line`, () => { + expect(() => + compareReports( + `calibration`, + [{ line: 3, messageId: MESSAGE_ID }], + [{ line: 2, messageId: MESSAGE_ID }], + ), + ).toThrow() + }) + it(`rejects a missing report`, () => { + expect(() => + compareReports(`calibration`, [], [{ line: 2, messageId: MESSAGE_ID }]), + ).toThrow() + }) +}) + +declareOracle({ + model: [predictReports, predictSeverity], + check: [compareReports, compareSeverity], + grammar: [programArbitrary], +}) diff --git a/scripts/eslint-plugin-oracle/rules/probe-form.js b/scripts/eslint-plugin-oracle/rules/probe-form.js new file mode 100644 index 0000000000..d4a0fb96ae --- /dev/null +++ b/scripts/eslint-plugin-oracle/rules/probe-form.js @@ -0,0 +1,184 @@ +/** + * A state probe is the only development code that a production build must not + * carry, so its call has one form, which the production build strips: + * + * process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.(name, condition) + * + * This rule reports every mention of `__oracleProbe` that is not the callee of + * a probe statement in that form. The statement is one expression statement, + * its guard is the `NODE_ENV` comparison on the left of `&&`, and its receiver + * is `globalThis`, optionally cast with `as`. Its name is a literal or a + * template literal without substitutions, of the form `package#name`. It has + * exactly two arguments, and the condition contains no assignment, update, + * `new`, `delete`, `await`, `yield`, or function or class expression. A call + * is allowed in the condition: a syntactic rule cannot prove that a call is + * pure. + * + * The law and its authority are in + * scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js, which also + * states the limits. The production config in index.js selects production + * source, so the rule reads no other file. + */ + +const PROBE = `__oracleProbe` +const NAME = /^[a-z][a-z0-9-]*#[a-zA-Z][a-zA-Z0-9.-]*$/ +const PRODUCTION = `production` + +// The expression kinds that the condition must not contain. A function, an +// arrow, or a class expression can hide a mutation or an await. +const FORBIDDEN = new Set([ + `AssignmentExpression`, + `UpdateExpression`, + `NewExpression`, + `AwaitExpression`, + `YieldExpression`, + `FunctionExpression`, + `ArrowFunctionExpression`, + `ClassExpression`, +]) + +const isNode = (value) => + value !== null && typeof value === `object` && typeof value.type === `string` + +function childrenOf(node) { + return Object.entries(node).flatMap(([key, value]) => { + if (key === `parent`) return [] + if (Array.isArray(value)) return value.filter(isNode) + return isNode(value) ? [value] : [] + }) +} + +function containsForbidden(node) { + if (FORBIDDEN.has(node.type)) return true + if (node.type === `UnaryExpression` && node.operator === `delete`) return true + return childrenOf(node).some(containsForbidden) +} + +// The text of a plain string literal, or of a template literal with no +// substitutions. Any other node has no static text. +function staticText(node) { + if (node.type === `Literal` && typeof node.value === `string`) { + return node.value + } + if (node.type === `TemplateLiteral` && node.expressions.length === 0) { + return node.quasis[0].value.cooked + } + return undefined +} + +// `process.env.NODE_ENV !== 'production'`, with the comparison on the left. +function isProductionGuard(node) { + if (node.type !== `BinaryExpression` || node.operator !== `!==`) return false + const env = node.left + return ( + env.type === `MemberExpression` && + !env.computed && + env.property.type === `Identifier` && + env.property.name === `NODE_ENV` && + env.object.type === `MemberExpression` && + !env.object.computed && + env.object.property.type === `Identifier` && + env.object.property.name === `env` && + env.object.object.type === `Identifier` && + env.object.object.name === `process` && + staticText(node.right) === PRODUCTION + ) +} + +// `globalThis`, or `globalThis as ...`. A cast returns its type annotation's +// `__oracleProbe` member keys, which mention the probe too. +function receiverKeys(node) { + if (node.type === `Identifier` && node.name === `globalThis`) return [] + if ( + node.type === `TSAsExpression` && + node.expression.type === `Identifier` && + node.expression.name === `globalThis` + ) { + const type = node.typeAnnotation + if (type.type !== `TSTypeLiteral`) return [] + return type.members + .filter( + (member) => + member.type === `TSPropertySignature` && + member.key.type === `Identifier` && + member.key.name === PROBE, + ) + .map((member) => member.key) + } + return undefined +} + +// The nodes of a probe statement's `__oracleProbe` mentions, or undefined when +// the expression is not a probe in the required form. +function probeMentions(expression) { + if ( + expression.type !== `LogicalExpression` || + expression.operator !== `&&` || + !isProductionGuard(expression.left) + ) { + return undefined + } + const chain = expression.right + if (chain.type !== `ChainExpression`) return undefined + const call = chain.expression + if (call.type !== `CallExpression` || !call.optional) return undefined + const callee = call.callee + if ( + callee.type !== `MemberExpression` || + callee.computed || + callee.optional || + callee.property.type !== `Identifier` || + callee.property.name !== PROBE + ) { + return undefined + } + const receiver = receiverKeys(callee.object) + if (receiver === undefined) return undefined + if (call.arguments.length !== 2) return undefined + const [name, condition] = call.arguments + if (name.type === `SpreadElement` || condition.type === `SpreadElement`) { + return undefined + } + const text = staticText(name) + if (text === undefined || !NAME.test(text)) return undefined + if (containsForbidden(condition)) return undefined + return [callee.property, ...receiver] +} + +const message = `\`__oracleProbe\` may be called only as a state probe: one expression statement, \`process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.('package#name', condition)\`, with a name literal of the form package#name and a condition that has no assignment, update, new, delete, await, yield, or function or class expression.` + +export default { + meta: { + type: `problem`, + messages: { probeForm: message }, + schema: [], + }, + create(context) { + // Every mention of the probe, and the mentions that a probe statement + // in the required form owns. A mention outside the owned set is reported. + const mentions = [] + const owned = new Set() + return { + Identifier(node) { + if (node.name === PROBE) mentions.push(node) + }, + Literal(node) { + if (node.value === PROBE) mentions.push(node) + }, + TemplateLiteral(node) { + if (staticText(node) === PROBE) mentions.push(node) + }, + ExpressionStatement(node) { + const owns = probeMentions(node.expression) + if (owns !== undefined) for (const mention of owns) owned.add(mention) + }, + 'Program:exit'() { + for (const mention of mentions) { + if (!owned.has(mention)) { + context.report({ node: mention, messageId: `probeForm` }) + } + } + }, + } + }, +} From 87fa5ca31e30188c5e436c2b6e009a5e8aff5b81 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 15:29:08 -0700 Subject: [PATCH 035/168] refactor(probes): migrate the offline executor's probes to the decided form KeyScheduler and TransactionExecutor now call `__oracleProbe(name, condition)` behind the `NODE_ENV` guard, and their `devBuild()` helpers are gone. Each condition is the state's own test, hoisted where a branch needed it. GREEN: offline-transactions 24 of 24 files, including reach agreement in the KeyScheduler and fifo-retry oracles. Size: offline-transactions -13 bytes gzip. Co-authored-by: Isaac --- .../src/executor/KeyScheduler.ts | 16 ++++--- .../src/executor/TransactionExecutor.ts | 42 ++++++++++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/offline-transactions/src/executor/KeyScheduler.ts b/packages/offline-transactions/src/executor/KeyScheduler.ts index 2502becadb..eb880dbdba 100644 --- a/packages/offline-transactions/src/executor/KeyScheduler.ts +++ b/packages/offline-transactions/src/executor/KeyScheduler.ts @@ -5,11 +5,17 @@ export class KeyScheduler { private activeTransactionId: string | undefined schedule(transaction: OfflineTransaction): boolean { - if ( - this.pendingTransactions.some((pending) => pending.id === transaction.id) - ) { - return false - } + const duplicate = this.pendingTransactions.some( + (pending) => pending.id === transaction.id, + ) + // Development-only oracle probe; a production build removes it. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`offline-transactions#duplicate-schedule`, duplicate) + if (duplicate) return false this.pendingTransactions.push(transaction) // Sort by creation time to maintain FIFO order this.pendingTransactions.sort( diff --git a/packages/offline-transactions/src/executor/TransactionExecutor.ts b/packages/offline-transactions/src/executor/TransactionExecutor.ts index f8d788719d..178d29f13d 100644 --- a/packages/offline-transactions/src/executor/TransactionExecutor.ts +++ b/packages/offline-transactions/src/executor/TransactionExecutor.ts @@ -224,6 +224,16 @@ export class TransactionExecutor { } else { console.warn(`Transaction ${transaction.id} failed permanently:`, error) } + // Development-only oracle probe; a production build removes it. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `offline-transactions#permanent-failure-before-peer`, + this.scheduler.getPendingCount() > 1, + ) try { await this.removeSettledTransaction(rejectionPending, true) } catch (storageError) { @@ -250,6 +260,16 @@ export class TransactionExecutor { }, } + // Development-only oracle probe; a production build removes it. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `offline-transactions#retry-before-peer`, + this.scheduler.getPendingCount() > 1, + ) this.scheduler.updateTransaction(updatedTransaction) try { @@ -279,13 +299,33 @@ export class TransactionExecutor { await this.outbox.update(transaction.id, transaction) } catch (error) { // A public removal can finish while the provider is still running. - if (!(error instanceof OutboxTransactionNotFoundError)) throw error + if (!(error instanceof OutboxTransactionNotFoundError)) { + // Development-only oracle probe; a production build removes it. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`offline-transactions#phase-write-fault`, true) + throw error + } } } await this.outbox.remove(transaction.id) this.scheduler.markCompleted(transaction) } catch (error) { const storageError = toError(error) + // Development-only oracle probe; a production build removes it. The + // failed transaction is still pending, so any other pending one is a peer. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `offline-transactions#fault-before-peer`, + this.scheduler.getPendingCount() > 1, + ) this.fatalError = storageError this.scheduler.markFailed(transaction) this.clearRetryTimer() From 59ed3253bc5084dfbff3071b01fcf292e26919ec Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 15:29:25 -0700 Subject: [PATCH 036/168] feat(oracle-gaps): a gap report owned by oracles, with an incremental daemon The gap report runs every operator mutant of the given sources against the oracle files that own each function, and lists what those oracles leave unexercised or unjudged. - Ownership: an oracle file declares `declareOracle({ owns })`; owning an entry function owns its reference tree within the package, as the type checker resolves it. A broken declaration refuses the run. - Classification: only a law verdict on a reproducible route kills. The route comes from framework registration (`__oracleRoute`), now also from `runOracleCampaign`, in-test random parameters, and the offline campaign options. Assertion and soft-assertion failures are verdicts under an unnamed law (transitional). - `--daemon`: one daemon per package keeps Vitest warm and stores each run of one test file under one mutant in a TanStack DB collection persisted to SQLite, with the hash of every module that run imported. A stored run is reused only while all of them are unchanged. - 24 oracle files in offline-transactions and db-ivm declare what they own. Oracles: - scripts.gap-report (end to end), with law 9, incremental equals cold, over edit sequences through the daemon. - scripts.gap-report-static and scripts.gap-report-classify run the static half and the classification in-process; `pnpm test:oracle-gaps:fast` takes about 3 s, the full set about 2.5 min. RED: the unchanged CLI had no daemon, and classified a soft-assertion failure as a crash. GREEN: 26 of 26. Mutants: the recorded hostile mutants that still apply are killed (static core 17 of 21, the rest owned by the classification core, which kills 25 of 26 and detects the last as a crash). Limits are in the coverage map. Co-authored-by: Isaac --- .gitignore | 4 + docs/contributing/oracle-coverage.md | 115 +- package.json | 2 + ...incrementalization-oracle.property.test.ts | 7 +- ...nct-representative-oracle.property.test.ts | 3 + ...hash-failure-retry-oracle.property.test.ts | 3 + .../tests/hash-graph-oracle.property.test.ts | 3 + .../hash-identity-oracle.property.test.ts | 7 + .../hash-mixed-graph-oracle.property.test.ts | 3 + .../db-ivm/tests/hash-oracle.property.test.ts | 7 +- ...ementalization-law-oracle.property.test.ts | 17 +- .../index-refinement-oracle.property.test.ts | 8 + ...ltiset-consolidate-oracle.property.test.ts | 375 +++- .../murmur-stream-oracle.property.test.ts | 10 +- .../reduce-fast-path-oracle.property.test.ts | 7 + .../topKWithFractionalIndex-oracle.test.ts | 10 + .../topk-batch-contract-oracle.test.ts | 7 + .../topk-support-window-oracle.test.ts | 7 + ...tructural-identity-oracle.property.test.ts | 11 +- .../tests/temporal-group-key-oracle.test.ts | 7 +- packages/db/tests/oracle-campaign.ts | 6 + packages/db/tests/oracle-config.ts | 15 + packages/db/tests/oracle-declaration.ts | 6 + packages/db/tests/oracle-replay-manifest.ts | 20 + .../KeyScheduler-oracle.property.test.ts | 508 +++++- .../tests/connectivity-replay-oracle.test.ts | 11 +- .../tests/fifo-retry-oracle.property.test.ts | 158 +- ...ruption-settlement-oracle.property.test.ts | 11 + .../leadership-replay-oracle.property.test.ts | 12 +- .../tests/oracle-config.ts | 4 + ...saction-serializer-oracle.property.test.ts | 13 +- ...saction-settlement-oracle.property.test.ts | 11 + scripts/oracle-gaps/analysis.mjs | 108 ++ scripts/oracle-gaps/args.mjs | 44 + scripts/oracle-gaps/authored.mjs | 62 + scripts/oracle-gaps/capture-setup.mjs | 63 +- scripts/oracle-gaps/check.mjs | 687 +++++++ scripts/oracle-gaps/classify.mjs | 291 +++ scripts/oracle-gaps/daemon.mjs | 148 ++ scripts/oracle-gaps/evidence.mjs | 75 + ...p-report-classify-oracle.property.test.mjs | 650 +++++++ .../oracle-gaps/gap-report-model-oracle.mjs | 1610 +++++++++++++++++ .../gap-report-oracle.property.test.mjs | 1000 ++++++++++ ...gap-report-static-oracle.property.test.mjs | 372 ++++ scripts/oracle-gaps/index.mjs | 687 +------ scripts/oracle-gaps/owners.mjs | 207 +++ scripts/oracle-gaps/report.mjs | 182 ++ scripts/oracle-gaps/schemata.mjs | 165 +- scripts/oracle-gaps/vitest.config.mjs | 15 + 49 files changed, 6890 insertions(+), 864 deletions(-) create mode 100644 scripts/oracle-gaps/analysis.mjs create mode 100644 scripts/oracle-gaps/args.mjs create mode 100644 scripts/oracle-gaps/authored.mjs create mode 100644 scripts/oracle-gaps/check.mjs create mode 100644 scripts/oracle-gaps/classify.mjs create mode 100644 scripts/oracle-gaps/daemon.mjs create mode 100644 scripts/oracle-gaps/evidence.mjs create mode 100644 scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs create mode 100644 scripts/oracle-gaps/gap-report-model-oracle.mjs create mode 100644 scripts/oracle-gaps/gap-report-oracle.property.test.mjs create mode 100644 scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs create mode 100644 scripts/oracle-gaps/owners.mjs create mode 100644 scripts/oracle-gaps/report.mjs create mode 100644 scripts/oracle-gaps/vitest.config.mjs diff --git a/.gitignore b/.gitignore index d1bb2024cd..ee163892cd 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,10 @@ tsconfig.vitest-temp.json # The part-reach oracle lints scratch programs inside the repository; an # interrupted run can leave them behind. /part-reach-scratch-*/ +# The gap-report oracle writes scratch packages beside the CLI it tests. +/scripts/oracle-gaps/.scratch-*/ +# A temporary copy of the CLI that hostile-mutant runs edit. +/scripts/oracle-gaps-mutant*/ # Other checkouts and scratch copies. A plain directory here is not part of # this checkout's branch, so the oracle lint hooks must not see its files. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index c6dc9b61e9..edeb26df27 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -595,9 +595,9 @@ that test identifiers must copy production's private data structures. | Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | | PowerSync | Fixed tests only | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are covered by fixed tests in `correctness.test.ts`; no oracle owns them (tracked in #2264). | | SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared-handle driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | -| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. A constructed history requires a phase-write fault before a queued peer on every run. Mutants that let a retried transaction move behind its peers, keep running after a storage fault, or stop the queue at a permanent failure fail it; the previous fault-free oracle passed all three. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per campaign for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight fixed histories enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. Reach comes from production. Four states are state probes in `TransactionExecutor` (`offline-transactions#retry-before-peer`, `#permanent-failure-before-peer`, `#phase-write-fault`, `#fault-before-peer`); `predict` says from the plans alone which histories enter each, and reach agreement must hold in every history. The storage fake reports `deletion fault` when it refuses the removal, and the driver reports `retried head` and `retried tail` as measured states. Four constructed histories enter the gating states on every run, and each campaign reaches every gating state at least 13 times. Mutants that let a ready tail pass the delayed head, retry without backoff, drop a permanent failure's caller rejection, swallow a phase-write error, keep an outbox row after removal, or keep running after a storage fault fail the oracle, and misplaced or removed probes fail reach agreement. A mutant that keeps the scheduler's active ID after a failure stalls the driver, so it is killed only by the test timeout, not by a named law. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The check labels a call after a storage fault as FIFO order rather than the stop law. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per campaign for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight fixed histories enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | | Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | -| Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. | +| Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. MultiSet consolidation (`ivm.multiset-consolidate`, [owner](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts)) keeps one record per identity under the keyed, primitive, or structural rule, with the first record and the summed multiplicity, and leaves its input unchanged. Six declared states are production probes (`db-ivm#...` in `packages/db-ivm/src/multiset.ts`): the three rules, `records merged`, `join tuples merged`, and `only pairs without a key`. The model predicts each one from the history alone, and reach counts a state only where the probe and the prediction agree (`reach agreement`). Each state has a constructed history, so every campaign enters it; the grammar modes are generator dimensions, not reach. Sixteen mutants are killed, each by both campaigns: wrong identity rules, merge sums, retained records, zero-sum output, -0 handling, input mutation, and wrong or missing probe placements. Limits: output order is not part of the law; structural identity uses a 32-bit hash, so no injectivity is claimed; the probes are development-only behind a temporary guard (`size:compare` measures zero shipped bytes), and they show where production enters a state while the check judges the output. The empty multiset takes rule 1 by the law's vacuous "every record" reading, which no output depends on. | | Mixed-kind ordered prefixes | Bounded | Eager BasicIndex and BTreeIndex paths compare ascending and descending direct and live-query windows with an unindexed scan across mixed value kinds and paired source changes. One retained on-demand BTree history checks settled windows after a number/bigint transition, a finite request for a missing row, and a cursor request after index eligibility changes. Other provider paging histories remain open. | | Boundary refinements | Complete | Cleanup/restart admission, metadata publication, retained state, acquisition cells, D2 source reconciliation, top-K support windows, and nested Query work bounds are literate. | | Small structures and test mechanics | Complete | SortedMap, cleanup appointments, and guarded replay are literate. | @@ -1290,7 +1290,20 @@ checks FIFO eligibility and retry order at the scheduler boundary, as the generator drew histories of at most 12 commands (fast-check's default array size) with a biased selector, so 4 of 150 histories per campaign started any transaction and 1 retried; generation now draws 1 to 50 unbiased commands, and -about a quarter of histories start work and a tenth retry. It does not observe +about a quarter of histories start work and about one in fifteen retries. +Histories also duplicate a pending ID, as outbox replay does, and schedule +`missing`, the ID replay snapshots name without the scheduler holding it. +`schedule` must return false for a duplicate (law `dedupe`, authority: +TransactionExecutor uses that result to find newly loaded transactions); +keeping the first record on a duplicate awaits a maintainer decision. The +declared state `duplicate schedule` is the first state bound to a production +probe: the model predicts which histories duplicate a pending ID, the +scheduler's development-only `duplicate-schedule` probe reports which ones +production rejected, and the two must agree in every history (law `reach +agreement`). A constructed history fills all five slots and then duplicates +one, so every campaign reaches `duplicate schedule at capacity`, which the +driver records. Reach for command types comes from the commands the driver +executed. It does not observe the executor's awaited outbox update between `updateTransaction` and `markFailed`, or public caller settlement. Those receiving cuts belong with `packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts`. @@ -3995,6 +4008,84 @@ keys and the fixed seed 271828 as removed, although both campaigns still run in the oracle file. Owner: `scripts.run-budget`. Needed witness: a campaign whose budget a helper computes and passes in as parameters. +`scripts/oracle-gaps/gap-report-oracle.property.test.mjs` owns the gap report +(`scripts.gap-report`), the CLI in `scripts/oracle-gaps/` that lists the gaps an +oracle's author must answer. It generates a source file of functions and +classes that imports from a second source file of the same package, two +oracle files that declare `owns` entries, an ordinary test file, +authored mutants, declaration faults, and source edits. A small interpreter of +that grammar predicts every report fact, and the driver runs the real CLI on a +scratch package. Its laws are the operator enumeration; probe-only code in the decided form +`process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.(name, +condition)`, with every other probe shape judged as production; owner +discovery, static reference trees within the package as the type checker +resolves them (calls, direct and aliased imports, `this` and typed fields, +`.bind`, values passed as arguments, and classes passed as values, which bring +all their methods; `new C()` brings only the constructor), stopping at the +package boundary, and refusal of an owns entry that names nothing, a +non-literal owns list, or a failing owner; classification by the tests of every owner on +their registered routes, where only a law verdict on a reproducible route kills +(a crash, an agreement-only kill, a random-only kill, a kill on an unregistered +route, a framework-only failure, a nondeterministic test, and an ordinary test +never do), an `expect` assertion failure counts as a verdict under an unnamed +law (transitional, for older oracles), and a test that runs several campaigns +has each failure attributed to the route active when it fired, including a +soft assertion (`expect.soft`) that lets the test go on to another campaign; +authored mutants; anchored keys; report completeness; key +stability under an unrelated function, an inserted probe guard, and a +reformat; and incremental equals cold: through the daemon (`--daemon`), whose +evidence store is a TanStack DB collection persisted to SQLite, a check at +every state of a sequence of edits (a test, a source statement, line shifts, +an owns list, the authored mutant, a helper module one oracle file imports, +the seed, a new oracle file, a daemon restart, and a revert) reports what a +cold run of that state reports. Its reach requires reuse, a mix of reuse and +recomputation, reuse from disk after a restart, and a changed cold result for +each content edit. Its authority is the maintainer's gap-check decisions of 2026-11-04 +and 2026-11-05, which are not yet in a decision register. Limits: two source +files of one package, with no re-exports, namespace imports, or calls into +another package; no loops, timeouts, multi-line conditions, arrow or nested +functions, member calls other than `this.m()`, or `--only`, `--sample`, and +`--diff-base`; no probe-bound states, so reach +agreement's killing of probe mutants is unchecked; no gap answers or gate; +the incremental law's edits leave out files a test reads through `fs`, +environment variables other than the seed, a new file that changes how an +unchanged import resolves, Vite config and installed-package changes, edits to +the CLI's own files, and concurrent checks. A test that is only sometimes +nondeterministic can make an incremental check differ from a cold one, because +the daemon replays its stored unmutated runs. Its required states are reached by constructed histories, +and generated histories add little reach of their own. The static states +(probe structure and ownership through the reference tree) are counted here +and required by `scripts.gap-report-static`. The three gap-report oracles run +in about two and a half minutes (`pnpm test:oracle-gaps`). Owner: +`scripts/oracle-gaps`. + +`scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs` +(`scripts.gap-report-static`) owns the gap report's static half in-process: +operator enumeration, probe-only code, refusal of an owns entry that names +nothing, a missing file, or a non-literal list, ownership through the +reference tree, keys, and key stability. It writes the end-to-end oracle's +generated fixtures (`gap-report-model-oracle.mjs`) to a scratch package and +calls `analyze` (analysis.mjs) and `assignKeys` directly, without Vitest, so +a campaign takes about a second and every static state is reached in every +campaign. Limits: the end-to-end grammar's; outcomes that need test runs and +the failing-owner refusal belong to `scripts.gap-report`. Owner: +`scripts/oracle-gaps`. + +`scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs` +(`scripts.gap-report-classify`) owns the gap report's classification and +summary over run records: it generates the records a test run writes +(states, error texts, timeouts, setup and unhandled errors, capture rows +with reach, `if` outcomes, asserted and observed hashes, routes, and +verdicts with the route active when they fired), runs the CLI's pure +`classify.mjs` and `renderReport`, and compares each mutant's outcome, class, +laws, and ordinary-tier outcome, and the summary's gaps, warnings, and +counts, with a model of the law over records. 300 histories run in well +under a second. Limits: no authored mutants; that real runs write these +records is `scripts.gap-report`'s. Owner: `scripts/oracle-gaps`. + +`pnpm test:oracle-gaps:fast` runs these two in a few seconds; the +end-to-end oracle runs with `pnpm test:oracle-gaps`. + `scripts/lint-scope-oracle.property.test.js` owns lint selection after an edit (`scripts.lint-scope`): the changed scope, the named (pre-commit) scope, and the Claude edit and stop hooks. Its model is a fixed-point closure over the @@ -4014,6 +4105,24 @@ does not generate a hook input without `cwd`, a worktree outside the launch checkout, or a worktree branch that has no copy of the script. In that last case the launch checkout's rule lints the worktree. +`scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js` +(`lint.probe-form`) owns the state-probe form (`oracle/probe-form`). In production +source (`packages/*/src`), a probe is one expression statement, +`process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.(name, condition)`, +and every other mention of `__oracleProbe` is reported. Its grammar draws each +legal form and one near-miss per broken part: a missing or different guard, an +`if` or ternary wrapper, a one-, three-, or spread argument, a non-optional call, +a bad name, a forbidden form in the condition at any depth, a foreign receiver or +cast, an alias, and a declared type key. The model predicts the reports from the +drawn kinds, and the reports come from the plugin's production config through +`Linter`. The root config's enablement is checked with `calculateConfigForFile`, +which reads no file. Owner: `scripts/eslint-plugin-oracle`. Limits: a call in a +condition is allowed, because a syntactic rule cannot prove that a call is pure; +the rule does not run typed rules or write scratch files; `pnpm lint:changed` +selects only oracle files, so production reports appear in `pnpm lint`, not in +the edit hook. Authority: the maintainer's probe-form decision and the glossary's +state probe entry. + `packages/db/tests/declared-reach-oracle.property.test.ts` (`oracle.declared-reach`; [review record](oracle-reviews/pr-2290-declared-reach.md)) owns what `createOracleReach` (ORC-015) counts, when diff --git a/package.json b/package.json index 01d99e9f64..5dc0584b6b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "lint:changed": "node scripts/agent-lint-feedback.mjs", "lint:oracles": "node scripts/agent-lint-feedback.mjs --all", "lint:run-budgets": "node scripts/check-oracle-run-counts.mjs", + "test:oracle-gaps": "vitest run --config scripts/oracle-gaps/vitest.config.mjs", + "test:oracle-gaps:fast": "vitest run --config scripts/oracle-gaps/vitest.config.mjs gap-report-static gap-report-classify", "test:lint-rules": "vitest run --config scripts/eslint-plugin-oracle/vitest.config.mjs && vitest run --config scripts/oracle-scripts.vitest.config.mjs", "lint-all": "eslint . --fix", "prepare": "husky", diff --git a/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts b/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts index a963ea86ee..f154db3b61 100644 --- a/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts @@ -40,7 +40,12 @@ const policy = { clone: (value: DistinctValue) => ({ ...value }), } -declareOracle({ check: [assertUnaryIncrementalization, checkDistinctSnapshot] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/distinct.ts#distinct`], + check: [assertUnaryIncrementalization, checkDistinctSnapshot], +}) for (const mode of [`selected`, `tuple`] as const) { const values = distinctCollisionValues((value) => diff --git a/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts b/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts index 4ede56e97b..2ba845116c 100644 --- a/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts @@ -37,7 +37,10 @@ import type { Weighted } from './incrementalization-law-oracle.js' const PROPERTY = `ivm.distinct-representatives` const family = distinctCollisionFamily(hash) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/operators/distinct.ts#distinct`], check: [checkDistinctRepresentatives], grammar: [distinctBucketHistory], }) diff --git a/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts b/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts index ce6ba92d94..7b55891764 100644 --- a/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts @@ -123,7 +123,10 @@ function expectRetryHistory(history: History): void { expect(reads).toEqual(afterSuccess) } +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/hashing/hash.ts#hash`], check: [expectRetryHistory], model: [expectedFailedReads, expectedSuccessReads, expectedPlainCopy], }) diff --git a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts index 3c464049ce..0c84921813 100644 --- a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts @@ -102,7 +102,10 @@ function expectGraphHash( expect(hashValue(nodes[0])).toBe(hashValue(unfoldEdges(edges, 0))) } +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/hashing/hash.ts#hash`], check: [expectGraphHash], model: [isAcyclic, unfoldEdges], }) diff --git a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts index acb61ffaa0..c03bac89c5 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -781,7 +781,14 @@ function expectCyclicPair({ spec, changed, seeds }: CyclicPair): void { // The model is `identity` (and its encoding `canon`): equal identities must // share a verdict and hash. The grammar `pairArb` is calibrated below. +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/hashing/hash.ts#hash`, + `src/hashing/hash.ts#equalHashValues`, + `src/hashing/hash.ts#registerOpaqueHash`, + ], check: [expectPair, expectCyclicPair], model: [identity, canon], grammar: [pairArb], diff --git a/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts b/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts index 37249585dc..0429d1eea7 100644 --- a/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts @@ -155,7 +155,10 @@ function expectMixedGraph( expect(hashValue(nodes[0])).toBe(hashValue(unfoldGraph(graph, 0, symbol))) } +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/hashing/hash.ts#hash`], check: [expectMixedGraph], model: [isAcyclic, unfoldGraph], }) diff --git a/packages/db-ivm/tests/hash-oracle.property.test.ts b/packages/db-ivm/tests/hash-oracle.property.test.ts index 0c6a505ad0..c0199f79a8 100644 --- a/packages/db-ivm/tests/hash-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-oracle.property.test.ts @@ -75,7 +75,12 @@ function expectPermutedHashes( expectEqualHashes(original, reversed, hashValue) } // The key-order law: permuting the keys of an object leaves its hash unchanged. -declareOracle({ check: [expectPermutedHashes] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/hashing/hash.ts#hash`], + check: [expectPermutedHashes], +}) // The reusable value laws run with identical generators and checks in a // fixed campaign and a random campaign. A path replay of `ivm.hash-value` diff --git a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts index 65f98b70c8..848ed6685c 100644 --- a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts @@ -543,7 +543,22 @@ const orderedHistory: fc.Arbitrary>> = fc // The grammar of legal ordered histories. Its calibration test checks that the // generated steps reach row promotions, which the ordered laws need. -declareOracle({ grammar: [orderedHistory] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/operators/map.ts#map`, + `src/operators/filter.ts#filter`, + `src/operators/consolidate.ts#consolidate`, + `src/operators/reduce.ts#reduce`, + `src/operators/groupBy.ts#groupBy`, + `src/operators/join.ts#join`, + `src/operators/output.ts#output`, + `src/operators/orderBy.ts#orderByWithFractionalIndex`, + `src/operators/groupedOrderBy.ts#groupedOrderByWithFractionalIndex`, + ], + grammar: [orderedHistory], +}) function firstThree(input: Weighted): Weighted { return keyedIdentity(input) diff --git a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts index eb1ba8ea96..fa14918008 100644 --- a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts +++ b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts @@ -234,7 +234,15 @@ function expectRefinement( // The refinement law is declared with its model, the multiset of values per // key, and its grammar, the generated addition histories. +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/indexes.ts#Index.addValue`, + `src/indexes.ts#Index.get`, + `src/indexes.ts#Index.has`, + `src/indexes.ts#Index.getIterator`, + ], check: [expectRefinement], model: [expectedIndex], grammar: [historyArbitrary], diff --git a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts index 3236c21d13..3ab298bf40 100644 --- a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts +++ b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts @@ -6,11 +6,18 @@ import { pass, violation, } from '../../db/tests/oracle-property.js' +import { declareOracle } from '../../db/tests/oracle-declaration' import { contentsOf } from './multiset-support.js' import type { Observation } from '../../db/tests/oracle-property.js' import type { Data } from './multiset-support.js' import type { MultiSetArray } from '../src/multiset.js' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/multiset.ts#MultiSet.consolidate`], +}) + /** * # Which records does `MultiSet.consolidate()` merge? * @@ -50,6 +57,36 @@ import type { MultiSetArray } from '../src/multiset.js' * - Key order is covered only by one fixed pair of keys that tie under locale * collation (a precomposed and a decomposed Å), inserted in both orders in * structural histories; equal objects must merge whatever the order. + * + * ## States, and what reach counts + * + * A state is a fact that production enters: taking an identity rule's path, or + * joining a record to an identity that already holds one. Production marks each + * state with a development-only probe (`db-ivm#...`). The model predicts each + * state from the history alone (`predict`). A state counts as reached only + * where the probe entered it (was called with a true condition) in the same + * history that the model predicted, which is the law `reach agreement`. + * + * - `keyed identity`: rule 1 governs the whole multiset. The rule holds + * vacuously for the empty multiset, since no record fails it. Nothing is + * identified there, so the empty output is the same on every path. + * - `primitive identity` and `structural identity`: rules 2 and 3. + * - `records merged`: an identity holds two or more input records. A merge + * counts even when the summed multiplicity is zero, because the records + * joined one identity before the zero sum dropped it. The earlier label + * compared the output size with the number of distinct record values, which + * a `Set` counts with SameValueZero: `-0` and `0` are one value there, so + * that merge went unlabeled. Rule 2 names one identity for them as well, so + * the merge is real and this state counts it. + * - `join tuples merged`: two join tuples with the same key and elements merge. + * - `only pairs without a key`: every record is a pair whose first element is + * no key, so the multiset takes rule 3 although each record is a pair. A + * multiset of pairs with some keys is not in this state: it takes rule 1 when + * every pair is keyed, and rule 3 otherwise. + * + * Grammar modes are generator dimensions. They are not reach: a history in a + * mode may enter none of these states, and no state is counted by its mode. + * Each state has a constructed history, so every campaign enters every state. */ type Spec = @@ -107,12 +144,36 @@ const KEYED_KEYS: ReadonlyArray = [ // --------------------------------------------------------------------------- // Model. It does not use production identity helpers or `hash`. +/** The identity rule (1, 2, or 3 in the law) that governs a whole multiset. */ +type Rule = `keyed` | `primitive` | `structural` +type Identity = { + /** The retained record, normalized as rule 2 says. */ + first: Data + /** The input position of the identity's first record. */ + firstIndex: number + sum: number + /** How many input records the identity holds; two or more is a merge. */ + members: number + /** Whether its records are join tuples, which only the keyed rule has. */ + tuple: boolean +} type Expected = { - /** `firstIndex` is the input position of the identity's first record. */ - groups: Array<{ first: Data; firstIndex: number; sum: number }> + rule: Rule + /** Every identity, including one whose sum is zero. */ + identities: Array + /** The identities that the output owes: a nonzero sum. */ + groups: Array sameIdentity: (left: Data, right: Data) => boolean } +// A keyed record is a `[key, value]` pair with a string or number key. +const isKeyedRecord = (data: Data): boolean => + Array.isArray(data) && + data.length === 2 && + (typeof data[0] === `string` || typeof data[0] === `number`) +const isJoinTuple = (value: Data): boolean => + Array.isArray(value) && value.length === 2 + function expectedConsolidation(records: MultiSetArray): Expected { // SameValueZero compares primitive values; strict equality also preserves // reference identity for objects, functions, and symbols. @@ -150,38 +211,47 @@ function expectedConsolidation(records: MultiSetArray): Expected { return [typeof value, value === undefined ? null : value] } - const isKeyed = - records.length > 0 && - records.every( - ([data]) => - Array.isArray(data) && - data.length === 2 && - (typeof data[0] === `string` || typeof data[0] === `number`), - ) - const onePrimitiveType = [`string`, `number`].some((type) => - records.every(([data]) => typeof data === type), - ) - const sameIdentity: Expected['sameIdentity'] = isKeyed - ? sameKeyed - : onePrimitiveType - ? sameLeaf - : (left, right) => - JSON.stringify(structure(left)) === JSON.stringify(structure(right)) + // The rule the law gives the whole multiset. "Every record" holds vacuously + // for the empty multiset, so it is keyed there; no record is identified, so + // nothing observable depends on that choice. + const rule: Rule = records.every(([data]) => isKeyedRecord(data)) + ? `keyed` + : [`string`, `number`].some((type) => + records.every(([data]) => typeof data === type), + ) + ? `primitive` + : `structural` + const sameIdentity: Expected['sameIdentity'] = + rule === `keyed` + ? sameKeyed + : rule === `primitive` + ? sameLeaf + : (left, right) => + JSON.stringify(structure(left)) === JSON.stringify(structure(right)) const retained = (data: Data) => - onePrimitiveType && Object.is(data, -0) ? 0 : data + rule === `primitive` && Object.is(data, -0) ? 0 : data - const groups: Expected['groups'] = [] + const identities: Array = [] for (const [index, [data, multiplicity]] of records.entries()) { - const group = groups.find(({ first }) => sameIdentity(first, data)) - if (group) group.sum += multiplicity - else - groups.push({ + const identity = identities.find(({ first }) => sameIdentity(first, data)) + if (identity) { + identity.sum += multiplicity + identity.members += 1 + } else + identities.push({ first: retained(data), firstIndex: index, sum: multiplicity, + members: 1, + tuple: rule === `keyed` && isJoinTuple((data as [Data, Data])[1]), }) } - return { groups: groups.filter(({ sum }) => sum !== 0), sameIdentity } + return { + rule, + identities, + groups: identities.filter(({ sum }) => sum !== 0), + sameIdentity, + } } // --------------------------------------------------------------------------- @@ -204,6 +274,22 @@ function specArbFor(maxPrimitiveIndex: number): fc.Arbitrary { } const keyedSpecArb = specArbFor(KEYED_PRIMITIVES.length - 1) +// Join tuples over a small domain, so that equal tuples recur within one +// history. The general keyed grammar draws its leaves from a wide domain, and +// two tuples there rarely share both elements. +const smallLeafArb: fc.Arbitrary = fc.oneof( + fc.record({ kind: fc.constant(`prim` as const), i: fc.nat(2) }), + fc.record({ kind: fc.constant(`obj` as const), i: fc.nat(1) }), +) +const tupleSpecArb: fc.Arbitrary = fc.record({ + kind: fc.constant(`tuple` as const), + a: smallLeafArb, + b: smallLeafArb, +}) +// The first element of a pair: indices 0 to 2 are values no key can be, and +// indices 3 and 4 are keys. `unkeyedPairs` draws from 0 to 2, and `mixedPairs` +// draws from all five, so a multiset of pairs can hold keyed and unkeyed pairs. +const PAIR_FIRSTS: ReadonlyArray = [true, false, null, 0, `a`] // The unkeyed fallback checks structural hashing on its original value domain. const fallbackSpecArb = specArbFor(9) const multiplicityArb = fc.integer({ min: -2, max: 2 }) @@ -223,7 +309,16 @@ type StructuralStep = | { fresh: `unkeyedPair`; v: number } type Mode = - `keyed` | `keyedCollision` | `numbers` | `strings` | `structural` | `fallback` + | `keyed` + | `keyedCollision` + | `numbers` + | `strings` + | `structural` + | `fallback` + | `tuples` + | `unkeyedPairs` + | `mixedPairs` + | `mixedPrimitives` // `keyedCollision` selects grammar witnesses; it exercises the same keyed // production path as `keyed`. `caseName` labels a replay, not product state. // A `keyedCollision` history has one step: `[[name, opposed], scale]`, the @@ -373,6 +468,27 @@ const historyArb: fc.Arbitrary = fc.oneof( { maxLength: 24, size: `medium` }, ), }), + fc.record({ + mode: fc.constant(`tuples` as const), + steps: fc.array( + fc.tuple(fc.tuple(fc.nat(3), tupleSpecArb), multiplicityArb), + { maxLength: 24, size: `medium` }, + ), + }), + fc.record({ + mode: fc.constant(`unkeyedPairs` as const), + steps: fc.array(fc.tuple(fc.tuple(fc.nat(2), fc.nat(1)), multiplicityArb), { + maxLength: 24, + size: `medium`, + }), + }), + fc.record({ + mode: fc.constant(`mixedPairs` as const), + steps: fc.array(fc.tuple(fc.tuple(fc.nat(4), fc.nat(1)), multiplicityArb), { + maxLength: 24, + size: `medium`, + }), + }), fc.record({ mode: fc.constant(`numbers` as const), steps: fc.array( @@ -380,6 +496,18 @@ const historyArb: fc.Arbitrary = fc.oneof( { maxLength: 24, size: `medium` }, ), }), + // Strings and numbers together: rule 2 needs one primitive type, so this + // multiset is structural, and `1` and `"1"` stay apart. + fc.record({ + mode: fc.constant(`mixedPrimitives` as const), + steps: fc.array( + fc.tuple(fc.constantFrom(0, 1, `1`, `p`), multiplicityArb), + { + maxLength: 24, + size: `medium`, + }, + ), + }), fc.record({ mode: fc.constant(`strings` as const), steps: fc.array(fc.tuple(fc.constantFrom(`p`, `q`, `r`), multiplicityArb), { @@ -424,6 +552,18 @@ const historyArb: fc.Arbitrary = fc.oneof( }), ) +// A constructed history keeps its witness steps, then the generated steps of +// the same mode that fit the grammar's length bound, so a campaign explores +// what follows the state rather than one fixed visit to it. +function continuing( + mode: Mode, + witness: History['steps'], + generated: History, +): History { + const tail = generated.mode === mode ? generated.steps : [] + return { mode, steps: [...witness, ...tail].slice(0, 24) } +} + // Build the records of one history with fresh object references. function buildRecords(history: History): MultiSetArray { const objects = [{ v: 1 }, { v: 1 }, { v: 2 }] @@ -437,7 +577,15 @@ function buildRecords(history: History): MultiSetArray { : fromLeaf(spec) const records: MultiSetArray = history.steps.map(([step, m]) => { if (history.mode === `keyedCollision`) return [step, m] - if (history.mode === `keyed` || history.mode === `fallback`) { + if (history.mode === `unkeyedPairs` || history.mode === `mixedPairs`) { + const [first, v] = step as [number, number] + return [[PAIR_FIRSTS[first]!, { v }], m] + } + if ( + history.mode === `keyed` || + history.mode === `fallback` || + history.mode === `tuples` + ) { const [keyIndex, spec] = step as [number, Spec] return [ [ @@ -447,7 +595,11 @@ function buildRecords(history: History): MultiSetArray { m, ] } - if (history.mode === `numbers` || history.mode === `strings`) + if ( + history.mode === `numbers` || + history.mode === `strings` || + history.mode === `mixedPrimitives` + ) return [step, m] const value = step as StructuralStep if (value !== null && typeof value === `object`) { @@ -652,14 +804,14 @@ function checkConsolidation( // Property // --------------------------------------------------------------------------- -const modes = [ - `keyed`, - `keyedCollision`, - `numbers`, - `strings`, - `structural`, - `fallback`, -] as const +// The declared states. Each is marked by a production probe and predicted by +// the model, so reach comes from production (see the header's States section). +const keyedIdentity = `keyed identity` +const primitiveIdentity = `primitive identity` +const structuralIdentity = `structural identity` +const recordsMerged = `records merged` +const joinTuplesMerged = `join tuples merged` +const pairsWithoutKey = `only pairs without a key` const witness = (name: string): History => ({ mode: `keyedCollision`, steps: [[[name, false], 1]], @@ -675,12 +827,79 @@ oracleProperty({ }, histories: { arbitrary: historyArb, - states: [], - measured: [ - ...modes, - `records merged`, - `join tuples merged`, - `only pairs without a key`, + states: [ + { name: keyedIdentity, probe: `db-ivm#keyed-identity` }, + { name: primitiveIdentity, probe: `db-ivm#primitive-identity` }, + { name: structuralIdentity, probe: `db-ivm#structural-identity` }, + { name: recordsMerged, probe: `db-ivm#records-merged` }, + { name: joinTuplesMerged, probe: `db-ivm#join-tuples-merged` }, + { name: pairsWithoutKey, probe: `db-ivm#only-pairs-without-key` }, + ], + // Each state's constructed history enters it in every campaign. The + // constructed history keeps the state's witness, then generated steps. + constructed: [ + { + name: `a keyed multiset`, + enters: [keyedIdentity], + build: (generated) => + continuing(`keyed`, [[[0, { kind: `obj`, i: 0 }], 1]], generated), + }, + { + name: `a single-number multiset`, + enters: [primitiveIdentity], + build: (generated) => continuing(`numbers`, [[1, 1]], generated), + }, + { + name: `a multiset of one fresh object`, + enters: [structuralIdentity], + build: (generated) => + continuing(`structural`, [[{ fresh: `object`, v: 0 }, 1]], generated), + }, + { + name: `two equal strings`, + enters: [primitiveIdentity, recordsMerged], + build: (generated) => + continuing( + `strings`, + [ + [`p`, 1], + [`p`, 1], + ], + generated, + ), + }, + { + name: `two fresh join tuples with the same elements`, + enters: [keyedIdentity, recordsMerged, joinTuplesMerged], + build: (generated) => { + const tuple = { + kind: `tuple` as const, + a: { kind: `prim` as const, i: 0 }, + b: { kind: `prim` as const, i: 1 }, + } + return continuing( + `tuples`, + [ + [[0, tuple], 1], + [[0, tuple], 1], + ], + generated, + ) + }, + }, + { + name: `two equal pairs without a key`, + enters: [structuralIdentity, pairsWithoutKey, recordsMerged], + build: (generated) => + continuing( + `unkeyedPairs`, + [ + [[0, 0], 1], + [[0, 0], 1], + ], + generated, + ), + }, ], runs: 300, fixedSeeds: [2026929], @@ -689,12 +908,12 @@ oracleProperty({ fixed: [ ...collisionCases.map(({ name }) => ({ name, - enters: [`keyedCollision`], + enters: [keyedIdentity], history: witness(name), })), { name: `keyed object values merge by reference, not by contents`, - enters: [`keyed`], + enters: [keyedIdentity, recordsMerged], history: { mode: `keyed`, steps: [ @@ -706,7 +925,7 @@ oracleProperty({ }, { name: `fresh join tuples with the same elements merge`, - enters: [`keyed`, `records merged`], + enters: [keyedIdentity, recordsMerged, joinTuplesMerged], history: { mode: `keyed`, steps: [ @@ -748,7 +967,7 @@ oracleProperty({ }, { name: `arrays of other lengths stay reference values`, - enters: [`keyed`], + enters: [keyedIdentity], history: { mode: `keyed`, steps: [ @@ -759,7 +978,7 @@ oracleProperty({ }, { name: `one non-keyed record makes equal contents merge structurally`, - enters: [`fallback`, `records merged`], + enters: [structuralIdentity, recordsMerged], history: { mode: `fallback`, steps: [ @@ -770,9 +989,9 @@ oracleProperty({ }, { name: `single-number data merges -0 with 0 and returns 0`, - // A Set already treats -0 and 0, and NaN and NaN, as one record, so - // this merge does not count as `records merged`. - enters: [`numbers`], + // -0 and 0 are one identity under rule 2, so the second record merges + // into the first; NaN merges with NaN in the same way. + enters: [primitiveIdentity, recordsMerged], history: { mode: `numbers`, steps: [ @@ -785,7 +1004,7 @@ oracleProperty({ }, { name: `single-string data merges equal strings`, - enters: [`strings`, `records merged`], + enters: [primitiveIdentity, recordsMerged], history: { mode: `strings`, steps: [ @@ -797,7 +1016,7 @@ oracleProperty({ }, { name: `pairs without a key merge equal object contents structurally`, - enters: [`structural`, `only pairs without a key`, `records merged`], + enters: [structuralIdentity, pairsWithoutKey, recordsMerged], history: { mode: `structural`, steps: [ @@ -809,7 +1028,7 @@ oracleProperty({ }, { name: `unkeyed structural data keeps 1 and "1" apart`, - enters: [`structural`], + enters: [structuralIdentity], history: { mode: `structural`, steps: [ @@ -826,34 +1045,26 @@ oracleProperty({ records: buildRecords(history), }), model: ({ records }) => expectedConsolidation(records), - // A history's mode, and whether consolidation merged or cancelled records - // (fewer owed identities than distinct input records). - reached: ({ mode, records }, _trace, expected) => [ - mode, - ...(expected.groups.length < new Set(records.map(([data]) => data)).size - ? [`records merged`] - : []), - // Two fresh join tuples of one key with the same elements. - ...(mode === `keyed` && - expected.groups.some( - ({ first }) => - Array.isArray((first as [unknown, unknown])[1]) && - records.filter(([data]) => expected.sameIdentity(first, data)).length > - 1, - ) - ? [`join tuples merged`] - : []), - ...(records.length > 0 && - records.every( - ([data]) => - Array.isArray(data) && - data.length === 2 && - typeof data[0] !== `string` && - typeof data[0] !== `number`, - ) - ? [`only pairs without a key`] - : []), - ], + // Each state, from the history alone. The model's identity grouping supplies + // the rule and the member counts; no production code or trace is read. + predict: ({ records }) => { + const expected = expectedConsolidation(records) + return { + [keyedIdentity]: expected.rule === `keyed`, + [primitiveIdentity]: expected.rule === `primitive`, + [structuralIdentity]: expected.rule === `structural`, + [recordsMerged]: expected.identities.some(({ members }) => members > 1), + [joinTuplesMerged]: expected.identities.some( + ({ members, tuple }) => tuple && members > 1, + ), + [pairsWithoutKey]: + records.length > 0 && + records.every( + ([data]) => + Array.isArray(data) && data.length === 2 && !isKeyedRecord(data), + ), + } + }, driver: driveConsolidation, check: checkConsolidation, calibrations: [ diff --git a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts index eff82ae784..6050e2c379 100644 --- a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts +++ b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts @@ -91,7 +91,15 @@ function expectDigestsMatchReference( ): void { expect(digests(Stream, chunks)).toEqual(digests(Model, chunks)) } -declareOracle({ check: [expectDigestsMatchReference] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/hashing/murmur.ts#MurmurHashStream.update`, + `src/hashing/murmur.ts#MurmurHashStream.digest`, + ], + check: [expectDigestsMatchReference], +}) describe(`MurmurHashStream`, () => { it(`digests every chunk sequence as the byte-at-a-time stream does`, () => { diff --git a/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts b/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts index b75d7346fd..4f3e4b5727 100644 --- a/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts +++ b/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts @@ -5,6 +5,13 @@ import { MultiSet } from '../../src/multiset.js' import { reduce } from '../../src/operators/reduce.js' import { output } from '../../src/operators/output.js' import { oracleRuns } from '../../../db/tests/oracle-config.js' +import { declareOracle } from '../../../db/tests/oracle-declaration' + +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/reduce.ts#reduce`], +}) /** * # Does the reduce fast path emit what the general path emits? diff --git a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts index 4f25efde12..1e26017bf5 100644 --- a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts @@ -11,8 +11,18 @@ import { oraclePropertyOptions, oracleRuns, } from '../../../db/tests/oracle-config.js' +import { declareOracle } from '../../../db/tests/oracle-declaration' import { TopKMessageTracker, TopKRelation } from './topk-relation-oracle.js' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/operators/topKWithFractionalIndex.ts#topKWithFractionalIndex`, + `src/operators/join.ts#join`, + ], +}) + // Helper function to check if indices are in lexicographic order function checkLexicographicOrder(results: Array) { // Extract values and their indices diff --git a/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts b/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts index f76b452f72..0442bcafe6 100644 --- a/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts @@ -8,6 +8,13 @@ import { topKBatch } from '../../src/operators/topKState.js' import { D2 } from '../../src/d2.js' import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalIndex.js' import { output } from '../../src/operators/index.js' +import { declareOracle } from '../../../db/tests/oracle-declaration' + +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/topKState.ts#topKBatch`], +}) afterEach(() => vi.restoreAllMocks()) diff --git a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts index 604e5f2b0a..ca7a3b199c 100644 --- a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts @@ -21,7 +21,14 @@ import { import { TopKRelation } from './topk-relation-oracle.js' import type { TopKWithFractionalIndexOptions } from '../../src/operators/topKWithFractionalIndex.js' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/operators/topKWithFractionalIndex.ts#topKWithFractionalIndex`, + `src/operators/groupedTopKWithFractionalIndex.ts#groupedTopKWithFractionalIndex`, + `src/operators/orderBy.ts#orderByWithFractionalIndex`, + ], check: [TopKRelation, checkNumericWindow], model: [selectedWindowKeys, requestedWindowChanged], }) diff --git a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts index a07e11ccde..1ec75642f8 100644 --- a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts @@ -367,7 +367,16 @@ function expectAgreement( expect(equal, `a shared identity: ${leftIdentity}`).toBe(true) } -declareOracle({ check: [expectAgreement], model: [normal] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/hashing/hash.ts#structuralIdentity`, + `src/hashing/hash.ts#equalHashValues`, + ], + check: [expectAgreement], + model: [normal], +}) function decisions(...values: Array): () => boolean { let index = 0 diff --git a/packages/db-ivm/tests/temporal-group-key-oracle.test.ts b/packages/db-ivm/tests/temporal-group-key-oracle.test.ts index 5bb5b1e490..1f70a74493 100644 --- a/packages/db-ivm/tests/temporal-group-key-oracle.test.ts +++ b/packages/db-ivm/tests/temporal-group-key-oracle.test.ts @@ -77,7 +77,12 @@ function expectedGroupCounts(classes: ReadonlyArray): Array { } return [...counts.values()].sort((left, right) => left - right) } -declareOracle({ model: [expectedGroupCounts] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/groupBy.ts#groupBy`], + model: [expectedGroupCounts], +}) function observedGroupCounts(rows: Array<{ date: object }>): Array { const graph = new D2() diff --git a/packages/db/tests/oracle-campaign.ts b/packages/db/tests/oracle-campaign.ts index 225b5ba487..15825c66c8 100644 --- a/packages/db/tests/oracle-campaign.ts +++ b/packages/db/tests/oracle-campaign.ts @@ -60,6 +60,12 @@ export async function runOracleCampaign( ...campaign.parameters, seed: oracleCampaignSeed(campaign.parameters.seed, prefix), } + // The gap report counts a kill only on a reproducible route, so each + // campaign registers its own: the fixed seed and a recorded replay are + // reproducible, and the random campaign draws a new seed in every CI run. + globalThis.__oracleRoute?.( + campaign.name === `random` ? `random seed` : `fixed seed`, + ) try { await run(reach, parameters) reach.assertReached() diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 41097149f6..2b2042088c 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -59,6 +59,7 @@ const staticOracleProperties = [ `react.window-render`, `preact.window-render`, `lint.part-reach`, + `lint.probe-form`, `oracle.declared-reach`, `scripts.lint-scope`, `scripts.run-budget`, @@ -162,6 +163,9 @@ const staticOracleProperties = [ `electric.valid-batch-partition`, `ivm.index-refinement`, `offline.key-scheduler-fifo-ledger`, + `scripts.gap-report`, + `scripts.gap-report-static`, + `scripts.gap-report-classify`, `offline.interruption-settlement`, `offline.fifo-retry`, `offline.leadership.serial-work`, @@ -811,6 +815,17 @@ function randomParameters( replayProperty: undefined, } const selectedSeed = oracleCampaignSeed(campaignSeed, property) + // These are a random campaign's parameters; a path replay runs one + // recorded history. The gap report reads the route only from a test that + // builds its parameters while it runs, so a campaign whose parameters are + // built when its file loads keeps an unknown route. + globalThis.__oracleRoute?.( + property !== undefined && + replayPath !== undefined && + replayProperty === property + ? `fixed history` + : `random seed`, + ) const parameters: OracleRunParameters = campaignSeed === undefined ? { numRuns, seed: selectedSeed } diff --git a/packages/db/tests/oracle-declaration.ts b/packages/db/tests/oracle-declaration.ts index f56143d90e..1ec69613c6 100644 --- a/packages/db/tests/oracle-declaration.ts +++ b/packages/db/tests/oracle-declaration.ts @@ -28,4 +28,10 @@ export function declareOracle(_parts: { // A type-level oracle's model is a type, which has no value to pass, so it // is named: each name is a type alias or interface in the same file. typeModel?: ReadonlyArray + // The production code this oracle owns: a source path relative to the + // package (`src/x.ts`), or one of its functions (`src/x.ts#run`, + // `src/x.ts#Class.method`), whose static call tree it then owns. The gap + // report judges each function by the tests of all its owners, so it reads + // this list statically: it must be a literal list of literal strings. + owns?: ReadonlyArray }): void {} diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 991b69a970..2361050655 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -97,6 +97,26 @@ const ownerGroups: ReadonlyArray< `lint`, `part-reach`, ], + [ + `../scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js`, + `lint`, + `probe-form`, + ], + [ + `../scripts/oracle-gaps/gap-report-oracle.property.test.mjs`, + `scripts`, + `gap-report`, + ], + [ + `../scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs`, + `scripts`, + `gap-report-static`, + ], + [ + `../scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs`, + `scripts`, + `gap-report-classify`, + ], [`../scripts/run-budget-oracle.property.test.js`, `scripts`, `run-budget`], [`../scripts/lint-scope-oracle.property.test.js`, `scripts`, `lint-scope`], [ diff --git a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts index 9a4849a283..7de80f732a 100644 --- a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts @@ -3,10 +3,17 @@ import fc from 'fast-check' import { vi } from 'vitest' import { KeyScheduler } from '../src/executor/KeyScheduler' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' +import { declareOracle } from '../../db/tests/oracle-declaration' import { BASE_TIME, createTransaction } from './key-scheduler-support' import type { Observation } from '../../db/tests/oracle-property' import type { OfflineTransaction } from '../src/types' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/executor/KeyScheduler.ts`], +}) + type Command = | { type: `schedule` @@ -20,7 +27,9 @@ type Command = | { type: `complete` } | { type: `fail` } | { type: `retry`; delay: number; payload: number } - | { type: `bulkUpdate`; payload: number } + | { type: `retryRemoved`; delay: number; payload: number } + | { type: `release` } + | { type: `bulkUpdate`; payload: number; reset: boolean } | { type: `remove`; ids: Array } | { type: `advance`; duration: number } | { type: `clear` } @@ -67,50 +76,94 @@ type Snapshot = { * work. Failure updates retry data before releasing the active transaction, * without changing its place. Replay reconciliation may retire only unissued * IDs. Clear retires all scheduler work and leaves the scheduler reusable. + * Scheduling an ID that is already pending is a duplicate schedule: `schedule` + * returns false, which TransactionExecutor uses during outbox replay to tell + * newly loaded transactions from ones it already holds. The model also keeps + * the first record, its place, payload, and retry data; that part has no + * contract source yet and is stated as a limit. * * Model: a declarative ledger, fake clock, stable creation sequence, and active * ID predict scheduler observations. The model does not import scheduler state * or production classifiers. * - * History grammar: legal commands schedule, inspect, start, fulfill, reject, - * update one or all pending records, reconcile one replay snapshot, advance - * the clock, and clear. A reject is followed immediately by its retry update - * and active release, matching TransactionExecutor's update-then-release order. - * IDs are unique while pending. At most five IDs exist in generated histories. - * Slot choice prevents duplicate pending IDs; creation time controls FIFO order; - * delay and clock advance control readiness; payload makes updates observable. - * The selector reaches every legal command, including removal during a run; - * it is unbiased, so each legal command is about equally likely. - * Histories use 1-50 tokens plus automatic retry steps. Slots span 0-4, - * creation times 0-3 seconds, delays and advances 0-5 seconds, and payloads - * -5 through 5. The domain includes equal creation times, zero and positive - * delays, and negative and positive payloads. - * The grammar excludes duplicate pending IDs, start without eligible work, - * completion without active work, and any intervening command before retry. + * History grammar: the commands are `schedule`, `getNext`, `start`, + * `complete`, `fail`, `release`, `bulkUpdate`, `remove`, `advance`, and + * `clear`, named for the scheduler calls TransactionExecutor makes. A `fail` + * is settled at once, as TransactionExecutor settles a failure: by `retry` + * (update the record, then release it), by `complete` (a terminal removal), or + * by `retryRemoved` (update, then remove because the outbox row is gone, then + * release). A `release` without an update is the executor's storage-failure + * path. A `bulkUpdate` changes every payload, and may reset every deadline to + * now, as `resetRetryDelays` does. A schedule takes a free slot `tx-0` to + * `tx-6`, schedules `missing`, the ID that replay snapshots name without the + * scheduler holding it, or duplicates a pending ID, as outbox replay does. + * At most seven IDs are pending; that is the grammar's bound, and production + * has none. Creation time controls FIFO order and never changes after + * scheduling; delay and clock advance control readiness; payload makes updates + * observable. Each token picks uniformly among the legal choices, and a + * removal choice folds four modes. Histories use 1-50 tokens plus the + * follow-up of each failure. Slots span 0-6, creation times 0-3 seconds, + * delays and advances 0-5 seconds, and payloads -5 through 5. The grammar + * excludes start without eligible work, completion without active work, and + * any command between a failure and its settlement. * * Production driver and refinement check: the driver calls the real * executor-facing scheduler methods and records, after every command, the next * eligible ID and payload, ordered pending records, counts, and active state, - * plus what `start` was given and what a removal returned. The model computes - * the same records from the ledger alone; the check compares them per command, - * each field under its law: `fifo` (the next eligible ID), `update` (payloads), - * `ledger` (ordered pending records and counts), `serial` (the active count), - * and `retirement` (what a removal or clear retires). + * plus what `schedule` returned, what `start` was given, and what a removal + * returned. A retry's update is recorded before its release, so a scheduler + * that releases on update is visible. After each production call it records + * the command as reach, and it records situations the laws turn on: a + * duplicate rejected with six or more pending, a duplicate of the active ID, + * clear while active, a removal naming an unheld ID or the active ID with a + * waiting sibling, and a blocked head with a ready sibling. A `fail` makes no + * scheduler call and records no reach. The model computes the same records + * from the ledger alone; the check compares them per checkpoint, each field + * under its law: `dedupe` (whether a schedule was accepted), `fifo` (the next + * eligible ID), `update` (payloads), `ledger` (ordered pending records and + * counts), `serial` (the active count), and `retirement` (what a removal or + * clear retires). * * Reach and controls: one fixed history reaches every transition. Two fixed - * histories cross retry-deadline order, and two more fix an active removal - * with a waiting sibling and a blocked FIFO head at its retry deadline. Five - * calibrations prove the comparison rejects bypass, double issue, stale - * payload, stale clear, and failed selective retirement, and three more reject - * a stale next payload, revoked work kept in the pending ledger, and a wrong - * count. The property is `offline.key-scheduler-fifo-ledger` on - * `oracleProperty`; a failure prints the seed, path, and property to set to - * replay it, and a path replay runs only the random campaign. + * histories cross retry-deadline order, two more fix an active removal with a + * waiting sibling and a blocked FIFO head at its retry deadline, one + * duplicates a pending ID with another creation time and payload, one + * schedules `missing` after a replay snapshot named it, one crosses a release, + * a terminal failure, and a removed retry, and one resets a delayed head's + * deadline. The declared state `duplicate schedule` is bound to the + * scheduler's `duplicate-schedule` probe: the model predicts, from the + * history alone, which histories duplicate a pending ID, the probe reports + * which ones production rejected, and the two must agree in every history + * (law `reach agreement`). A constructed history fills six slots and then + * duplicates one, so every campaign reaches `duplicate schedule with six or + * more pending`, which the driver records. Five calibrations prove the comparison rejects bypass, double + * issue, stale payload, stale clear, and failed selective retirement; three + * more reject a stale next payload, revoked work kept in the pending ledger, + * and a wrong count; one more rejects an accepted duplicate. The property is + * `offline.key-scheduler-fifo-ledger` on `oracleProperty`; a failure prints + * the seed, path, and property to set to replay it, and a path replay runs + * only the random campaign. * * Limits: persistence, caller promise settlement, leadership, and real timers * have separate owners. The model does not promise fairness beyond FIFO order. + * Creation times never change after scheduling (maintainer decision, + * 2026-11-04). The executor's promise that reloaded work can run at once + * belongs to the outbox replay owner; this oracle owns only that a deadline + * reset reaches every record. Keeping the first record on a duplicate schedule + * awaits a maintainer decision. */ +// Seven slots `tx-0` to `tx-6`, and slot 7 for `missing`, the ID that replay +// snapshots name without the scheduler holding it. Production has no pending +// bound; seven is the grammar's bound, so six or more pending is reachable. +const SLOTS = 7 +const MISSING_SLOT = SLOTS +const MAX_PENDING = SLOTS +const idOf = (slot: number) => + slot === MISSING_SLOT ? `missing` : `tx-${slot}` +const slotOf = (id: string) => + id === `missing` ? MISSING_SLOT : Number(id.slice(`tx-`.length)) + type CommandToken = { selector: number slot: number @@ -144,7 +197,7 @@ const commandToken = fc.record({ // Unbiased: fast-check biases small naturals, which picked the first few // choices almost always, so start, fail, and retry were rarely generated. selector: fc.noBias(fc.nat()), - slot: fc.integer({ min: 0, max: 4 }), + slot: fc.integer({ min: 0, max: SLOTS - 1 }), createdAt: fc.integer({ min: 0, max: 3 }), delay: fc.integer({ min: 0, max: 5 }), payload: fc.integer({ min: -5, max: 5 }), @@ -165,8 +218,8 @@ function planningNext(state: PlanningState): PlanningEntry | undefined { } function nextFreeSlot(state: PlanningState, preferred: number): number { - for (let offset = 0; offset < 5; offset++) { - const slot = (preferred + offset) % 5 + for (let offset = 0; offset < SLOTS; offset++) { + const slot = (preferred + offset) % SLOTS if (!state.pending.some(({ id }) => id === `tx-${slot}`)) return slot } throw new Error(`No transaction slot is available`) @@ -177,8 +230,9 @@ function applyPlanningCommand( nextCommand: Command, ): void { if (nextCommand.type === `schedule`) { + if (state.pending.some(({ id }) => id === idOf(nextCommand.slot))) return state.pending.push({ - id: `tx-${nextCommand.slot}`, + id: idOf(nextCommand.slot), createdAt: BASE_TIME + nextCommand.createdAt * 1000, nextAttemptAt: state.now + nextCommand.delay * 1000, sequence: state.nextSequence++, @@ -188,6 +242,16 @@ function applyPlanningCommand( } else if (nextCommand.type === `complete`) { state.pending = state.pending.filter(({ id }) => id !== state.activeId) state.activeId = undefined + state.retryableId = undefined + } else if (nextCommand.type === `release`) { + state.activeId = undefined + } else if (nextCommand.type === `retryRemoved`) { + state.pending = state.pending.filter(({ id }) => id !== state.retryableId) + state.activeId = undefined + state.retryableId = undefined + } else if (nextCommand.type === `bulkUpdate`) { + if (nextCommand.reset) + for (const entry of state.pending) entry.nextAttemptAt = state.now } else if (nextCommand.type === `fail`) { state.retryableId = state.activeId } else if (nextCommand.type === `retry`) { @@ -220,7 +284,13 @@ function buildLegalHistory(tokens: Array): Array { for (const token of tokens) { const choices: Array = [ { type: `getNext` }, - { type: `bulkUpdate`, payload: token.payload }, + // A reset is TransactionExecutor's resetRetryDelays: every pending + // deadline becomes now, in one batch. + { + type: `bulkUpdate`, + payload: token.payload, + reset: token.duration % 2 === 1, + }, { type: `advance`, duration: token.duration }, { type: `clear` }, ] @@ -241,7 +311,18 @@ function buildLegalHistory(tokens: Array): Array { }) } - if (state.pending.length < 5) { + if ( + state.pending.length < MAX_PENDING && + !state.pending.some(({ id }) => id === `missing`) + ) + choices.push({ + type: `schedule`, + slot: MISSING_SLOT, + createdAt: token.createdAt, + delay: token.delay, + payload: token.payload, + }) + if (state.pending.length < MAX_PENDING) { choices.push({ type: `schedule`, slot: nextFreeSlot(state, token.slot), @@ -250,6 +331,17 @@ function buildLegalHistory(tokens: Array): Array { payload: token.payload, }) } + if (state.pending.length > 0) { + // Outbox replay schedules transactions the scheduler may already hold. + const duplicated = state.pending[token.slot % state.pending.length]! + choices.push({ + type: `schedule`, + slot: slotOf(duplicated.id), + createdAt: token.createdAt, + delay: token.delay, + payload: token.payload, + }) + } if (state.activeId) { // A replay snapshot may name the active ID, a waiting sibling, and an // ID the scheduler does not hold. @@ -264,21 +356,30 @@ function buildLegalHistory(tokens: Array): Array { } if (planningNext(state)) choices.push({ type: `start` }) if (state.activeId) { - choices.push({ type: `complete` }, { type: `fail` }) + // A release without an update is the executor's storage-failure path. + choices.push({ type: `complete` }, { type: `fail` }, { type: `release` }) } const nextCommand = choices[token.selector % choices.length]! history.push(nextCommand) applyPlanningCommand(state, nextCommand) + // TransactionExecutor follows a failure with its retry update, a + // terminal removal, or a retry update whose transaction is then removed. if (nextCommand.type === `fail`) { - const retry: Command = { - type: `retry`, - delay: token.delay, - payload: token.payload, - } - history.push(retry) - applyPlanningCommand(state, retry) + const mode = Math.abs(token.payload) % 3 + const follow: Command = + mode === 0 + ? { type: `retry`, delay: token.delay, payload: token.payload } + : mode === 1 + ? { type: `complete` } + : { + type: `retryRemoved`, + delay: token.delay, + payload: token.payload, + } + history.push(follow) + applyPlanningCommand(state, follow) } } @@ -293,49 +394,66 @@ function tokensOf(history: ReadonlyArray): Array { for (let index = 0; index < history.length; index++) { const command = history[index]! const prefix = buildLegalHistory(tokens) - const retry = command.type === `fail` ? history[index + 1] : undefined + const follow = command.type === `fail` ? history[index + 1] : undefined + const retry = + follow?.type === `retry` || follow?.type === `retryRemoved` + ? follow + : undefined const base: CommandToken = { selector: 0, slot: command.type === `schedule` ? command.slot : 0, createdAt: command.type === `schedule` ? command.createdAt : 0, delay: - command.type === `schedule` - ? command.delay - : retry?.type === `retry` - ? retry.delay - : 0, + command.type === `schedule` ? command.delay : retry ? retry.delay : 0, payload: command.type === `schedule` || command.type === `bulkUpdate` ? command.payload - : retry?.type === `retry` + : retry ? retry.payload : 0, - duration: command.type === `advance` ? command.duration : 0, + duration: + command.type === `advance` + ? command.duration + : command.type === `bulkUpdate` && command.reset + ? 1 + : 0, } // A removal's IDs come from the token's payload and slot. + // A duplicate schedule takes its ID from the token's slot among the pending. const candidates = command.type === `remove` - ? Array.from({ length: 11 * 5 }, (_, n) => ({ + ? Array.from({ length: 11 * SLOTS }, (_, n) => ({ ...base, payload: (n % 11) - 5, slot: Math.floor(n / 11), })) - : [base] + : command.type === `schedule` + ? Array.from({ length: SLOTS }, (_, slot) => ({ ...base, slot })) + : command.type === `fail` && !retry + ? // A terminal removal's payload only selects the follow-up. + Array.from({ length: 11 }, (_, n) => ({ + ...base, + payload: n - 5, + })) + : [base] + // A failure is matched with its follow-up, which the same token decides. + const wanted = follow ? [command, follow] : [command] let found: CommandToken | undefined for (const candidate of candidates) - for (let selector = 0; selector < 12 && !found; selector++) { + for (let selector = 0; selector < 16 && !found; selector++) { const token = { ...candidate, selector } const built = buildLegalHistory([...tokens, token]) if ( - isDeepStrictEqual(built.slice(prefix.length, prefix.length + 1), [ - command, - ]) + isDeepStrictEqual( + built.slice(prefix.length, prefix.length + wanted.length), + wanted, + ) ) found = token } if (!found) throw new Error(`command ${index} is not legal here`) tokens.push(found) - if (retry?.type === `retry`) index++ + if (follow) index++ } if (!isDeepStrictEqual(buildLegalHistory(tokens), history)) throw new Error(`not a legal scheduler history`) @@ -402,13 +520,15 @@ function expectedSnapshot(model: Model): Snapshot { // // The ledger replays the commands and owes, after each one, the scheduler's // snapshot, the transaction a `start` is given, and the IDs a removal -// retires (requested pending IDs other than the active one, once each). A +// retires (requested IDs other than the active one, once each, including IDs +// the scheduler does not hold). A // command the lifecycle does not allow is a setup error with its name: the // grammar never generates one, so a fixed history or a grammar change that does is // caught here. type Owed = { snapshot: Snapshot + accepted?: boolean started?: { id: string; payload: unknown } removed?: Array } @@ -423,26 +543,32 @@ function ledgerModel(history: ReadonlyArray): Map { const illegal = (reason: string) => { throw new Error(`command ${index} (${command.type}): ${reason}`) } - // TransactionExecutor updates a failed transaction before asking for - // more work, so nothing else may come between a failure and its retry. - if (model.retryableId && command.type !== `retry`) - illegal(`retry must immediately follow fail`) + // TransactionExecutor settles a failed transaction before asking for + // more work: a retry update, a terminal removal, or a retry update whose + // transaction is then removed comes straight after the failure. + if ( + model.retryableId && + command.type !== `retry` && + command.type !== `complete` && + command.type !== `retryRemoved` + ) + illegal(`a failure must be settled before anything else`) if (command.type === `schedule`) { - if ( - model.pending.some( - ({ transaction }) => transaction.id === `tx-${command.slot}`, - ) + // Model law: a duplicate schedule of a pending ID is rejected, so the first record keeps + // its place, creation time, and payload. + extra.accepted = !model.pending.some( + ({ transaction }) => transaction.id === idOf(command.slot), ) - illegal(`tx-${command.slot} is already pending`) - model.pending.push({ - transaction: createTransaction( - `tx-${command.slot}`, - BASE_TIME + command.createdAt * 1000, - model.now + command.delay * 1000, - command.payload, - ), - sequence: model.nextSequence++, - }) + if (extra.accepted) + model.pending.push({ + transaction: createTransaction( + idOf(command.slot), + BASE_TIME + command.createdAt * 1000, + model.now + command.delay * 1000, + command.payload, + ), + sequence: model.nextSequence++, + }) } else if (command.type === `start`) { const transaction = expectedNext(model) ?? illegal(`no eligible work`) extra.started = { @@ -456,10 +582,14 @@ function ledgerModel(history: ReadonlyArray): Map { ({ transaction }) => transaction.id !== model.activeId, ) model.activeId = undefined + model.retryableId = undefined + } else if (command.type === `release`) { + if (!model.activeId) illegal(`no active work`) + model.activeId = undefined } else if (command.type === `fail`) { if (!model.activeId) illegal(`no active work`) model.retryableId = model.activeId - } else if (command.type === `retry`) { + } else if (command.type === `retry` || command.type === `retryRemoved`) { if (!model.retryableId) illegal(`no failure to retry`) const entry = model.pending.find( ({ transaction }) => transaction.id === model.retryableId, @@ -470,12 +600,21 @@ function ledgerModel(history: ReadonlyArray): Map { nextAttemptAt: model.now + command.delay * 1000, metadata: { payload: command.payload }, } + // Between the update and the release, the transaction is still active. + owed.set(`${index}: ${command.type} update`, { + snapshot: expectedSnapshot(model), + }) + if (command.type === `retryRemoved`) + model.pending = model.pending.filter( + ({ transaction }) => transaction.id !== model.retryableId, + ) model.activeId = undefined model.retryableId = undefined } else if (command.type === `bulkUpdate`) { for (const entry of model.pending) entry.transaction = { ...entry.transaction, + ...(command.reset ? { nextAttemptAt: model.now } : {}), metadata: { payload: command.payload }, } } else if (command.type === `advance`) { @@ -537,28 +676,59 @@ function driveScheduler( let active: OfflineTransaction | undefined for (const [index, command] of history.entries()) { const extra: Partial = {} + // Reach records what production did, after it did it. + const reached = (state: string) => observe(`reach`, state) if (command.type === `schedule`) { - scheduler.schedule( + const crowded = scheduler.getPendingCount() >= 6 + const id = idOf(command.slot) + extra.accepted = scheduler.schedule( createTransaction( - `tx-${command.slot}`, + id, BASE_TIME + command.createdAt * 1000, Date.now() + command.delay * 1000, command.payload, ), ) + reached(`schedule`) + if (!extra.accepted && crowded) reached(duplicateWhenCrowded) + if (!extra.accepted && active?.id === id) + reached(`duplicate of the active ID`) } else if (command.type === `getNext`) { - scheduler.getNext() + const next = scheduler.getNext() + reached(`getNext`) + const [head, sibling] = scheduler.getAllPendingTransactions() + if ( + !next && + scheduler.getRunningCount() === 0 && + head && + sibling && + head.nextAttemptAt > Date.now() && + sibling.nextAttemptAt <= Date.now() + ) + reached(`blocked head with a ready sibling`) } else if (command.type === `start`) { const next = scheduler.getNext() extra.started = next ? { id: next.id, payload: next.metadata?.payload } : undefined - if (next) scheduler.markStarted(next) + if (next) { + scheduler.markStarted(next) + reached(`start`) + } active = next && cloneTransaction(next) } else if (command.type === `complete`) { - if (active) scheduler.markCompleted(active) + if (active) { + scheduler.markCompleted(active) + reached(`complete`) + } active = undefined - } else if (command.type === `retry`) { + } else if (command.type === `release`) { + if (active) { + scheduler.markFailed(active) + reached(`release`) + } + active = undefined + } else if (command.type === `retry` || command.type === `retryRemoved`) { if (active) { scheduler.updateTransaction({ ...cloneTransaction(active), @@ -566,22 +736,46 @@ function driveScheduler( nextAttemptAt: Date.now() + command.delay * 1000, metadata: { payload: command.payload }, }) + observe(`${index}: ${command.type} update`, { + snapshot: snapshotOf(scheduler), + }) + // TransactionExecutor removes a transaction whose outbox row is + // gone, then releases it in its finally block. + if (command.type === `retryRemoved`) scheduler.markCompleted(active) scheduler.markFailed(active) + reached(command.type) } active = undefined } else if (command.type === `bulkUpdate`) { scheduler.updateTransactions( scheduler.getAllPendingTransactions().map((transaction) => ({ ...cloneTransaction(transaction), + ...(command.reset ? { nextAttemptAt: Date.now() } : {}), metadata: { payload: command.payload }, })), ) + reached(`bulkUpdate`) } else if (command.type === `advance`) { vi.advanceTimersByTime(command.duration * 1000) + reached(`advance`) } else if (command.type === `remove`) { + const held = new Set( + scheduler.getAllPendingTransactions().map(({ id }) => id), + ) + if (command.ids.some((id) => !held.has(id))) + reached(`removal names an unheld ID`) + if ( + active && + command.ids.includes(active.id) && + command.ids.some((id) => id !== active!.id && held.has(id)) + ) + reached(`removal names the active ID and a waiting sibling`) extra.removed = scheduler.removePendingTransactions(command.ids) + reached(`remove`) } else if (command.type === `clear`) { + if (scheduler.getRunningCount() === 1) reached(`clear while active`) scheduler.clear() + reached(`clear`) active = undefined } observe(checkpointOf(index, command), { @@ -615,6 +809,13 @@ function checkLedger( `the driver recorded no "${checkpoint}"`, )! const wrong = + differs( + checkpoint, + `dedupe`, + seen.accepted, + expected.accepted, + `schedule reported another acceptance`, + ) ?? differs( checkpoint, `fifo`, @@ -686,12 +887,32 @@ const commandTypes = [ `start`, `fail`, `retry`, + `retryRemoved`, + `release`, `bulkUpdate`, `remove`, `advance`, `complete`, `clear`, ] as const +// Situations the laws turn on, recorded by the driver from production. +const observedStates = [ + `clear while active`, + `duplicate of the active ID`, + `removal names an unheld ID`, + `removal names the active ID and a waiting sibling`, + `blocked head with a ready sibling`, +] as const +// The driver records no reach for a failure: the scheduler sees a failure +// only through the update and release that settle it. +const executedCommands = commandTypes.filter((type) => type !== `fail`) +// A history enters this state when it duplicates a pending ID. Production +// marks it with a probe, so its reach is an agreement between model and +// production. +const duplicateSchedule = `duplicate schedule` +// The driver records a duplicate that production rejected with six or more +// transactions pending; the grammar bound is seven, and production has none. +const duplicateWhenCrowded = `duplicate schedule with six or more pending` const schedule = ( slot: number, createdAt: number, @@ -703,8 +924,10 @@ const schedule = ( const corruptFirst = (applies: (value: Owed, checkpoint: string) => Owed | undefined) => (trace: ReadonlyArray) => { + // Reach records are not ledger records, so no corruption applies to them. const at = trace.findIndex( ({ checkpoint, value }) => + checkpoint !== `reach` && applies(value as Owed, checkpoint) !== undefined, ) if (at < 0) return undefined @@ -718,21 +941,53 @@ const corruptFirst = oracleProperty({ id: `offline.key-scheduler-fifo-ledger`, law: { - statement: `KeyScheduler is one globally serial FIFO queue: equal creation times keep scheduling order, a delayed head blocks younger work, a failure updates retry data before releasing the active transaction without moving it, replay reconciliation retires only unissued IDs, and clear retires all work and leaves the scheduler reusable.`, - authority: `the established KeyScheduler FIFO tests and TransactionExecutor's calling order (packages/offline-transactions/src/executor)`, - limits: `Persistence, caller promise settlement, leadership, and real timers have separate owners; no fairness beyond FIFO order; at most five IDs.`, + statement: `KeyScheduler is one globally serial FIFO queue: equal creation times keep scheduling order, a delayed head blocks younger work, a failure updates retry data before releasing the active transaction without moving it, replay reconciliation retires only unissued IDs, clear retires all work and leaves the scheduler reusable, a removal returns the requested IDs other than the active one, which TransactionExecutor rejects, and a duplicate schedule of a pending ID returns false.`, + authority: `the established KeyScheduler FIFO tests and TransactionExecutor's calling order (packages/offline-transactions/src/executor); for duplicate schedules, TransactionExecutor's outbox replay, which uses schedule's result to find newly loaded transactions`, + limits: `Persistence, caller promise settlement, leadership, and real timers have separate owners; no fairness beyond FIFO order; at most seven pending IDs, a grammar bound (production has none); creation times never change after scheduling (maintainer decision, 2026-11-04), so no update changes one; the executor's promise that reloaded work can run at once belongs to the outbox replay owner; keeping the first record on a duplicate schedule awaits a maintainer decision.`, owner: `Offline execution`, }, histories: { arbitrary: legalHistory, - states: [], - measured: commandTypes, + states: [ + { + name: duplicateSchedule, + probe: `offline-transactions#duplicate-schedule`, + }, + duplicateWhenCrowded, + ], + measured: [...commandTypes, ...observedStates], + constructed: [ + { + name: `six pending, then a duplicate`, + enters: [duplicateSchedule, duplicateWhenCrowded], + // Six schedules and a seventh that duplicates tx-0; the generated + // tokens then continue from that state, cut so the whole history + // stays within the grammar's 50 tokens. + build: (generated: Array) => { + const prefix = tokensOf([ + schedule(0, 0, 0, 1), + schedule(1, 1, 0, 1), + schedule(2, 2, 0, 1), + schedule(3, 3, 0, 1), + schedule(4, 0, 0, 1), + schedule(5, 1, 0, 1), + schedule(0, 2, 0, 2), + ]) + return buildLegalHistory([ + ...prefix, + ...tokensOf(generated).slice(0, 50 - prefix.length), + ]) + }, + }, + ], runs: 150, fixedSeeds: [1815], fixed: [ { - name: `every modeled transition`, - enters: [...commandTypes], + name: `every executed command`, + enters: executedCommands.filter( + (type) => type !== `retryRemoved` && type !== `release`, + ), history: [ schedule(0, 0, 0, 1), schedule(1, 0, 0, 2), @@ -742,7 +997,7 @@ oracleProperty({ { type: `fail` }, { type: `retry`, delay: 2, payload: 3 }, { type: `getNext` }, - { type: `bulkUpdate`, payload: 4 }, + { type: `bulkUpdate`, payload: 4, reset: false }, { type: `advance`, duration: 2 }, { type: `start` }, { type: `complete` }, @@ -770,7 +1025,7 @@ oracleProperty({ schedule(1, 1, 0, 1), { type: `start` }, { type: `fail` }, - { type: `retry`, delay: 5, payload: 1 }, + { type: `retry`, delay: 5, payload: 3 }, // tx-1 is ready but cannot bypass tx-0. { type: `getNext` }, { type: `advance`, duration: 4 }, @@ -781,6 +1036,58 @@ oracleProperty({ { type: `start` }, ], }, + { + name: `a duplicate schedule with another creation time and payload`, + enters: [`schedule`, duplicateSchedule, `start`], + history: [ + schedule(0, 2, 0, 1), + schedule(1, 1, 0, 2), + // tx-0 again, now older and with another payload: it is ignored, so + // tx-1 stays first and tx-0 keeps payload 1. + schedule(0, 0, 0, 5), + { type: `start` }, + ], + }, + { + name: `a release, a terminal failure, and a removed retry`, + enters: [`release`, `complete`, `retryRemoved`], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 1, 0, 1), + { type: `start` }, + // A storage failure releases tx-0 without an update. + { type: `release` }, + { type: `start` }, + // A terminal failure removes tx-0. + { type: `fail` }, + { type: `complete` }, + { type: `start` }, + // tx-1's retry update lands, then its outbox row is gone. + { type: `fail` }, + { type: `retryRemoved`, delay: 1, payload: 2 }, + ], + }, + { + name: `a bulk deadline reset makes a delayed head eligible`, + enters: [`bulkUpdate`, `start`], + history: [ + schedule(0, 0, 3, 1), + { type: `getNext` }, + { type: `bulkUpdate`, payload: 1, reset: true }, + { type: `start` }, + ], + }, + { + name: `missing scheduled after a replay snapshot named it`, + enters: [`remove`, `schedule`], + history: [ + schedule(0, 0, 0, 1), + // The snapshot names tx-0 and missing; the scheduler holds only tx-0. + { type: `remove`, ids: [`tx-0`, `missing`] }, + schedule(MISSING_SLOT, 0, 0, 2), + { type: `start` }, + ], + }, ...( [ [`an earlier retry deadline than its sibling`, 5], @@ -802,7 +1109,13 @@ oracleProperty({ ], }, model: ledgerModel, - reached: (history) => [...new Set(history.map(({ type }) => type))], + // The scheduler marks a duplicate schedule with a probe; the model predicts + // it from its own ledger, so the two must agree in every history. + predict: (history) => ({ + [duplicateSchedule]: [...ledgerModel(history).values()].some( + ({ accepted }) => accepted === false, + ), + }), driver: driveScheduler, check: checkLedger, calibrations: [ @@ -939,6 +1252,13 @@ oracleProperty({ }, })), }, + { + name: `a duplicate schedule is accepted`, + law: `dedupe`, + corrupt: corruptFirst((value) => + value.accepted === false ? { ...value, accepted: true } : undefined, + ), + }, { name: `a selective removal keeps the revoked work`, law: `retirement`, diff --git a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts index 9de67b52d6..04d09da378 100644 --- a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts +++ b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts @@ -40,7 +40,16 @@ function expectedAt(phase: `hidden` | `visible`): { : { providerCalls: 1, outboxEntries: 0, callerState: `fulfilled` } } -declareOracle({ model: [expectedAt] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/OfflineExecutor.ts#OfflineExecutor.getOnlineDetector`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.peekOutbox`, + ], + model: [expectedAt], +}) it.each([`visibilitychange`, `explicit retry`] as const)( `replays a committed outbox write on %s in a visible tab with a false online hint`, diff --git a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts index 15dece72eb..98d6560f8b 100644 --- a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts @@ -10,10 +10,20 @@ import { pass, violation, } from '../../db/tests/oracle-property' +import { declareOracle } from '../../db/tests/oracle-declaration' import { FakeStorageAdapter } from './harness' import type { Observation } from '../../db/tests/oracle-property' import type { OfflineTransaction } from '../src/types' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/executor/TransactionExecutor.ts#TransactionExecutor.executeAll`, + `src/executor/TransactionExecutor.ts#TransactionExecutor.clear`, + ], +}) + /** * # Why does a ready transaction wait, and what stops the queue? * @@ -65,6 +75,20 @@ import type { OfflineTransaction } from '../src/types' * and jitter off. It records provider calls, the outbox, and each caller's * settlement at each checkpoint. * + * Reach comes from what production did, never from the grammar's labels. The + * four states that the model can predict from the plans are state probes + * (glossary: state probe): production marks each one where its branch runs, + * `predict` says from the plans alone whether a history enters it, and reach + * agreement checks the two in every history. They are `retry before a queued + * peer` (a retriable failure while a transaction waits behind it), + * `permanent failure before a peer` (a non-retriable failure with a row + * behind it), `phase write fault` (the deletion-pending marker write fails), + * and `fault before a queued peer` (a storage fault stops the executor with a + * row behind it). `deletion fault` is reported by the storage fake when it + * rejects the removal that the executor issued: production reached the + * removal and the storage refused it. `retried head` and `retried tail` are + * measured only: the driver reports each provider call that fails retriably. + * * Limits: an online executor with one mutation function; no leadership * changes, no `shouldRetry` or `beforeRetry` hooks, no storage fault on a * permanent failure's removal, and no real browser timers. Backoff lengths @@ -182,35 +206,37 @@ function fifoModel(history: History) { ]) } -// Reach: which fault paths a history takes, from its plans. -const states = [ - `retried head`, - `retried tail`, - `permanent failure before a peer`, - `phase write fault`, - `deletion fault`, - `fault before a queued peer`, -] as const -function reachedBy({ plans }: History): Array { - const reached: Array = [] +// States. A probe-bound state is predicted by `predictedStates` and marked by +// a production probe; the other two are reported by the driver. +const retryBeforePeer = `retry before a queued peer` +const permanentBeforePeer = `permanent failure before a peer` +const phaseWriteFault = `phase write fault` +const faultBeforePeer = `fault before a queued peer` +const deletionFault = `deletion fault` +const measuredStates = [`retried head`, `retried tail`] as const + +/** + * Which probe-bound states a history enters, from its plans alone. The + * executor plays the plans in order and stops at the first storage fault + * (`stop`), so only the plans up to it run. A plan at `index` has a queued + * peer when `index < last`: the rows behind it are still waiting. + */ +function predictedStates({ plans }: History): Record { + const last = plans.length - 1 const stop = plans.findIndex( (entry) => entry.final === `success` && entry.storageFault !== `none`, ) const played = stop < 0 ? plans : plans.slice(0, stop + 1) - if (played[0]!.failures > 0) reached.push(`retried head`) - if (played.slice(1).some((entry) => entry.failures > 0)) - reached.push(`retried tail`) - if (played.slice(0, -1).some((entry) => entry.final === `permanent`)) - reached.push(`permanent failure before a peer`) - if (stop >= 0) { - reached.push( - plans[stop]!.storageFault === `phase` - ? `phase write fault` - : `deletion fault`, - ) - if (stop < plans.length - 1) reached.push(`fault before a queued peer`) + return { + [retryBeforePeer]: played.some( + (entry, index) => entry.failures > 0 && index < last, + ), + [permanentBeforePeer]: played.some( + (entry, index) => entry.final === `permanent` && index < last, + ), + [phaseWriteFault]: stop >= 0 && plans[stop]!.storageFault === `phase`, + [faultBeforePeer]: stop >= 0 && stop < last, } - return reached } // --------------------------------------------------------------------------- @@ -243,8 +269,10 @@ async function driveFifo( } override async delete(key: string): Promise { const index = plans.findIndex((_, i) => key.endsWith(idOf(i))) - if (index >= 0 && plans[index]!.storageFault === `delete`) + if (index >= 0 && plans[index]!.storageFault === `delete`) { + observe(`reach`, deletionFault) throw new Error(storageFailure(index)) + } return super.delete(key) } })() @@ -270,8 +298,10 @@ async function driveFifo( // An attempt the plan does not have never settles, so a runaway // executor stops at its first extra call, which the trace shows. if (attempt > failures) return new Promise(() => {}) - if (attempt < failures) + if (attempt < failures) { + observe(`reach`, index === 0 ? `retried head` : `retried tail`) return Promise.reject(new Error(retriable(index, attempt))) + } if (final === `permanent`) return Promise.reject(new NonRetriableError(permanent(index))) return Promise.resolve() @@ -415,19 +445,34 @@ oracleProperty({ }, histories: { arbitrary: fifoHistory, - states: [`phase write fault`, `fault before a queued peer`], - measured: states.filter( - (state) => - state !== `phase write fault` && state !== `fault before a queued peer`, - ), + states: [ + { + name: retryBeforePeer, + probe: `offline-transactions#retry-before-peer`, + }, + { + name: permanentBeforePeer, + probe: `offline-transactions#permanent-failure-before-peer`, + }, + { + name: phaseWriteFault, + probe: `offline-transactions#phase-write-fault`, + }, + { + name: faultBeforePeer, + probe: `offline-transactions#fault-before-peer`, + }, + deletionFault, + ], + measured: measuredStates, runs: 60, fixedSeeds: [20260916], - // A storage fault in the middle of the queue: the stop law needs a peer - // behind the fault. + // Each required state gets a history with a queued peer behind the event: + // the laws need a peer that waits, runs, or stays in the outbox. constructed: [ { name: `a phase write fault before a queued peer`, - enters: [`phase write fault`, `fault before a queued peer`], + enters: [phaseWriteFault, faultBeforePeer], build: (generated: History): History => ({ delay: generated.delay, plans: [ @@ -437,6 +482,42 @@ oracleProperty({ ], }), }, + { + name: `a deletion fault before a queued peer`, + enters: [deletionFault, faultBeforePeer], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { ...ok }, + { failures: 0, final: `success`, storageFault: `delete` }, + ...generated.plans.slice(0, 3), + ], + }), + }, + { + name: `a retry before a queued peer`, + enters: [retryBeforePeer], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { failures: 1, final: `success`, storageFault: `none` }, + { ...ok }, + ...generated.plans.slice(0, 3), + ], + }), + }, + { + name: `a permanent failure before a queued peer`, + enters: [permanentBeforePeer], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { ...ok }, + { failures: 0, final: `permanent`, storageFault: `none` }, + ...generated.plans.slice(0, 2), + ], + }), + }, ], fixed: [ { @@ -445,7 +526,7 @@ oracleProperty({ }, { name: `a retried head keeps its place`, - enters: [`retried head`], + enters: [retryBeforePeer, `retried head`], history: { delay: 500, plans: [ @@ -457,7 +538,7 @@ oracleProperty({ }, { name: `a permanent failure lets the queue continue`, - enters: [`permanent failure before a peer`], + enters: [permanentBeforePeer], history: { delay: 10, plans: [ @@ -469,7 +550,7 @@ oracleProperty({ }, { name: `a failed deletion stops the queue`, - enters: [`deletion fault`, `fault before a queued peer`], + enters: [deletionFault, faultBeforePeer], history: { delay: 10, plans: [ @@ -481,7 +562,8 @@ oracleProperty({ ], }, model: fifoModel, - reached: (history) => reachedBy(history), + // Probe-bound states are predicted from the plans, never from the trace. + predict: predictedStates, driver: driveFifo, check: checkFifo, // Wrong answers the check must reject: a ready tail that passes the diff --git a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts index b4822c314c..803e828c2b 100644 --- a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts @@ -9,6 +9,7 @@ import { violation, withinTurns, } from '../../db/tests/oracle-property' +import { declareOracle } from '../../db/tests/oracle-declaration' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import { captureUnhandledRejections, @@ -18,6 +19,16 @@ import type { Observation } from '../../db/tests/oracle-property' import type { OnlineDetector } from '../src/types' import type { TestItem } from './harness' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/OfflineExecutor.ts#startOfflineExecutor`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.dispose`, + ], +}) + /** * # Does an interruption at any point keep each offline transaction's * settlement and durable ownership? diff --git a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts index 85b33280c5..1812602699 100644 --- a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts @@ -163,7 +163,17 @@ const storageFailureOracle = readOfflineOracleConfig({ // The stale-read campaign compares each delivered read with the ledger fold, // the model. The append-only judgment fixes the fold's own result. -declareOracle({ check: [checkStaleRead], model: [foldReplayLedger] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/OfflineExecutor.ts#startOfflineExecutor`, + `src/OfflineExecutor.ts#OfflineExecutor.waitForInit`, + `src/OfflineExecutor.ts#OfflineExecutor.dispose`, + ], + check: [checkStaleRead], + model: [foldReplayLedger], +}) const startupCases = (seed: number | undefined) => [`construction`, `leadership`, `outbox read`, `retry hook`].map( diff --git a/packages/offline-transactions/tests/oracle-config.ts b/packages/offline-transactions/tests/oracle-config.ts index 343db988ea..54414e6781 100644 --- a/packages/offline-transactions/tests/oracle-config.ts +++ b/packages/offline-transactions/tests/oracle-config.ts @@ -47,5 +47,9 @@ export function oracleCampaignOptions( seed: number | undefined, ): ReturnType | { numRuns: number; seed: number } { + // A fixed seed is a reproducible route for the gap report. The random + // campaign's options registered its route when they were built, which + // happened when the file loaded, so it registers again here, in the test. + globalThis.__oracleRoute?.(seed === undefined ? `random seed` : `fixed seed`) return seed === undefined ? config.random : { numRuns: config.runs, seed } } diff --git a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts index ad19287df8..c246379cae 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -385,7 +385,18 @@ async function checkRoundtrip( // The check runs one history through production and compares every checkpoint // with the semantic model above. Generated properties, fixed histories, and the // fault calibrations all reach it. -declareOracle({ check: [checkRoundtrip] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/outbox/TransactionSerializer.ts#TransactionSerializer.serialize`, + `src/outbox/TransactionSerializer.ts#TransactionSerializer.deserialize`, + `src/outbox/OutboxManager.ts#OutboxManager.add`, + `src/outbox/OutboxManager.ts#OutboxManager.getAll`, + `src/outbox/OutboxManager.ts#OutboxManager.get`, + ], + check: [checkRoundtrip], +}) const twin: Pair = { runtime: `2024-01-01T00:00:00.000Z`, diff --git a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts index afc512390b..483bd22a96 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -2231,7 +2231,18 @@ oracleReplayTest(it, ADMISSION_PROPERTY).each([undefined])( // retry decision table and FIFO local rows, and the visibility and provider // eligibility of a started peer. Fixed scenarios whose expectations are // written by hand are not oracle tests; they live in transaction-settlement.test.ts. +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/OfflineExecutor.ts#startOfflineExecutor`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineAction`, + `src/OfflineExecutor.ts#OfflineExecutor.waitForTransactionCompletion`, + `src/OfflineExecutor.ts#OfflineExecutor.rejectTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.peekOutbox`, + `src/OfflineExecutor.ts#OfflineExecutor.dispose`, + ], check: [ checkSettlement, checkRetryDecision, diff --git a/scripts/oracle-gaps/analysis.mjs b/scripts/oracle-gaps/analysis.mjs new file mode 100644 index 0000000000..bbbddaecdc --- /dev/null +++ b/scripts/oracle-gaps/analysis.mjs @@ -0,0 +1,108 @@ +// The static half of a check: what the report owes before any test runs +// (check.mjs). It instruments each source file (schemata.mjs), reads every +// oracle file's `owns` declaration and refuses a broken one, resolves each +// function's owners through the type checker (owners.mjs), and names the +// function each mutant belongs to. It reads only files, so the CLI's own +// oracle checks it in-process. +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { ownership, readOwns } from './owners.mjs' +import { instrument } from './schemata.mjs' + +// One compiler host per TypeScript copy. The declarations under +// node_modules, such as the standard library's, are parsed once and kept, +// which makes each later program several times faster to build. +const hosts = new WeakMap() +const compilerOptions = (ts) => ({ + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: true, + noEmit: true, + skipLibCheck: true, + types: [], +}) +function hostFor(ts) { + if (!hosts.has(ts)) { + const base = ts.createCompilerHost(compilerOptions(ts)) + const kept = new Map() + hosts.set(ts, { + ...base, + getSourceFile(name, language, onError, fresh) { + if (!name.includes(`/node_modules/`)) + return base.getSourceFile(name, language, onError, fresh) + if (!kept.has(name)) + kept.set(name, base.getSourceFile(name, language, onError, fresh)) + return kept.get(name) + }, + }) + } + return hosts.get(ts) +} + +/** + * The instrumented sources, the mutants (each with its function), the + * oracle files' declarations, and the ownership. `testFiles` are the + * absolute paths of every test file the package's config includes. A + * declaration that names nothing, a list that is not a literal, or an entry + * that names a missing file or function throws, because it would hide gaps. + */ +export function analyze(ts, pkg, sources, testFiles) { + const relative = (file) => file.slice(pkg.length + 1) + // An oracle file is a test file that calls `declareOracle`. + const declarations = new Map() + for (const file of testFiles) { + const declared = readOwns(ts, file, readFileSync(file, `utf8`)) + if (!declared.declares) continue + if (declared.problem) throw new Error(`${relative(file)}: ${declared.problem}`) + for (const entry of declared.entries) { + const [source] = entry.split(`#`) + if (!existsSync(join(pkg, source))) + throw new Error( + `${relative(file)}: owns entry ${entry} names a missing file, ${source}`, + ) + } + declarations.set(file, declared.entries) + } + const instrumented = new Map() + const mutants = [] + for (const source of sources) { + const path = realpathSync(join(pkg, source)) + const result = instrument( + ts, + path, + readFileSync(path, `utf8`), + source, + mutants.length, + ) + instrumented.set(path, result.code) + mutants.push(...result.mutants) + } + // A function's owners are the oracle files whose `owns` reference trees + // contain it; only their tests judge its mutants. The type checker reads + // the package from the sources and the files owns names. + const program = ts.createProgram( + [ + ...new Set([ + ...sources, + ...[...declarations.values()].flat().map((entry) => entry.split(`#`)[0]), + ]), + ].map((source) => join(pkg, source)), + compilerOptions(ts), + hostFor(ts), + ) + const owned = ownership(ts, program, pkg, declarations) + for (const [file, entries] of declarations) + for (const entry of entries) { + const [source, fn] = entry.split(`#`) + if (fn !== undefined && !owned.has(source, fn)) + throw new Error( + `${relative(file)}: owns entry ${entry} names no function in ${source}`, + ) + } + // Each mutant belongs to the function whose lines hold it; keys and + // ownership both read this one name. + for (const mutant of mutants) + mutant.function = owned.functionAt(mutant.source, mutant.line) + return { instrumented, mutants, declarations, owned } +} diff --git a/scripts/oracle-gaps/args.mjs b/scripts/oracle-gaps/args.mjs new file mode 100644 index 0000000000..81ed73c8a0 --- /dev/null +++ b/scripts/oracle-gaps/args.mjs @@ -0,0 +1,44 @@ +// The CLI's arguments, shared by the CLI (index.mjs) and the daemon +// (daemon.mjs), which parses each request's arguments the same way. +import { realpathSync } from 'node:fs' +import { availableParallelism } from 'node:os' +import { resolve } from 'node:path' + +const flags = new Set([`daemon`, `stop-daemon`]) +export function parseArgs(argv) { + const args = {} + for (let i = 0; i < argv.length; i += 1) { + const [key, inline] = argv[i].replace(/^--/, ``).split(`=`) + args[key] = flags.has(key) ? true : (inline ?? argv[++i]) + } + if (!args.package) throw new Error(`--package is required`) + // Test reports name files by their real path (on macOS, /tmp is + // /private/tmp), so the package path is resolved the same way. + const pkg = realpathSync(resolve(args.package)) + if (args[`stop-daemon`]) return { stop: true, pkg } + if (!args.source) throw new Error(`--package and --source are required`) + return { + pkg, + daemon: args.daemon === true, + // Each warm context runs one mutant at a time with one worker. Measured on + // main's LocalStorage owners (320 mutants): 2 contexts 297 s, 4 185 s, + // 8 137 s, with identical oracle-tier classes. + contexts: Number( + args.contexts ?? Math.min(8, Math.max(2, availableParallelism() - 2)), + ), + sources: args.source.split(`,`), + seed: args.seed, + diffBase: args[`diff-base`], + out: resolve(args.out ?? `/tmp/oracle-gaps`), + only: args.only ? new Set(args.only.split(`,`)) : undefined, + // A fixed-seed random sample of this many mutants per source file, for + // surveys across many modules. + sample: args.sample ? Number(args.sample) : undefined, + // A mutant can make a test hang. Oracles that run in milliseconds do not + // need Vitest's default limits, which would make each hang cost seconds. + testTimeout: Number(args[`test-timeout`] ?? 15000), + // A directory of authored mutants (see loadAuthored). + mutants: args.mutants ? resolve(args.mutants) : undefined, + } +} + diff --git a/scripts/oracle-gaps/authored.mjs b/scripts/oracle-gaps/authored.mjs new file mode 100644 index 0000000000..ba9410e4c0 --- /dev/null +++ b/scripts/oracle-gaps/authored.mjs @@ -0,0 +1,62 @@ +// Authored mutants: plausible wrong implementations that someone other than +// the oracle's author wrote. +// +// Operator mutants (schemata.mjs) find what a small syntactic edit exposes. +// The reviewers' findings came mostly from semantic mutants instead: a +// scheduler that remembers retired IDs, a hash that collides, a retry that +// skips its write. Running such a mutant is mechanical; inventing one is +// not, and the oracle's own author tends to write the mutants the oracle +// already kills. So the runner takes them from a directory, and each one +// names its author, which the report shows. +// +// A mutant file is JSON, one object or an array of them: +// +// { +// "id": "tombstone-retired-ids", +// "source": "src/executor/KeyScheduler.ts", // relative to --package +// "find": "exact text that occurs once in the source", +// "replace": "the wrong implementation", +// "intent": "remember removed IDs it never held", +// "author": "the session or agent that wrote it" +// } +// +// `find` must occur exactly once, so a mutant cannot silently apply to the +// wrong place or stop applying after an edit; one that does not apply is +// reported as `does-not-apply`. +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const fields = [`id`, `source`, `find`, `replace`, `intent`, `author`] + +export function loadAuthored(directory) { + const mutants = [] + for (const name of readdirSync(directory).sort()) { + if (!name.endsWith(`.json`)) continue + const parsed = JSON.parse(readFileSync(join(directory, name), `utf8`)) + for (const mutant of Array.isArray(parsed) ? parsed : [parsed]) { + const missing = fields.filter( + (field) => typeof mutant[field] !== `string` || mutant[field] === ``, + ) + if (missing.length > 0) + throw new Error( + `${name}: authored mutant ${mutant.id ?? `(no id)`} lacks ${missing.join(`, `)}`, + ) + mutants.push({ ...mutant, file: name }) + } + } + const ids = mutants.map((m) => m.id) + const repeated = ids.filter((id, index) => ids.indexOf(id) !== index) + if (repeated.length > 0) + throw new Error(`authored mutant ids repeat: ${repeated.join(`, `)}`) + return mutants +} + +/** The patched source text, or undefined when `find` does not occur once. */ +export function applyAuthored(text, mutant) { + const at = text.indexOf(mutant.find) + if (at < 0 || text.indexOf(mutant.find, at + 1) >= 0) return undefined + return { + text: text.slice(0, at) + mutant.replace + text.slice(at + mutant.find.length), + line: text.slice(0, at).split(`\n`).length, + } +} diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 98f30cb508..8002029eea 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -32,6 +32,12 @@ const state = (process.env.ORACLE_GAPS_STATE ? JSON.parse(readFileSync(process.env.ORACLE_GAPS_STATE, `utf8`)) : { mutant: null, select: null, capture: null }) +// The seed travels with each run's state, so a worker that a warm context +// started under an earlier check's seed runs under this run's seed. +if (`seed` in state) { + if (state.seed === null) delete process.env.TANSTACK_DB_ORACLE_SEED + else process.env.TANSTACK_DB_ORACLE_SEED = String(state.seed) +} const fc = await import(`fast-check`).catch(() => undefined) if (fc) { // A property that takes no seed from the shared config would draw new @@ -282,11 +288,61 @@ for (const name of Object.getOwnPropertyNames(proto)) { } catch { digest.update(`${name}(unrenderable)\n`) } - return matcher.apply(this, args) + // A soft assertion (`expect.soft`) does not throw: Vitest records its + // failure among the test's errors and the test goes on. A failure is + // seen as a new assertion error there. + const soft = inTest && chai.util.flag(this, `soft`) + const errorsBefore = currentTask?.result?.errors?.length ?? 0 + try { + const returned = matcher.apply(this, args) + if ( + soft && + (currentTask?.result?.errors ?? []) + .slice(errorsBefore) + .some((error) => error?.name === `AssertionError`) + ) + recordAssertion() + return returned + } catch (error) { + // A thrown assertion error is recognized by its type, never by its + // message. + if (inTest && error instanceof chai.AssertionError) recordAssertion() + throw error + } }, }) } +// An assertion that fails is a verdict under an unnamed law, as older +// oracles judge (transitional; check.mjs), with the route active when it +// failed. +function recordAssertion() { + verdicts.set(`(unnamed)\u0000${route}`, { + property: currentFile, + law: `(unnamed)`, + checkpoint: `assertion`, + route, + }) +} +let currentTask + +// Each law violation the oracle framework raises, so the runner classifies +// a kill by the law that rejected the mutant rather than by error text. +let verdicts = new Map() +globalThis.__oracleVerdict = ({ property, law, checkpoint }) => { + // A verdict after its test ended cannot be attributed to it. + if (!inTest) return + // A verdict carries the route active when it fired, so a test that runs + // several campaigns is attributed per campaign. + const key = `${property}\u0000${law}\u0000${checkpoint}\u0000${route}` + verdicts.set(key, { property, law, checkpoint, route }) +} +// The route the framework registered for the current test (index.mjs routeOf). +let route +globalThis.__oracleRoute = (registered) => { + if (inTest) route = registered +} + globalThis.__oracleObserve = (checkpoint, value) => { observed.update(`${checkpoint}(${render(value)})\n`) observations += 1 @@ -294,6 +350,7 @@ globalThis.__oracleObserve = (checkpoint, value) => { beforeEach((context) => { currentFile = context.task.file?.filepath + currentTask = context.task observed = createHash(`sha1`) observations = 0 if (selected && !selected.has(testKey(context.task))) context.skip() @@ -304,6 +361,8 @@ beforeEach((context) => { identities = new Map() hits = new Set() outcomes = new Map() + verdicts = new Map() + route = undefined inTest = true }) @@ -319,6 +378,8 @@ afterEach((context) => { observed: observations > 0 ? observed.digest(`hex`) : undefined, hits: [...hits], outcomes: [...outcomes], + verdicts: [...verdicts.values()], + route, })}\n`, ) hits = new Set() diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs new file mode 100644 index 0000000000..6b4c45f967 --- /dev/null +++ b/scripts/oracle-gaps/check.mjs @@ -0,0 +1,687 @@ +// One gap check over a package, cold or through the daemon (index.mjs, +// daemon.mjs). +// +// The evidence of a check is a set of units. A unit is one Vitest run of one +// test file under one mutant spec: an unmutated pass, an operator mutant, or +// an authored patch. Its result is plain data, the outcomes of its tests, +// their errors, its capture rows, and the modules it imported, so the +// classification (`outcomeOf` and the mutant loop) is a pure function of the +// units, the ownership, and the options. A cold run computes every unit. The +// daemon keeps each unit in its evidence store with the hash of every module +// the run imported, as Vitest reports them, and of what was served for each +// instrumented or patched source; a stored unit is reused only while every +// one of those hashes is unchanged. So a check through the daemon reports +// what a cold run reports (the CLI oracle's law 9). Not covered: a file a +// test reads through `fs`, an environment variable other than the seed, and +// a new file that changes how an unchanged import resolves. +// +// A unit's identity also carries the global inputs: this CLI's own files, +// the harness, the Vite config and the files it loads, the package's +// manifest and the lockfile, the Node version, and the options that change a +// run (the seed, the test timeout, and bail). +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative as relativePath } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { applyAuthored, loadAuthored } from './authored.mjs' +import { assignKeys, renderReport } from './report.mjs' +import { analyze } from './analysis.mjs' +import { + baselineOf, + judgeAuthored, + judgeMutant, + judgeOrdinary, + needsOrdinary, + outcomeOf, + targetsOf, +} from './classify.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const sha1 = (text) => createHash(`sha1`).update(text).digest(`hex`) +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// This CLI's own code and the framework it reads. They are part of the run +// identity and of every unit's identity. +const ownFiles = [ + `index.mjs`, + `args.mjs`, + `check.mjs`, + `analysis.mjs`, + `classify.mjs`, + `schemata.mjs`, + `owners.mjs`, + `report.mjs`, + `authored.mjs`, + `capture-setup.mjs`, + `evidence.mjs`, + `daemon.mjs`, + `../../packages/db/tests/oracle-property.ts`, +].map((file) => join(here, file)) + +/** Hashes of files by content, memoized by size and modification time. */ +function fileHashes() { + const memo = new Map() + return (path) => { + let stat + try { + stat = statSync(path) + } catch { + return `missing` + } + const stamp = `${stat.size}:${stat.mtimeMs}` + const known = memo.get(path) + if (known?.stamp === stamp) return known.hash + const hash = sha1(readFileSync(path)) + memo.set(path, { stamp, hash }) + return hash + } +} + +/** The lockfile that pins the package's dependencies, if any. */ +function lockfileOf(pkg) { + for (let dir = pkg; ; dir = dirname(dir)) { + for (const name of [`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`]) + if (existsSync(join(dir, name))) return join(dir, name) + if (dirname(dir) === dir) return undefined + } +} + +/** + * A session over one package: the TypeScript and Vitest that the package + * resolves, and warm Vitest contexts that later checks reuse. A cold run + * opens a session for one check; the daemon keeps one open. + */ +export async function openSession(pkg) { + const requireTarget = createRequire(join(pkg, `package.json`)) + const ts = requireTarget(`typescript`) + const { createVitest } = await import( + pathToFileURL(requireTarget.resolve(`vitest/node`)).href + ) + // Each process uses its own harness files, so a cold run beside the daemon + // cannot remove the daemon's. + const wrapper = join(pkg, `.oracle-gaps-${process.pid}.vitest.config.ts`) + const harness = join(pkg, `.oracle-gaps-${process.pid}.capture-setup.mjs`) + copyFileSync(join(here, `capture-setup.mjs`), harness) + writeFileSync( + wrapper, + `import { realpathSync } from 'node:fs' +import { mergeConfig } from 'vitest/config' +import base from './vite.config.ts' +export default mergeConfig(base, { + plugins: [{ + name: 'oracle-gaps-schemata', + enforce: 'pre', + load(id) { + const path = id.split('?')[0] + // An authored mutant's patched source, or else the instrumented source + // of the current check, both set by the runner in this process. + try { + const real = realpathSync(path) + return globalThis.__oracleGapsServe?.get(real) ?? globalThis.__oracleGapsSchemata?.get(real) + } catch { return undefined } + }, + }], + test: { setupFiles: ['./${harness.split(`/`).at(-1)}'] }, +}) +`, + ) + // Vitest resolves test-file filters and parts of its project setup against + // the working directory, so the runner works from the package directory. + process.chdir(pkg) + const hashOf = fileHashes() + // The text served for each path in the current check, and the hash each + // context last served, so a changed file is invalidated in the warm + // contexts before it is read again. + globalThis.__oracleGapsSchemata = new Map() + const servedHash = (path) => { + const text = + globalThis.__oracleGapsServe?.get(path) ?? + globalThis.__oracleGapsSchemata.get(path) + return text === undefined ? hashOf(path) : sha1(text) + } + const lastServed = new Map() + let contexts = [] + let contextsKey + let contextsConfig + const contextSpecs = new Map() + let runCounter = 0 + + async function warmContext(settings) { + const context = await createVitest(`test`, { + root: pkg, + config: wrapper, + watch: false, + maxWorkers: 1, + // Every test in a file runs, so a kill is attributed to each route + // that produces it (see routeOf). A property still stops at its first + // failure. + bail: settings.bail, + testTimeout: settings.testTimeout, + hookTimeout: settings.testTimeout, + teardownTimeout: settings.testTimeout, + typecheck: { enabled: false }, + coverage: { enabled: false }, + // Every module each test file imports, which a unit records as its + // inputs. + experimental: { importDurations: { limit: 1_000_000 } }, + reporters: [{}], + }) + // Vitest 3 calls this step `init`; Vitest 4 renamed it `standalone`. + await (context.standalone ? context.standalone() : context.init()) + return context + } + async function globSpecs(context) { + // A warm context caches the test files it found; a file added since the + // last check, such as a new oracle file, appears only after the cache is + // cleared. + context.clearSpecificationsCache?.() + const map = new Map() + for (const spec of await context.globTestSpecifications()) + map.set(spec.moduleId, [...(map.get(spec.moduleId) ?? []), spec]) + contextSpecs.set(context, map) + } + async function closeContexts() { + await Promise.all( + contexts.map((context) => + Promise.race([context.close().catch(() => undefined), sleep(10_000)]), + ), + ) + contexts = [] + lastServed.clear() + } + // This process's harness files are generated from this CLI's own files, + // which every key covers, and their names carry the process id, so they + // are left out of every key and input. + const ownHarness = (path) => path === wrapper || path === harness + // The files the Vite config loaded, for the global key. + const configFiles = () => + (contexts[0]?.vite?.config?.configFileDependencies ?? []).filter( + (path) => !ownHarness(path), + ) + + /** Invalidates every path whose served text changed since a context read it. */ + function invalidateChanged(paths) { + for (const path of paths) { + const hash = servedHash(path) + if (lastServed.get(path) === hash) continue + for (const context of contexts) context.invalidateFile(path) + lastServed.set(path, hash) + } + } + + /** One Vitest run of one test file, as plain data. */ + async function runUnit(context, out, file, { mutant = null, seed = null, limitMs } = {}) { + const capture = join(out, `run-${process.pid}-${++runCounter}.jsonl`) + const state = { mutant, select: null, capture, seed } + // Vitest 3 and 4 both expose the root project's `provide`; the state file + // remains for setups that cannot `inject`. + context.getRootProject?.().provide(`oracleGaps`, state) + writeFileSync(join(out, `state.json`), JSON.stringify(state)) + const started = Date.now() + const running = context.runTestSpecifications( + contextSpecs.get(context).get(file) ?? [], + false, + ) + let timer + const result = + limitMs === undefined + ? await running + : await Promise.race([ + running, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), limitMs) + }), + ]) + clearTimeout(timer) + const seconds = (Date.now() - started) / 1000 + if (result === undefined) { + await Promise.race([ + context.cancelCurrentRun(`keyboard-input`).catch(() => undefined), + sleep(5000), + ]) + rmSync(capture, { force: true }) + return { file, timedOut: true, seconds } + } + // The result also lists test modules from earlier runs, with their old + // results. Only this run's file counts. + const modules = result.testModules.filter((m) => m.moduleId === file) + const rows = [] + if (existsSync(capture)) { + for (const line of readFileSync(capture, `utf8`).split(`\n`).filter(Boolean)) { + const row = JSON.parse(line) + rows.push([row.key, row]) + } + rmSync(capture) + } + const tests = modules.flatMap((module) => + [...module.children.allTests()].map((testCase) => { + const testResult = testCase.result() + return { + key: `${module.moduleId} > ${testCase.fullName}`, + fullName: testCase.fullName, + state: testResult.state, + // fast-check attaches the error its predicate threw as the cause. + message: (testResult.errors ?? []) + .map((error) => { + const cause = error.cause + return cause?.message + ? `${cause.name ?? ``}: ${cause.message}` + : `${error.name ?? ``}: ${error.message ?? ``}` + }) + .join(`\n`), + } + }), + ) + return { + file, + timedOut: false, + seconds, + duration: Math.max( + 0, + ...modules.map((m) => (m.diagnostic?.().duration ?? 0) / 1000), + ), + setupErrors: modules.flatMap((m) => + (m.errors?.() ?? []).map((error) => error.message), + ), + tests, + unhandled: (result.unhandledErrors ?? []).map((error) => + String(error?.message ?? error), + ), + rows, + inputs: [ + ...new Set([ + file, + ...modules.flatMap((m) => + Object.keys(m.diagnostic?.().importDurations ?? {}), + ), + ]), + ].filter((path) => path.startsWith(`/`) && !ownHarness(path)), + } + } + + async function check(options, store, log = console.error) { + const authored = options.mutants ? loadAuthored(options.mutants) : [] + mkdirSync(options.out, { recursive: true }) + // The state file is the fallback for setups that cannot `inject`. + process.env.ORACLE_GAPS_STATE = join(options.out, `state.json`) + const settings = { + testTimeout: options.testTimeout, + bail: Number(process.env.ORACLE_GAPS_BAIL ?? 0), + } + // Contexts are warmed again when their settings or the Vite config they + // loaded changed. + const configHash = () => + sha1(JSON.stringify(configFiles().map((file) => [file, hashOf(file)]))) + if ( + JSON.stringify(settings) !== contextsKey || + (contexts.length > 0 && configHash() !== contextsConfig) + ) { + await closeContexts() + for (let i = 0; i < options.contexts; i += 1) + contexts.push(await warmContext(settings)) + contextsKey = JSON.stringify(settings) + contextsConfig = configHash() + } + while (contexts.length < options.contexts) + contexts.push(await warmContext(settings)) + for (const context of contexts) await globSpecs(context) + + // 1. The static half (analysis.mjs): instrumented sources, mutants, + // declarations, and ownership. A broken declaration refuses the run. + const { instrumented, mutants, declarations, owned } = analyze( + ts, + pkg, + options.sources, + [...contextSpecs.get(contexts[0]).keys()], + ) + const previous = [...globalThis.__oracleGapsSchemata.keys()] + globalThis.__oracleGapsSchemata = instrumented + invalidateChanged(new Set([...previous, ...instrumented.keys(), ...lastServed.keys()])) + + // The global inputs of every unit. + const lockfile = lockfileOf(pkg) + const globalKey = sha1( + JSON.stringify([ + // The harness is this CLI's capture setup and a wrapper that + // check.mjs writes, both covered by `ownFiles`; their file names + // carry the process id, so they are not part of the key. + [...ownFiles, join(pkg, `package.json`), ...configFiles(), ...(lockfile ? [lockfile] : [])] + .sort() + .map((file) => [file, hashOf(file)]), + process.version, + options.seed ?? null, + settings, + ]), + ) + const evidence = { reused: 0, computed: 0 } + async function unit(context, file, spec, runOptions = {}) { + const id = sha1(JSON.stringify([globalKey, file, spec])) + const row = store.get(id) + if (row && row.inputs.every(([path, hash]) => servedHash(path) === hash)) { + evidence.reused += 1 + return row.unit + } + const result = await runUnit(context, options.out, file, { + seed: options.seed ?? null, + ...runOptions, + }) + evidence.computed += 1 + if (!result.timedOut) { + const inputs = result.inputs.map((path) => [path, servedHash(path)]) + for (const [path, hash] of inputs) lastServed.set(path, hash) + store.put({ id, inputs, unit: result }) + } + return result + } + + const vitest = contexts[0] + const fileSpecs = new Map(contextSpecs.get(vitest)) + const relative = (file) => file.slice(pkg.length + 1) + const oracleFiles = new Set(declarations.keys()) + const runIdentity = identityOf(options, authored, [...fileSpecs.keys()], oracleFiles.size) + const fileOf = (key) => key.split(` > `)[0] + + // 2. Two unmutated runs of each file, split across the contexts. A file + // that fails with no mutant active is left out of the report: it cannot + // detect anything, and it may read source text that instrumentation + // changes. Each file's unmutated duration sets a mutant run's time limit. + const fileSeconds = new Map() + const limitFor = (file) => 10_000 + 3000 * (fileSeconds.get(file) ?? 10) + const runAcross = async (files, pass) => { + const parts = contexts.map(() => []) + files.forEach((file, index) => parts[index % contexts.length].push(file)) + const units = ( + await Promise.all( + contexts.map(async (context, index) => { + const done = [] + for (const file of parts[index]) + done.push(await unit(context, file, `unmutated:${pass}`)) + return done + }), + ) + ).flat() + const rows = new Map(units.flatMap((u) => u.rows ?? [])) + for (const u of units) + fileSeconds.set(u.file, Math.max(fileSeconds.get(u.file) ?? 0, u.duration ?? 0)) + const failed = new Set( + units + .filter((u) => outcomeOf([u]).outcome !== `survived`) + .map((u) => u.file), + ) + return { rows, failed, seconds: Math.max(0, ...units.map((u) => u.seconds)) } + } + let files = [...fileSpecs.keys()] + const first = await runAcross(files, 1) + // An oracle file that fails with no mutant active cannot judge the code + // it owns, so the run refuses rather than report that code as unowned. + const failingOwners = [...first.failed].filter((file) => oracleFiles.has(file)) + if (failingOwners.length > 0) + throw new Error( + `oracle files fail with no mutant active, so their owned code cannot be judged: ${failingOwners.map(relative).join(`, `)}`, + ) + const excluded = [...first.failed] + if (excluded.length > 0) { + log(`left out, failing with no mutant active: ${excluded.join(`, `)}`) + files = files.filter((file) => !first.failed.has(file)) + for (const file of excluded) { + fileSpecs.delete(file) + oracleFiles.delete(file) + for (const key of [...first.rows.keys()]) + if (fileOf(key) === file) first.rows.delete(key) + } + } + const second = await runAcross(files, 2) + const baseline = baselineOf(first.rows, second.rows, oracleFiles) + const { nondeterministic } = baseline + log( + `unmutated: ${[...first.rows.values()].filter((r) => !r.background).length} tests in ${first.seconds.toFixed(1)}s and ${second.seconds.toFixed(1)}s; nondeterministic: ${nondeterministic.size}`, + ) + + const ownersOf = (mutant) => owned.of(mutant.source, mutant.function) + + // 3–5. Mutants. + const diffs = new Map( + options.sources.map((source) => [source, diffLines(pkg, options.diffBase, source)]), + ) + const results = [] + const started = Date.now() + let queue = mutants.filter((m) => !options.only || options.only.has(m.id)) + if (options.sample !== undefined) { + // A fixed-seed shuffle, so a survey samples the same mutants every run. + let seed = 20251102 + const random = () => + (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31 + queue = options.sources.flatMap((source) => { + const own = queue.filter((m) => m.source === source) + for (let i = own.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)) + ;[own[i], own[j]] = [own[j], own[i]] + } + return own.slice(0, options.sample) + }) + } + // Results are appended as they finish, so a stopped survey keeps them. + const partial = join(options.out, `results.jsonl`) + rmSync(partial, { force: true }) + // The units of one mutant against some files, one file at a time. A + // unit that timed out makes the mutant's outcome `timeout`, and its + // cancelled context must be replaced before it runs anything else, so + // the rest are not run. + const unitsFor = async (context, targets, mutant) => { + const done = [] + for (const file of targets) { + const result = await unit(context, file, `mutant:${mutant}`, { + mutant, + limitMs: limitFor(file), + }) + done.push(result) + if (result.timedOut) break + } + return done + } + async function evaluate(mutant, context) { + mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) + const owners = ownersOf(mutant) + const targets = targetsOf(mutant, baseline, owners, oracleFiles) + const result = { + ...mutant, + oracleFiles: targets.oracleTargets, + ordinaryFiles: targets.testTargets, + } + let replace = false + const units = + owners.size > 0 && targets.oracleTargets.length > 0 + ? await unitsFor(context, targets.oracleTargets, mutant.number) + : [] + replace ||= units.some((u) => u.timedOut) + Object.assign( + result, + judgeMutant(mutant, { owners, targets, units, baseline, mutants }), + ) + if (needsOrdinary(result, targets)) { + const ordinary = await unitsFor(context, targets.testTargets, mutant.number) + replace ||= ordinary.some((u) => u.timedOut) + result.testOutcome = judgeOrdinary(ordinary) + } + results.push(result) + appendFileSync(partial, `${JSON.stringify(result)}\n`) + log( + `${mutant.id} L${mutant.line} ${mutant.operator} ${result.outcome}${result.class ? ` (${result.class})` : ``}${result.testOutcome ? ` [ordinary: ${result.testOutcome}]` : ``}`, + ) + return replace + } + await Promise.all( + contexts.map(async (initial, slot) => { + let context = initial + for (let mutant = queue.shift(); mutant; mutant = queue.shift()) { + if (!(await evaluate(mutant, context))) continue + // A cancelled run can leave work behind, so its context is + // replaced. + await Promise.race([context.close().catch(() => undefined), sleep(10_000)]) + context = await warmContext(settings) + await globSpecs(context) + contexts[slot] = context + } + }), + ) + results.sort((a, b) => a.number - b.number || a.source.localeCompare(b.source)) + + // 6. Authored mutants, one at a time in the first context. Each one + // serves a patched copy of its source, which every context in this + // process would see. The copy is not instrumented, so an authored mutant + // runs against every owner of the function its edit lands in. + for (const mutant of authored) { + const id = `authored:${mutant.id}` + if (options.only && !options.only.has(id)) continue + const path = realpathSync(join(pkg, mutant.source)) + const patched = applyAuthored(readFileSync(path, `utf8`), mutant) + const result = { + id, + authored: true, + authoredId: mutant.id, + source: mutant.source, + operator: `authored`, + function: `authored mutants`, + intent: mutant.intent, + author: mutant.author, + original: mutant.find, + replacement: mutant.replace, + line: patched?.line, + } + results.push(result) + if (!patched) { + result.outcome = `does-not-apply` + continue + } + result.function = owned.functionAt(mutant.source, patched.line) + const owners = owned.of(mutant.source, result.function) + const targets = [...owners].filter((file) => fileSpecs.has(file)) + if (owners.size === 0) { + result.outcome = `unowned` + continue + } + let context = contexts[0] + globalThis.__oracleGapsServe = new Map([[path, patched.text]]) + invalidateChanged([path]) + const units = [] + for (const file of targets) { + units.push( + await unit(context, file, id, { mutant: -1, limitMs: limitFor(file) }), + ) + if (units.at(-1).timedOut) break + } + globalThis.__oracleGapsServe = undefined + invalidateChanged([path]) + Object.assign(result, judgeAuthored(units, baseline)) + log(`${id} ${result.outcome}${result.class ? ` (${result.class})` : ``}`) + if (units.some((u) => u.timedOut)) { + await Promise.race([context.close().catch(() => undefined), sleep(10_000)]) + context = await warmContext(settings) + await globSpecs(context) + contexts[0] = context + } + } + // Keys go into every output, so an agent reads them from the JSON + // results. + assignKeys(results) + const mutantSeconds = (Date.now() - started) / 1000 + log(`mutant phase: ${results.length} mutants in ${mutantSeconds.toFixed(1)}s`) + await store.flush?.() + writeFileSync( + join(options.out, `results.json`), + JSON.stringify( + { + run: runIdentity, + options: { ...options, only: options.only && [...options.only] }, + excluded, + nondeterministic: [...nondeterministic], + mutantSeconds, + // How the result was produced: units reused from the evidence + // store and units run in this check. + evidence, + results, + }, + null, + 1, + ), + ) + const summary = renderReport({ + results, + run: runIdentity, + excluded: excluded.map(relative), + }) + writeFileSync(join(options.out, `summary.md`), summary) + return summary + } + + async function close() { + await closeContexts() + rmSync(wrapper, { force: true }) + rmSync(harness, { force: true }) + } + + return { check, close } +} + +/** + * The run's identity: a hash of everything that decides its outcome (the + * sources, every test file, the authored mutants, the options that change a + * result, and this CLI's own code and the framework it reads), so a count + * can be traced to the run that produced it and reproduced by running it + * again. Where the report is written and whether the daemon produced it do + * not change the result, so they are not part of it. + */ +function identityOf(options, authored, testFiles, oracleCount) { + const hash = createHash(`sha1`) + const files = [ + ...options.sources.map((file) => join(options.pkg, file)), + ...testFiles, + ...ownFiles, + ].sort() + // Files are named relative to the package or to this CLI, so the same + // inputs in another checkout have the same identity. + const name = (file) => + file.startsWith(`${options.pkg}/`) + ? file.slice(options.pkg.length + 1) + : `cli:${relativePath(here, file)}` + for (const file of files) + if (existsSync(file)) + hash.update(`${name(file)}\0${readFileSync(file, `utf8`)}\0`) + hash.update(JSON.stringify(authored)) + const { out, daemon, pkg, mutants, ...decisive } = options + hash.update(JSON.stringify({ ...decisive, only: [...(options.only ?? [])] })) + return { + id: hash.digest(`hex`).slice(0, 12), + inputs: `${options.sources.length} source files, ${oracleCount} oracle files, ${testFiles.length - oracleCount} ordinary test files, ${authored.length} authored mutants, seed ${options.seed ?? `(none)`}${options.only || options.sample !== undefined ? `; a partial run (--only or --sample)` : ``}`, + } +} + +// Lines changed relative to the diff base, for diff-scoped summaries. +function diffLines(pkg, diffBase, source) { + if (!diffBase) return undefined + const child = spawnSync(`git`, [`diff`, `-U0`, diffBase, `--`, source], { + cwd: pkg, + encoding: `utf8`, + }) + const lines = new Set() + for (const match of child.stdout.matchAll( + /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm, + )) { + const start = Number(match[1]) + for (let line = start; line < start + Number(match[2] ?? 1); line += 1) + lines.add(line) + } + return lines +} diff --git a/scripts/oracle-gaps/classify.mjs b/scripts/oracle-gaps/classify.mjs new file mode 100644 index 0000000000..6c6fbeea32 --- /dev/null +++ b/scripts/oracle-gaps/classify.mjs @@ -0,0 +1,291 @@ +// The gap report's classification: what each mutant's outcome is, from the +// plain records of the runs that judged it (check.mjs runs them). Every +// function here is pure, so the CLI's own oracle checks the classification +// on generated records in-process (gap-report-classify-oracle). +// +// A run record (a unit) holds one test file's tests under one mutant: each +// test's state and error text, its setup and unhandled errors, whether it +// timed out, and its capture rows (capture-setup.mjs): the sites each test +// reached, the outcomes each `if` took, the hashes of what it asserted on +// and observed, its registered route, and its law verdicts. + +// Which kind of test produced a failure: the route the framework registered +// for it through `__oracleRoute` (capture-setup.mjs), never its name. A kill +// counts only on a reproducible route. The random campaign draws a new seed +// in every CI run, so a kill only it finds is a gap until it is kept as a +// fixed history. Calibrations, probe calibrations, and the determinism test +// check the oracle itself, not the law. A test that registered no route has +// an unknown one, and cannot kill. +const reproducibleRoutes = new Set([ + `fixed seed`, + `fixed history`, + `constructed history`, + `case`, + `enumerated`, +]) +const frameworkRoutes = new Set([`calibration`, `probe calibration`, `determinism`]) +function routeOf(registered) { + if (registered === undefined) return `unknown` + if (reproducibleRoutes.has(registered)) return `reproducible` + if (registered === `random seed`) return `random` + if (frameworkRoutes.has(registered)) return `framework` + return `unknown` +} + +/** + * A mutant's outcome from its units. A test whose observations varied + * between the two unmutated runs is nondeterministic: its failures judge + * nothing, so a mutant that only such tests fail is not killed. + */ +export function outcomeOf(units, nondeterministic = new Set(), assertions = false) { + if (units.some((unit) => unit.timedOut)) + return { outcome: `timeout`, detail: `run time limit` } + const failures = [] + for (const unit of units) { + const rows = new Map(unit.rows) + for (const message of unit.setupErrors) + failures.push({ setup: true, message }) + for (const test of unit.tests) { + if (test.state !== `failed`) continue + if (nondeterministic.has(test.key)) continue + const row = rows.get(test.key) + // Each verdict keeps the route active when it fired. + const verdicts = (row?.verdicts ?? []).map((v) => ({ + ...v, + route: routeOf(v.route ?? row?.route), + })) + failures.push({ + test: test.fullName, + route: routeOf(row?.route), + verdicts, + assertion: + // fast-check's wrapper text ("Property failed …") is not an + // assertion; the wrapped error, which its message quotes, decides. + /AssertionError|expected .+ to /.test(test.message), + timeout: /Test timed out/.test(test.message), + // fast-check quotes the error its predicate threw after its own + // header; that error is the cause the report shows. + message: ( + test.message.match(/Got error: (.*)/)?.[1] ?? test.message + ).slice(0, 300), + }) + } + for (const message of unit.unhandled) failures.push({ message }) + } + if (failures.length === 0) return { outcome: `survived` } + const judged = (route) => + failures + .filter( + (f) => + f.verdicts?.some((v) => v.route === route) || + (assertions && + f.assertion && + f.route === route && + f.route !== `framework`), + ) + .map((f) => ({ ...f, verdicts: f.verdicts.filter((v) => v.route === route) })) + const lawsOf = (rows) => [ + ...new Set(rows.flatMap((f) => f.verdicts.map((v) => v.law))), + ] + const reproducible = judged(`reproducible`) + const random = judged(`random`) + const unregistered = judged(`unknown`) + // Reach agreement alone shows that production took another path, not that + // any behavioral law saw a different result. + const laws = lawsOf(reproducible) + if ( + reproducible.length > 0 && + laws.length > 0 && + laws.every((law) => law === `reach agreement`) + ) + return { + outcome: `agreement-only-kill`, + by: reproducible.map((f) => f.test), + laws, + } + if (reproducible.length > 0) + return { + outcome: `killed`, + by: reproducible.map((f) => f.test), + laws, + randomAlso: random.length > 0, + } + if (random.length > 0) + return { + outcome: `random-only-kill`, + by: random.map((f) => f.test), + laws: lawsOf(random), + } + if (unregistered.length > 0) + return { + outcome: `unregistered-kill`, + by: unregistered.map((f) => f.test), + laws: lawsOf(unregistered), + } + const tests = failures.filter((f) => f.test) + if (tests.length > 0 && tests.every((f) => f.timeout)) + return { outcome: `timeout`, by: tests.map((f) => f.test) } + const crashed = tests.filter((f) => f.route !== `framework` && !f.timeout) + if (crashed.length > 0) + return { + outcome: `crashed`, + by: crashed.map((f) => f.test), + detail: crashed[0].message, + } + if (tests.length > 0) + return { + outcome: `framework-error`, + by: tests.map((f) => f.test), + detail: tests[0].message, + } + return { outcome: `setup-error`, detail: failures[0].message } +} + + +const fileOf = (key) => key.split(` > `)[0] + +/** + * What the two unmutated runs show: which tests are nondeterministic (their + * values differed between the runs, so their failures judge nothing), which + * tests reached each site, and which `if` outcomes each oracle file saw. + */ +export function baselineOf(firstRows, secondRows, oracleFiles) { + const nondeterministic = new Set() + for (const [key, row] of firstRows) { + const again = secondRows.get(key) + if ( + !row.background && + (again?.hash !== row.hash || again?.observed !== row.observed) + ) + nondeterministic.add(key) + } + const reachedBy = new Map() + const outcomeSeen = new Map() + for (const [key, row] of firstRows) { + for (const id of row.hits) + reachedBy.set(id, [...(reachedBy.get(id) ?? []), key]) + if (row.background || !oracleFiles.has(fileOf(key))) continue + for (const [id, [truthy, falsy]] of row.outcomes) { + const byFile = outcomeSeen.get(id) ?? new Map() + const seen = byFile.get(fileOf(key)) ?? [false, false] + byFile.set(fileOf(key), [seen[0] || truthy, seen[1] || falsy]) + outcomeSeen.set(id, byFile) + } + } + return { firstRows, nondeterministic, reachedBy, outcomeSeen } +} + +/** The owner tests and the ordinary tests that reached a mutant's site. */ +export function targetsOf(mutant, baseline, owners, oracleFiles) { + const keys = baseline.reachedBy.get(mutant.number) ?? [] + const oracleKeys = keys.filter((k) => owners.has(fileOf(k))) + const testKeys = keys.filter((k) => !oracleFiles.has(fileOf(k))) + return { + oracleKeys, + testKeys, + oracleTargets: [...new Set(oracleKeys.map(fileOf))], + testTargets: [...new Set(testKeys.map(fileOf))], + } +} + +// An `if` on a mutant's line that its owners' tests reached but never saw +// take both outcomes. +function outcomeMissing(mutant, owners, mutants, baseline) { + return mutants.some((m) => { + if ( + m.operator !== `if-true` || + m.source !== mutant.source || + m.line !== mutant.line + ) + return false + let truthy = false + let falsy = false + for (const [file, seen] of baseline.outcomeSeen.get(m.number) ?? []) + if (owners.has(file)) { + truthy ||= seen[0] + falsy ||= seen[1] + } + const reached = (baseline.reachedBy.get(m.number) ?? []).some((k) => + owners.has(fileOf(k)), + ) + return reached && !(truthy && falsy) + }) +} + +// A changed asserted value or a changed property observation, with no +// failure, is a difference that the check did not judge. +const changedIn = (rows, keys, baseline) => + keys.filter( + (k) => + rows.get(k)?.hash !== baseline.firstRows.get(k)?.hash || + rows.get(k)?.observed !== baseline.firstRows.get(k)?.observed, + ) + +/** + * An operator mutant's outcome from the units of its owner files. A mutant + * with no owner is `unowned`, and one that no owner test reached is + * `unreached`. A survivor is a `comparison-gap`, an `indeterminate` mutant, + * a `generator-gap`, or `no-observed-difference`. + */ +export function judgeMutant(mutant, { owners, targets, units, baseline, mutants }) { + if (owners.size === 0) return { outcome: `unowned` } + if (targets.oracleKeys.length === 0) return { outcome: `unreached` } + const result = { + ...outcomeOf(units, baseline.nondeterministic), + seconds: units.reduce((sum, u) => sum + u.seconds, 0), + } + if (result.outcome !== `survived`) return result + const rows = new Map(units.flatMap((u) => u.rows ?? [])) + // Only owner tests that reach the site can judge it. + const reaching = new Set(targets.oracleKeys) + const compared = [...rows.keys()].filter( + (k) => + reaching.has(k) && + !rows.get(k).background && + !baseline.nondeterministic.has(k), + ) + result.changedTests = changedIn(rows, compared, baseline) + result.class = + result.changedTests.length > 0 + ? `comparison-gap` + : compared.length === 0 + ? `indeterminate` + : outcomeMissing(mutant, owners, mutants, baseline) + ? `generator-gap` + : `no-observed-difference` + return result +} + +/** Whether the ordinary tests run, for information: only for a survivor. */ +export const needsOrdinary = (result, targets) => + (result.outcome === `survived` || result.outcome === `unreached`) && + targets.testKeys.length > 0 + +/** + * The ordinary tests' outcome. They have no verdicts, so their assertions + * are reported; an ordinary test never closes a gap. + */ +export const judgeOrdinary = (units) => outcomeOf(units, new Set(), true).outcome + +/** + * An authored mutant's outcome from its owners' units. Its copy is not + * instrumented, so no `if` outcomes tell a generator gap from an equivalent + * mutant: a survivor is a `comparison-gap` or stays `survived`. + */ +export function judgeAuthored(units, baseline) { + const result = { + ...outcomeOf(units, baseline.nondeterministic), + seconds: units.reduce((sum, u) => sum + u.seconds, 0), + } + if (result.outcome !== `survived`) return result + const rows = new Map(units.flatMap((u) => u.rows ?? [])) + result.changedTests = changedIn( + rows, + [...rows.keys()].filter( + (k) => !rows.get(k).background && !baseline.nondeterministic.has(k), + ), + baseline, + ) + if (result.changedTests.length > 0) result.class = `comparison-gap` + return result +} diff --git a/scripts/oracle-gaps/daemon.mjs b/scripts/oracle-gaps/daemon.mjs new file mode 100644 index 0000000000..dbebacf791 --- /dev/null +++ b/scripts/oracle-gaps/daemon.mjs @@ -0,0 +1,148 @@ +// The gap report's daemon: one process per package that keeps a check +// session warm (check.mjs) and its evidence store on disk (evidence.mjs), so +// an author who checks hundreds of times pays Vitest's startup once and reruns +// only the units whose inputs changed. A check through the daemon reports +// what a cold run reports (the CLI oracle's law 9). +// +// The CLI talks to it over a Unix socket named by the package's path. Each +// request is one line of JSON, the CLI's arguments; the reply is one line of +// JSON with the exit code and the output a cold run would have printed. One +// check runs at a time. The daemon exits when asked, or after 30 idle +// minutes. +// +// Usage (the CLI starts it): node scripts/oracle-gaps/daemon.mjs --package +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdirSync, openSync, realpathSync, rmSync } from 'node:fs' +import { connect, createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const idleMs = 30 * 60_000 + +/** The socket of a package's daemon. A socket path must stay short. */ +export const socketOf = (pkg) => + join( + tmpdir(), + `oracle-gaps-${createHash(`sha1`).update(pkg).digest(`hex`).slice(0, 12)}.sock`, + ) +/** Where a package's daemon keeps its evidence and its log. */ +export const stateDirOf = (pkg) => join(pkg, `.oracle-gaps`) + +function send(pkg, message) { + return new Promise((resolvePromise, reject) => { + const socket = connect(socketOf(pkg)) + let reply = `` + socket.on(`connect`, () => socket.write(`${JSON.stringify(message)}\n`)) + socket.on(`data`, (chunk) => { + reply += chunk + }) + socket.on(`end`, () => { + try { + resolvePromise(JSON.parse(reply)) + } catch (error) { + reject(error) + } + }) + socket.on(`error`, reject) + }) +} + +/** Sends a check to the package's daemon, starting the daemon if needed. */ +export async function requestDaemon(pkg, argv) { + try { + return await send(pkg, { argv }) + } catch (error) { + if (![`ENOENT`, `ECONNREFUSED`].includes(error.code)) throw error + } + mkdirSync(stateDirOf(pkg), { recursive: true }) + const log = openSync(join(stateDirOf(pkg), `daemon.log`), `a`) + spawn(process.execPath, [join(here, `daemon.mjs`), `--package`, pkg], { + detached: true, + stdio: [`ignore`, log, log], + }).unref() + for (let waited = 0; ; waited += 100) { + await new Promise((r) => setTimeout(r, 100)) + try { + return await send(pkg, { argv }) + } catch (error) { + if (![`ENOENT`, `ECONNREFUSED`].includes(error.code) || waited > 60_000) + throw error + } + } +} + +/** Stops the package's daemon, if one runs, after its current check. */ +export async function stopDaemon(pkg) { + try { + await send(pkg, { stop: true }) + } catch (error) { + if (![`ENOENT`, `ECONNREFUSED`].includes(error.code)) throw error + } +} + +async function serve(pkg) { + const { openSession } = await import(`./check.mjs`) + const { diskStore } = await import(`./evidence.mjs`) + const { parseArgs } = await import(`./args.mjs`) + const store = await diskStore(stateDirOf(pkg)) + const session = await openSession(pkg) + const socket = socketOf(pkg) + rmSync(socket, { force: true }) + let queue = Promise.resolve() + let idle + const server = createServer((connection) => { + let request = `` + connection.on(`data`, (chunk) => { + request += chunk + if (!request.includes(`\n`)) return + const message = JSON.parse(request) + queue = queue.then(async () => { + clearTimeout(idle) + // The reply waits until the daemon has closed, so a daemon started + // next finds the evidence on disk and no warm context in its way. + if (message.stop) await shutdown(connection) + const stderr = [] + let stdout = `` + let exitCode = 0 + try { + const options = parseArgs(message.argv) + if (options.pkg !== pkg) + throw new Error(`this daemon serves ${pkg}, not ${options.pkg}`) + stdout = `${await session.check(options, store, (line) => stderr.push(`${line}\n`))}\n` + } catch (error) { + stderr.push(`${error?.stack ?? error}\n`) + exitCode = 1 + } + connection.end( + `${JSON.stringify({ exitCode, stdout, stderr: stderr.join(``) })}\n`, + ) + idle = setTimeout(() => { + queue = queue.then(() => shutdown()) + }, idleMs) + }) + }) + }) + async function shutdown(connection) { + server.close() + rmSync(socket, { force: true }) + await session.close() + await store.close() + if (connection) + await new Promise((r) => + connection.end(`${JSON.stringify({ exitCode: 0 })}\n`, r), + ) + process.exit(0) + } + server.listen(socket) + idle = setTimeout(() => { + queue = queue.then(() => shutdown()) + }, idleMs) +} + +if (import.meta.url === `file://${realpathSync(process.argv[1])}`) { + const at = process.argv.indexOf(`--package`) + await serve(realpathSync(resolve(process.argv[at + 1]))) +} diff --git a/scripts/oracle-gaps/evidence.mjs b/scripts/oracle-gaps/evidence.mjs new file mode 100644 index 0000000000..8a08959fcb --- /dev/null +++ b/scripts/oracle-gaps/evidence.mjs @@ -0,0 +1,75 @@ +// The evidence store: units of evidence by identity (check.mjs). +// +// A cold run keeps its units in memory and drops them. The daemon keeps them +// in a TanStack DB collection persisted to SQLite on disk, so a daemon that +// restarts reuses what an earlier one ran. The store is a cache over the +// evidence a check reports, never the record: a stored unit is used only +// while every input it recorded is unchanged. +import { mkdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) + +/** A store that lives for one check. */ +export function memoryStore() { + const rows = new Map() + return { + get: (id) => rows.get(id), + put: (row) => rows.set(row.id, row), + } +} + +/** + * A store in `/evidence.sqlite`: a local-only persisted collection, + * whose inserts and updates SQLite persists. `flush` waits until every + * write so far is on disk. + */ +export async function diskStore(dir) { + mkdirSync(dir, { recursive: true }) + // The workspace's own packages, until this CLI is published with them as + // dependencies. Both are loaded as ES modules, so they share one copy of + // TanStack DB. + const persistence = join(here, `../../packages/node-db-sqlite-persistence`) + const requirePersistence = createRequire(join(persistence, `package.json`)) + const { createCollection } = await import( + pathToFileURL(join(here, `../../packages/db/dist/esm/index.js`)).href + ) + const { createNodeSQLitePersistence, persistedCollectionOptions } = + await import(pathToFileURL(join(persistence, `dist/esm/index.js`)).href) + const Database = requirePersistence(`better-sqlite3`) + const database = new Database(join(dir, `evidence.sqlite`)) + const units = createCollection( + persistedCollectionOptions({ + id: `evidence-units`, + getKey: (row) => row.id, + persistence: createNodeSQLitePersistence({ database }), + schemaVersion: 1, + }), + ) + await units.preload() + let pending = [] + return { + get: (id) => units.get(id), + put(row) { + const transaction = units.has(row.id) + ? units.update(row.id, (draft) => { + draft.inputs = row.inputs + draft.unit = row.unit + }) + : units.insert(row) + pending.push(transaction.when(`settled`)) + }, + async flush() { + const waiting = pending + pending = [] + await Promise.all(waiting) + }, + async close() { + await this.flush() + await units.cleanup() + database.close() + }, + } +} diff --git a/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs new file mode 100644 index 0000000000..7405de584f --- /dev/null +++ b/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs @@ -0,0 +1,650 @@ +/** + * # The gap report's classification, checked on run records + * + * Laws 4 and 7 of the gap report (gap-report-oracle.property.test.mjs states + * them in full) say what each mutant's outcome is and what the summary lists. + * The CLI computes both from plain records of the runs that judged a mutant + * (classify.mjs, report.mjs): each test's state and error text, whether its + * file's run timed out or failed to load, and its capture rows, which hold + * the sites it reached, the `if` outcomes it saw, the hashes of what it + * asserted on and observed, its registered route, and its law verdicts. + * This oracle generates those records directly, including combinations that + * a fixture package reaches only rarely, and checks the CLI's pure functions + * against a small model in-process. The end-to-end oracle checks that real + * runs produce such records. + * + * ## The law over records + * + * For one mutant, with the owner files of its function: + * + * 1. No owner: `unowned`. No owner test reached it: `unreached`. + * 2. Otherwise the records of the owner files whose tests reached it judge + * it, except tests whose values differed between the two unmutated runs + * (nondeterministic), which judge nothing. A run that timed out makes it + * `timeout`. + * 3. A law verdict takes the route active when it fired, or the test's route + * when none was active. On a reproducible route (a fixed seed, a fixed, + * constructed, or enumerated history, a case) it kills: `killed`, unless + * reach agreement is its only law, which makes it an + * `agreement-only-kill`. Failing that, a verdict on the random campaign + * makes it a `random-only-kill`, and one on an unknown or unregistered + * route an `unregistered-kill`. + * 4. Failing that, if every failing test timed out, it is `timeout`; a + * failing test outside the framework's own checks makes it `crashed`; a + * failing framework check alone (a calibration, a probe calibration, the + * determinism test) makes it a `framework-error`; and errors outside any + * test make it a `setup-error`. + * 5. With no failure it survived: a `comparison-gap` when a reaching, + * deterministic owner test's asserted or observed value changed; + * `indeterminate` when no such test judged it; a `generator-gap` when an + * `if` on its line, reached by an owner test, never took one of its + * outcomes in the owners' tests; and `no-observed-difference` otherwise. + * 6. A survivor or an unreached mutant that ordinary tests reached gets + * their outcome for information, where an assertion failure counts on the + * test's route; it never changes the class. + * + * Law 7 over the classified mutants: the summary lists every gap (every + * class except `killed` and `unowned`, outside probe code) once under its + * key, with its kind and line, every unowned mutant as a warning, and counts + * that agree. + * + * Authority: the maintainer's gap-check decisions (design runs v11, v14, + * and v19), as for scripts.gap-report; ORC-006's outcome kinds; the frozen + * design's oracle over the checker's pure core (v19 P10, F4); and the + * maintainer's few-seconds budget for an incremental test run (2026-11-05). + * + * Limits: authored mutants are not generated here (the end-to-end oracle + * has them). The records' shapes are those capture-setup.mjs writes; that a + * real run writes them correctly is the end-to-end oracle's. + */ +import fc from 'fast-check' +import { declareOracle } from '../../packages/db/tests/oracle-declaration.ts' +import { + oracleProperty, + pass, + violation, +} from '../../packages/db/tests/oracle-property.ts' +import { + baselineOf, + judgeMutant, + judgeOrdinary, + needsOrdinary, + targetsOf, +} from './classify.mjs' +import { readSummary } from './gap-report-model-oracle.mjs' +import { assignKeys, renderReport } from './report.mjs' + +const MUTANTS = 4 + +// ## History grammar +// +// A history is a package's test files, each an oracle file or an ordinary +// one, and up to four mutants of one source, each with its owner files, its +// line, operator, and statement anchor (with backticks, which the summary +// must fence). Each test has a registered route or none, may be +// nondeterministic, and has, for each mutant, whether it reached it, which +// outcomes an `if` there took, and what it did under it: nothing different, +// a changed asserted or observed value, a law verdict (on the route then active, or with none +// active), an assertion failure, a crash, a timeout message, a timed-out +// run, a setup error, or an unhandled error. +const routes = [ + undefined, + `fixed seed`, + `fixed history`, + `constructed history`, + `case`, + `enumerated`, + `random seed`, + `calibration`, + `probe calibration`, + `determinism`, + `custom route`, +] +const effect = fc.record({ + kind: fc.constantFrom( + `same`, + `same`, + `changed`, + `observedChanged`, + `verdict`, + `verdict`, + `assertion`, + `crash`, + `timeoutMessage`, + `runTimeout`, + `setupError`, + `unhandled`, + ), + law: fc.constantFrom(`L`, `reach agreement`), + // The route active when a verdict fired, when it differs from the test's. + firedAt: fc.option(fc.constantFrom(...routes.slice(1)), { nil: undefined }), +}) +const testRecord = fc.record({ + route: fc.constantFrom(...routes), + flaky: fc.constantFrom(false, false, false, true), + reaches: fc.array(fc.boolean(), { minLength: MUTANTS, maxLength: MUTANTS }), + seen: fc.array(fc.constantFrom(`both`, `both`, `true`, `false`), { + minLength: MUTANTS, + maxLength: MUTANTS, + }), + under: fc.array(effect, { minLength: MUTANTS, maxLength: MUTANTS }), +}) +const records = fc.record({ + files: fc.array( + fc.record({ + oracle: fc.boolean(), + tests: fc.array(testRecord, { minLength: 1, maxLength: 3 }), + // Code that ran when the file loaded, outside any test. + background: fc.array(fc.boolean(), { minLength: MUTANTS, maxLength: MUTANTS }), + }), + { minLength: 1, maxLength: 3 }, + ), + mutants: fc.array( + fc.record({ + owners: fc.array(fc.nat({ max: 2 }), { maxLength: 2 }), + line: fc.nat({ max: 1 }), + operator: fc.constantFrom(`if-true`, `if-false`, `delete-call`), + anchor: fc.constantFrom( + `out . push ( 'a' ) ;`, + `if ( x > 4 ) out . push ( \`b\` ) ;`, + `x \`\` y`, + // Longer than 80 characters, so a key cannot be cut short. + `if ( value . total > limit . maximum && value . total !== limit . exact ) out . push ( 'over the limit' ) ;`, + ), + probe: fc.constantFrom(false, false, false, true), + }), + { minLength: 1, maxLength: MUTANTS }, + ), +}) + +const fileName = (index) => `/pkg/tests/f${index}.test.ts` +const keyOf = (fi, ti) => `${fileName(fi)} > t${ti}` +const reproducible = new Set([ + `fixed seed`, + `fixed history`, + `constructed history`, + `case`, + `enumerated`, +]) +const framework = new Set([`calibration`, `probe calibration`, `determinism`]) +const kindOf = (route) => + route === undefined + ? `unknown` + : reproducible.has(route) + ? `reproducible` + : route === `random seed` + ? `random` + : framework.has(route) + ? `framework` + : `unknown` + +// ## Model +// +// The model states the law above over the records, without the CLI's +// functions: it reads each test's effect under the mutant directly. +function modelOutcome(h, mi) { + const m = h.mutants[mi] + const owners = new Set(m.owners.filter((fi) => fi < h.files.length && h.files[fi].oracle)) + const reachingIn = (fi) => + h.files[fi].background[mi] || h.files[fi].tests.some((t) => t.reaches[mi]) + const ordinary = h.files + .map((f, fi) => fi) + .filter((fi) => !h.files[fi].oracle && reachingIn(fi)) + const withOrdinary = (result) => + (result.outcome === `survived` || result.outcome === `unreached`) && ordinary.length > 0 + ? { ...result, testOutcome: ordinaryOutcome(h, mi, ordinary) } + : result + if (owners.size === 0) return { outcome: `unowned` } + const targets = [...owners].filter(reachingIn) + if (targets.length === 0) return withOrdinary({ outcome: `unreached` }) + const tests = targets.flatMap((fi) => h.files[fi].tests.map((t, ti) => ({ t, fi, ti }))) + const under = ({ t }) => t.under[mi].kind + if (tests.some((x) => under(x) === `runTimeout`)) return { outcome: `timeout` } + const setup = tests.some((x) => under(x) === `setupError`) + // A file that failed to load ran no tests. + const ran = tests.filter((x) => !h.files[x.fi].tests.some((t) => t.under[mi].kind === `setupError`)) + const failing = ran.filter( + (x) => !x.t.flaky && [`verdict`, `assertion`, `crash`, `timeoutMessage`].includes(under(x)), + ) + const verdictsOn = (kind) => + failing + .filter((x) => under(x) === `verdict` && kindOf(x.t.under[mi].firedAt ?? x.t.route) === kind) + .map((x) => x.t.under[mi].law) + const kills = verdictsOn(`reproducible`) + if (kills.length > 0) + return { + outcome: kills.every((law) => law === `reach agreement`) ? `agreement-only-kill` : `killed`, + laws: [...new Set(kills)].sort(), + } + if (verdictsOn(`random`).length > 0) return withOrdinary({ outcome: `random-only-kill` }) + if (verdictsOn(`unknown`).length > 0) return { outcome: `unregistered-kill` } + if (failing.length > 0 && failing.every((x) => under(x) === `timeoutMessage`)) + return { outcome: `timeout` } + if (failing.some((x) => under(x) !== `timeoutMessage` && kindOf(x.t.route) !== `framework`)) + return { outcome: `crashed` } + if (failing.length > 0) return { outcome: `framework-error` } + const unhandled = ran.some((x) => under(x) === `unhandled`) + if (setup || unhandled) return { outcome: `setup-error` } + // A survivor. Only reaching, deterministic owner tests judge it. + const compared = ran.filter((x) => x.t.reaches[mi] && !x.t.flaky) + if (compared.some((x) => [`changed`, `observedChanged`].includes(under(x)))) + return withOrdinary({ outcome: `survived`, class: `comparison-gap` }) + if (compared.length === 0) return withOrdinary({ outcome: `survived`, class: `indeterminate` }) + const missing = h.mutants.some((other, oi) => { + if (other.operator !== `if-true` || other.line !== m.line) return false + const seen = targetsSeen(h, oi, owners) + return seen.reached && !(seen.truthy && seen.falsy) + }) + return withOrdinary({ + outcome: `survived`, + class: missing ? `generator-gap` : `no-observed-difference`, + }) +} + +// Whether an owner test reached an `if-true` mutant, and which outcomes the +// owners' deterministic and nondeterministic tests saw there. +function targetsSeen(h, oi, owners) { + let reached = false + let truthy = false + let falsy = false + for (const fi of owners) { + if (h.files[fi].background[oi]) reached = true + for (const t of h.files[fi].tests) { + if (!t.reaches[oi]) continue + reached = true + truthy ||= t.seen[oi] !== `false` + falsy ||= t.seen[oi] !== `true` + } + } + return { reached, truthy, falsy } +} + +// The ordinary tests' outcome: their assertion failures count on their +// test's route, and nothing is nondeterministic for them. +function ordinaryOutcome(h, mi, files) { + const tests = files.flatMap((fi) => h.files[fi].tests.map((t) => ({ t, fi }))) + const under = ({ t }) => t.under[mi].kind + if (tests.some((x) => under(x) === `runTimeout`)) return `timeout` + const ran = tests.filter((x) => !h.files[x.fi].tests.some((t) => t.under[mi].kind === `setupError`)) + const failing = ran.filter((x) => + [`verdict`, `assertion`, `crash`, `timeoutMessage`].includes(under(x)), + ) + const judgedOn = (kind) => + failing.filter( + (x) => + (under(x) === `verdict` && kindOf(x.t.under[mi].firedAt ?? x.t.route) === kind) || + (under(x) === `assertion` && kindOf(x.t.route) === kind && kind !== `framework`), + ) + const kills = judgedOn(`reproducible`) + if (kills.length > 0) { + const laws = kills.filter((x) => under(x) === `verdict`).map((x) => x.t.under[mi].law) + return laws.length > 0 && laws.every((law) => law === `reach agreement`) + ? `agreement-only-kill` + : `killed` + } + if (judgedOn(`random`).length > 0) return `random-only-kill` + if (judgedOn(`unknown`).length > 0) return `unregistered-kill` + if (failing.length > 0 && failing.every((x) => under(x) === `timeoutMessage`)) return `timeout` + if (failing.some((x) => under(x) !== `timeoutMessage` && kindOf(x.t.route) !== `framework`)) + return `crashed` + if (failing.length > 0) return `framework-error` + if (tests.some((x) => under(x) === `setupError` || under(x) === `unhandled`)) return `setup-error` + return `survived` +} + +function model(h) { + const outcomes = h.mutants.map((m, mi) => modelOutcome(h, mi)) + const kindOfResult = (o) => o.class ?? o.outcome + const keyOf = (m, mi) => { + const base = `src/x.ts#f${m.line}:${m.operator}:${m.anchor}` + const before = h.mutants + .slice(0, mi) + .filter((o) => o.line === m.line && o.operator === m.operator && o.anchor === m.anchor).length + return before === 0 ? base : `${base}~${before + 1}` + } + const listed = h.mutants.map((m, mi) => ({ m, key: keyOf(m, mi), kind: kindOfResult(outcomes[mi]) })) + const gapsOf = listed.filter((x) => !x.m.probe && ![`killed`, `unowned`].includes(x.kind)) + return { + outcomes, + summary: { + counts: { + mutants: h.mutants.length, + authored: 0, + killed: listed.filter((x) => !x.m.probe && x.kind === `killed`).length, + gaps: gapsOf.length, + probe: listed.filter((x) => x.m.probe).length, + }, + gaps: gapsOf.map((x) => x.key).sort(), + unowned: listed.filter((x) => !x.m.probe && x.kind === `unowned`).map((x) => x.key).sort(), + entries: gapsOf + .map((x) => ({ key: x.key, kind: x.kind, line: String(x.m.line + 1) })) + .sort((a, b) => (a.key < b.key ? -1 : 1)), + }, + } +} + +// ## Production driver +// +// The driver turns the records into the capture rows and units the CLI's +// runner produces, calls the CLI's classification for each mutant as the +// runner does (asking for units only of the files it targets), assigns keys, +// and renders the summary. +const messages = { + verdict: `Error: violation`, + assertion: `AssertionError: expected [ 'a' ] to deeply equal [ 'b' ]`, + crash: `TypeError: x.y is not a function`, + timeoutMessage: `Error: Test timed out in 15000ms.`, +} +function rowsOf(h, pass) { + const rows = new Map() + h.files.forEach((f, fi) => { + f.tests.forEach((t, ti) => { + rows.set(keyOf(fi, ti), { + key: keyOf(fi, ti), + hash: t.flaky && pass === 2 ? `h-again` : `h`, + observed: `o`, + hits: t.reaches.flatMap((r, mi) => (r && mi < h.mutants.length ? [mi] : [])), + outcomes: t.reaches.flatMap((r, mi) => + r && mi < h.mutants.length && h.mutants[mi].operator === `if-true` + ? [[mi, [t.seen[mi] !== `false`, t.seen[mi] !== `true`]]] + : [], + ), + route: t.route, + verdicts: [], + }) + }) + rows.set(`${fileName(fi)} > (background)`, { + key: `${fileName(fi)} > (background)`, + background: true, + hits: f.background.flatMap((r, mi) => (r && mi < h.mutants.length ? [mi] : [])), + }) + }) + return rows +} +function unitOf(h, fi, mi) { + const f = h.files[fi] + const under = (t) => t.under[mi] + if (f.tests.some((t) => under(t).kind === `runTimeout`)) + return { file: fileName(fi), timedOut: true, seconds: 1 } + const loadFailed = f.tests.some((t) => under(t).kind === `setupError`) + const tests = loadFailed ? [] : f.tests + return { + file: fileName(fi), + timedOut: false, + seconds: 1, + setupErrors: loadFailed ? [`Error: cannot load the file`] : [], + unhandled: tests.some((t) => under(t).kind === `unhandled`) ? [`unhandled rejection`] : [], + tests: tests.map((t, ti) => { + const kind = under(t).kind + return { + key: keyOf(fi, ti), + fullName: `t${ti}`, + state: messages[kind] ? `failed` : `passed`, + message: messages[kind] ?? ``, + } + }), + rows: tests.map((t, ti) => { + const kind = under(t).kind + return [ + keyOf(fi, ti), + { + key: keyOf(fi, ti), + hash: kind === `changed` ? `h-changed` : `h`, + observed: kind === `observedChanged` ? `o-changed` : `o`, + hits: [], + outcomes: [], + route: t.route, + verdicts: + kind === `verdict` + ? [{ property: `p`, law: under(t).law, checkpoint: `c`, route: under(t).firedAt }] + : [], + }, + ] + }), + } +} + +async function driver(h, observe) { + const oracleFiles = new Set(h.files.flatMap((f, fi) => (f.oracle ? [fileName(fi)] : []))) + const baseline = baselineOf(rowsOf(h, 1), rowsOf(h, 2), oracleFiles) + const mutants = h.mutants.map((m, mi) => ({ + id: `m${mi}`, + number: mi, + source: `src/x.ts`, + function: `f${m.line}`, + line: m.line + 1, + operator: m.operator, + anchor: m.anchor, + ...(m.probe ? { probeGuard: true } : {}), + })) + const fileIndex = (file) => Number(file.match(/f(\d+)\.test/)[1]) + const results = mutants.map((mutant, mi) => { + const owners = new Set( + h.mutants[mi].owners + .filter((fi) => fi < h.files.length) + .map(fileName) + .filter((file) => oracleFiles.has(file)), + ) + const targets = targetsOf(mutant, baseline, owners, oracleFiles) + const units = targets.oracleTargets.map((file) => unitOf(h, fileIndex(file), mi)) + const result = { + ...mutant, + ...judgeMutant(mutant, { owners, targets, units, baseline, mutants }), + } + if (needsOrdinary(result, targets)) + result.testOutcome = judgeOrdinary( + targets.testTargets.map((file) => unitOf(h, fileIndex(file), mi)), + ) + return result + }) + assignKeys(results) + observe(`classified`, { + outcomes: results.map((r) => ({ + outcome: r.outcome, + class: r.class, + laws: r.laws && [...r.laws].sort(), + testOutcome: r.testOutcome, + })), + summary: readSummary( + renderReport({ results, run: { id: `r`, inputs: `records` }, excluded: [] }), + ), + }) + const states = new Set() + for (const r of results) { + states.add(r.class ?? r.outcome) + if (r.testOutcome === `killed`) states.add(`ordinary killed`) + } + if (h.mutants.some((m) => m.anchor.includes(`\``) && !m.probe)) states.add(`a key with backticks`) + for (const state of states) observe(`reach`, state) +} + +// ## Comparison +function check(h, observations, expected) { + const got = observations.find((o) => o.checkpoint === `classified`)?.value + if (!got) return violation(`classified`, `nothing was classified`, `completion`) + // A kill names its laws; the model owes laws only for kills. + const view = (o, laws) => + JSON.stringify([o.outcome, o.class, laws ? (o.laws ?? []) : null, o.testOutcome ?? null]) + for (const [mi, owed] of expected.outcomes.entries()) { + const actual = got.outcomes[mi] + const laws = owed.laws !== undefined + if (view(actual, laws) !== view(owed, laws)) + return violation(`classified`, `mutant m${mi}: ${view(actual, laws)}, owed ${view(owed, laws)}`, `classification`, { actual, expected: owed }) + } + if (JSON.stringify(got.summary) !== JSON.stringify(expected.summary)) + return violation(`classified`, `the summary does not list exactly the owed gaps, unowned warnings, and counts`, `report completeness`, { actual: got.summary, expected: expected.summary }) + return pass +} + +declareOracle({ check: [check], model: [model, modelOutcome, ordinaryOutcome], grammar: [records] }) + +// ## Calibrations +const corrupt = (change) => (trace) => { + const index = trace.findIndex((o) => o.checkpoint === `classified`) + if (index < 0) return undefined + const value = structuredClone(trace[index].value) + if (change(value) === false) return undefined + return trace.map((o, i) => (i === index ? { ...o, value } : o)) +} +const calibrations = [ + { + name: `a crash is reported as a kill`, + law: `classification`, + corrupt: corrupt((v) => { + const o = v.outcomes.find((x) => x.outcome === `crashed`) + if (!o) return false + o.outcome = `killed` + }), + }, + { + name: `a random-only kill is reported as a kill`, + law: `classification`, + corrupt: corrupt((v) => { + const o = v.outcomes.find((x) => x.outcome === `random-only-kill`) + if (!o) return false + o.outcome = `killed` + }), + }, + { + name: `the summary omits a gap`, + law: `report completeness`, + corrupt: corrupt((v) => { + if (v.summary.gaps.length === 0) return false + v.summary.gaps.shift() + }), + }, +] + +const required = [ + `unowned`, + `unreached`, + `timeout`, + `killed`, + `agreement-only-kill`, + `random-only-kill`, + `unregistered-kill`, + `crashed`, + `framework-error`, + `setup-error`, + `comparison-gap`, + `indeterminate`, + `generator-gap`, + `no-observed-difference`, + `ordinary killed`, + `a key with backticks`, +] + +// One constructed history per outcome class: one mutant judged by one +// oracle file's test, with the effect that makes the class, and an ordinary +// file whose assertion fails beside a survivor. +const four = (value) => Array.from({ length: MUTANTS }, () => value) +const single = (name, enters, { mutant = {}, test = {}, effect = {}, ordinary = false } = {}) => ({ + name, + enters, + build: () => ({ + files: [ + { + oracle: true, + tests: [ + { + route: `fixed seed`, + flaky: false, + reaches: four(true), + seen: four(`both`), + under: four({ kind: `same`, law: `L`, firedAt: undefined, ...effect }), + ...test, + }, + ], + background: four(false), + }, + ...(ordinary + ? [ + { + oracle: false, + tests: [ + { + route: `fixed seed`, + flaky: false, + reaches: four(true), + seen: four(`both`), + under: four({ kind: `assertion`, law: `L`, firedAt: undefined }), + }, + ], + background: four(false), + }, + ] + : []), + ], + mutants: [ + { + owners: [0], + line: 0, + operator: `delete-call`, + anchor: `out . push ( 'a' ) ;`, + probe: false, + ...mutant, + }, + ], + }), +}) +const constructed = [ + single(`an unowned mutant`, [`unowned`], { mutant: { owners: [] } }), + single(`an unreached mutant`, [`unreached`], { test: { reaches: four(false) } }), + single(`a run that timed out`, [`timeout`], { effect: { kind: `runTimeout` } }), + single(`a verdict on a fixed seed`, [`killed`], { effect: { kind: `verdict` } }), + single(`reach agreement alone`, [`agreement-only-kill`], { + effect: { kind: `verdict`, law: `reach agreement` }, + }), + single(`a verdict that fired in the random campaign`, [`random-only-kill`], { + effect: { kind: `verdict`, firedAt: `random seed` }, + }), + single(`a verdict with no route`, [`unregistered-kill`], { + test: { route: undefined }, + effect: { kind: `verdict` }, + }), + single(`a crash`, [`crashed`], { effect: { kind: `crash` } }), + single(`a failing calibration`, [`framework-error`], { + test: { route: `calibration` }, + effect: { kind: `crash` }, + }), + single(`a file that fails to load`, [`setup-error`], { effect: { kind: `setupError` } }), + single(`a changed value`, [`comparison-gap`], { effect: { kind: `changed` } }), + single(`a changed observation`, [`comparison-gap`], { effect: { kind: `observedChanged` } }), + single(`only a nondeterministic test`, [`indeterminate`], { test: { flaky: true } }), + single(`an if that took one outcome`, [`generator-gap`], { + mutant: { operator: `if-true` }, + test: { seen: four(`true`) }, + }), + single( + `a survivor an ordinary test fails`, + [`no-observed-difference`, `ordinary killed`], + { ordinary: true }, + ), + single(`a key with backticks`, [`a key with backticks`], { + mutant: { anchor: `x \`\` y` }, + effect: { kind: `changed` }, + }), +] + +oracleProperty({ + id: `scripts.gap-report-classify`, + law: { + statement: `From the records of the runs that judged it, each mutant gets the outcome the law assigns: kills only by law verdicts on reproducible routes, agreement-only, random-only, and unregistered kills, timeouts, crashes, framework and setup errors as gaps, survivors split into comparison gaps, indeterminate, generator gaps, and no observed difference, and ordinary tests for information only; the summary lists every gap and unowned mutant once with consistent counts.`, + authority: `The maintainer's gap-check decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), as for scripts.gap-report; oracle-rubric ORC-006 outcome kinds; the frozen design's oracle over the checker's pure core (v19 P10, F4); the maintainer's few-seconds budget for an incremental test run (2026-11-05).`, + limits: `No authored mutants. That real runs produce these records is scripts.gap-report's.`, + owner: `scripts/oracle-gaps`, + }, + histories: { + arbitrary: records, + states: required, + runs: 300, + fixedSeeds: [20261105], + constructed, + }, + resolve: (h) => h, + model, + driver, + check, + calibrations, + timeout: 60_000, +}) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs new file mode 100644 index 0000000000..c4c380990c --- /dev/null +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -0,0 +1,1610 @@ +/** + * The gap report oracles' shared history grammar, fixture rendering, and + * model (see gap-report-oracle.property.test.mjs for the laws). The static + * oracle runs the CLI's static half in-process on these fixtures; the + * end-to-end oracle runs the real CLI on them. + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import fc from 'fast-check' + +const SOURCE = `src/sut.ts` +// A second source in the same package, which the first imports from. +const LIB = `src/lib.ts` +const ORACLE_FILES = [`a`, `b`] +const METHOD_NAMES = [`run`, `step`, `tick`] + +// ## History grammar +// +// A history is a source file, the test files of its package, and how the +// CLI is run. The source holds one to three generated functions over `x`, +// each a free function or a method of class `C0` or `C1`; methods take +// names in order from `run`, `step`, `tick`, so the two classes share names. +// A statement pushes a label (quoted with `'` or a backtick), branches on +// one comparison against a constant, throws on a value no input reaches, +// fires a probe through a guard, a helper, a probe-only `if` or block, or +// calls a later function (by name, or through `this` within a class). +// +// Oracle files `a` and `b` declare owns entries: per function, or for the +// whole source, as strings or template literals. File `c` is an ordinary +// test file. A test calls one function with one input and observes the +// output, either through `__oracleObserve` or through `expect`; it may judge, +// reporting a verdict when the output differs from the unmutated one; it may +// register a route, chosen independently of its name; and it may also +// observe a clock, which makes it nondeterministic. +// +// A declaration fault breaks one owns declaration or one owner, and the run +// must then fail. The stability edit says how the source is changed for law +// 8. The steps are law 9's edits, applied one at a time between checks +// through the daemon (see `applyEdit`). The witness module adds functions whose outcomes the generated part +// reaches rarely, each with its own tests, so constructed histories reach +// every class. +const comparison = fc.constantFrom(`<`, `<=`, `>`, `>=`, `===`, `!==`) +const quote = fc.constantFrom(`'`, `\``) +const constant = fc.integer({ min: 0, max: 9 }) +const statement = fc.oneof( + fc.record({ kind: fc.constant(`push`), quote }), + fc.record({ + kind: fc.constant(`if`), + op: comparison, + k: constant, + not: fc.boolean(), + quote, + }), + fc.record({ + kind: fc.constant(`throwIf`), + message: fc.constantFrom(`plain`, `assertion`), + }), + fc.record({ kind: fc.constant(`guard`), op: comparison, k: constant, quote }), + // The probe form before the maintainer's decision: production code now. + fc.record({ kind: fc.constant(`oldProbe`) }), + fc.record({ kind: fc.constant(`mixed`), op: comparison, k: constant, quote }), + fc.record({ + kind: fc.constant(`helperCall`), + helper: fc.constantFrom(`report`, `reportTwice`), + }), + fc.record({ kind: fc.constant(`mixedHelperCall`) }), + fc.record({ kind: fc.constant(`probeIf`), op: comparison, k: constant }), + fc.record({ kind: fc.constant(`probeBlock`), op: comparison, k: constant }), + fc.record({ kind: fc.constant(`elseIf`), op: comparison, k: constant, quote }), + fc.record({ kind: fc.constant(`call`), target: fc.nat({ max: 3 }) }), + // A function passed as a value, a class constructed with `new`, a method + // called through a field, and a method called through `.bind`. + fc.record({ kind: fc.constant(`valueCall`), target: fc.nat({ max: 3 }) }), + fc.record({ kind: fc.constant(`newCall`), target: fc.nat({ max: 3 }) }), + fc.record({ kind: fc.constant(`fieldCall`), target: fc.nat({ max: 3 }) }), + fc.record({ kind: fc.constant(`bindCall`), target: fc.nat({ max: 3 }) }), + // A class passed as a value to a factory. + fc.record({ kind: fc.constant(`factoryCall`), target: fc.nat({ max: 3 }) }), + // A call to a function of the library file, by its name or an import alias. + fc.record({ + kind: fc.constant(`libCall`), + target: fc.nat({ max: 3 }), + alias: fc.boolean(), + }), +) +const libStatement = fc.oneof( + fc.record({ kind: fc.constant(`push`), quote }), + fc.record({ + kind: fc.constant(`if`), + op: comparison, + k: constant, + not: fc.boolean(), + quote, + }), +) +const routes = [ + `fixed seed`, + `fixed history`, + `case`, + `random seed`, + `calibration`, + `probe calibration`, + `determinism`, +] +const testCase = fc.record({ + file: fc.constantFrom(`a`, `b`, `c`), + route: fc.constantFrom(...routes), + registered: fc.constantFrom(true, true, true, false), + name: fc.constantFrom(`route`, `plain`), + fn: fc.nat({ max: 7 }), + input: constant, + judges: fc.boolean(), + law: fc.constantFrom(`L`, `reach agreement`), + channel: fc.constantFrom(`observe`, `observe`, `expect`), + flaky: fc.constantFrom(false, false, false, true), + // A test may judge by an `expect` assertion instead of a verdict, as older + // oracles do; that is a kill under an unnamed law. A soft assertion + // (`expect.soft`) records its failure and lets the test go on to its next + // campaign. + judgeBy: fc.constantFrom(`verdict`, `verdict`, `assertion`, `soft`), + // A test may run a second campaign, on its own route and input, as + // `runOracleCampaign` does. + then: fc.option( + fc.record({ route: fc.constantFrom(...routes), input: constant }), + { nil: null }, + ), +}) +// Law 9's edits. Each content edit changes what some evidence depends on: +// a test file, a source statement, the source's line numbers, an owns list, +// the authored mutant, a helper module that only file b imports, the seed +// (file a judges only under seed 1), or the set of oracle files. `restart` +// stops the daemon, and `revert` returns to the state before the last step. +const edits = [ + `test`, + `source`, + `prepend`, + `owns`, + `authored`, + `helper`, + `seed`, + `newOracle`, + `restart`, + `revert`, +] +const histories = fc.record({ + functions: fc.array(fc.array(statement, { minLength: 1, maxLength: 5 }), { + minLength: 1, + maxLength: 3, + }), + containers: fc.array(fc.constantFrom(`free`, `free`, `C0`, `C1`), { + maxLength: 3, + }), + // A free function may carry an overload signature before its body. + overloads: fc.array(fc.boolean(), { maxLength: 3 }), + owners: fc.array(fc.constantFrom(`a`, `b`, `both`, `none`), { maxLength: 3 }), + // The library file's functions and who owns each directly. + library: fc.array(fc.array(libStatement, { minLength: 1, maxLength: 3 }), { + maxLength: 2, + }), + libraryOwners: fc.array(fc.constantFrom(`a`, `b`, `none`, `none`), { + maxLength: 2, + }), + wholeFile: fc.constantFrom(`none`, `none`, `none`, `a`, `b`), + template: fc.boolean(), + tests: fc.array(testCase, { minLength: 1, maxLength: 5 }), + // The witness module: all of it, or only the functions whose outcomes + // need real test runs (see `captureWitness`). + witness: fc.constantFrom(false, true, `capture`), + authored: fc.option( + fc.record({ + stmt: fc.nat({ max: 20 }), + kind: fc.constantFrom(`apply`, `apply`, `stale`, `ambiguous`), + }), + { nil: null }, + ), + fault: fc.constantFrom( + `none`, + `none`, + `none`, + `none`, + `none`, + `none`, + `badEntry`, + `missingSource`, + `nonLiteral`, + `brokenOwner`, + ), + stability: fc.constantFrom( + `none`, + `none`, + `none`, + `prepend`, + `insertGuard`, + `reformat`, + ), + // Most histories have no steps, which keeps the campaign's cost down. + steps: fc.oneof( + { weight: 5, arbitrary: fc.constant(null) }, + { + weight: 1, + arbitrary: fc.array(fc.constantFrom(...edits), { + minLength: 1, + maxLength: edits.length, + }), + }, + ), +}) + +// The witness module: functions whose outcome classes generated histories +// reach rarely, each with its own owners and tests. +const kill = { kind: `if`, op: `>`, k: 4, not: false, quote: `'` } +const push = { kind: `push`, quote: `'` } +const judging = (file, route, input, extra = {}) => ({ + file, + route, + registered: true, + name: `route`, + input, + judges: true, + law: `L`, + channel: `observe`, + flaky: false, + judgeBy: `verdict`, + then: null, + ...extra, +}) +const witness = [ + { id: `w_kill`, owners: [`a`], body: [kill], tests: [judging(`a`, `fixed seed`, 7)] }, + { id: `w_both`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed seed`, 1), judging(`a`, `random seed`, 1)] }, + { id: `w_overloaded`, owners: [`a`], overload: true, body: [push], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_random`, owners: [`a`], body: [kill], tests: [judging(`a`, `random seed`, 7)] }, + { id: `w_agree`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed seed`, 1, { law: `reach agreement` })] }, + { id: `w_agreeboth`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed seed`, 1, { law: `reach agreement` }), judging(`a`, `case`, 1)] }, + { id: `w_crash`, owners: [`a`], body: [{ kind: `throwIf`, message: `assertion` }, push], tests: [judging(`a`, `case`, 3)] }, + { id: `w_frame`, owners: [`a`], body: [push], tests: [judging(`a`, `calibration`, 1)] }, + { id: `w_unregistered`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed seed`, 1, { registered: false })] }, + { id: `w_flaky`, owners: [`b`], body: [push], tests: [judging(`b`, `fixed seed`, 1, { flaky: true })] }, + { id: `w_observe`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed history`, 1, { judges: false })] }, + { id: `w_expect`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed history`, 1, { judges: false, channel: `expect` })] }, + { id: `w_noobs`, owners: [`a`], body: [kill], tests: [judging(`a`, `fixed seed`, 7), judging(`a`, `fixed seed`, 1)] }, + { id: `w_unreached`, owners: [`a`], body: [push], tests: [] }, + { id: `w_ordinary`, owners: [`a`], body: [push], tests: [judging(`c`, `case`, 1)] }, + { id: `w_mixed`, owners: [`a`], body: [{ kind: `mixed`, op: `>`, k: 4, quote: `'` }, { kind: `mixedHelperCall` }], tests: [judging(`a`, `fixed seed`, 7)] }, + { id: `w_else`, owners: [`a`], body: [{ kind: `elseIf`, op: `>`, k: 4, quote: `'` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_callsprobe`, owners: [`a`], body: [{ kind: `call`, to: `w_probeonly` }, push], tests: [judging(`a`, `fixed seed`, 7)] }, + { id: `w_probeonly`, owners: [], body: [{ kind: `guard`, op: `>`, k: 4, quote: `'` }, { kind: `probeIf`, op: `>`, k: 4 }], tests: [] }, + // A chain of probe helpers, each declared after its caller. + { id: `w_callschain`, owners: [`a`], body: [{ kind: `call`, to: `w_probe1` }, push], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_probe1`, owners: [], body: [{ kind: `call`, to: `w_probe2` }], tests: [] }, + { id: `w_probe2`, owners: [], body: [{ kind: `guard`, op: `>`, k: 4, quote: `\`` }], tests: [] }, + { id: `w_oldprobe`, owners: [`a`], body: [{ kind: `oldProbe` }, push], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_foreign`, owners: [`b`], body: [push], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_union`, owners: [`a`, `b`], body: [push], tests: [judging(`b`, `fixed seed`, 1)] }, + { id: `w_unowned`, owners: [], body: [push], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_entry`, owners: [`a`], body: [{ kind: `call`, to: `w_mid` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_mid`, owners: [], body: [{ kind: `call`, to: `w_leaf` }], tests: [] }, + { id: `w_leaf`, owners: [], body: [push], tests: [] }, + { id: `W0.run`, owners: [`a`], body: [{ kind: `call`, to: `W0.step` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `W0.step`, owners: [], body: [push], tests: [] }, + { id: `W1.run`, owners: [`b`], body: [push], tests: [judging(`b`, `fixed seed`, 1)] }, + // Owned across files: through an import, and through an import alias. + { id: `w_libentry`, owners: [`a`], body: [{ kind: `libCall`, to: `wg0`, alias: false }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_libalias`, owners: [`a`], body: [{ kind: `libCall`, to: `wg1`, alias: true }], tests: [judging(`a`, `fixed seed`, 1)] }, + // Owned through references the type checker resolves: a value, a field, + // a class constructed with `new` (which owns all its methods), and `.bind`. + { id: `w_value`, owners: [`a`], body: [{ kind: `valueCall`, to: `w_valued` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `w_valued`, owners: [], body: [push], tests: [] }, + { id: `W2.run`, owners: [`a`], body: [{ kind: `fieldCall`, to: `W3.run` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `W3.run`, owners: [], body: [push], tests: [] }, + { id: `W3.step`, owners: [], body: [push], tests: [] }, + { id: `w_new`, owners: [`a`], body: [{ kind: `newCall`, to: `W4.run` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `W4.run`, owners: [], body: [push], tests: [] }, + { id: `W4.step`, owners: [], body: [push], tests: [] }, + { id: `W5.run`, owners: [`a`], body: [{ kind: `bindCall`, to: `W5.step` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `W5.step`, owners: [], body: [push], tests: [] }, + // A class passed to a factory as a value brings all its methods. + { id: `w_factory`, owners: [`a`], body: [{ kind: `factoryCall`, to: `W6.run` }], tests: [judging(`a`, `fixed seed`, 1)] }, + // A constructor is a function: `new W7()` brings it, and it brings what it + // refers to. + { id: `w_ctor`, owners: [`a`], body: [{ kind: `newCall`, to: `W7.run` }], tests: [judging(`a`, `fixed seed`, 1)] }, + { id: `W7.constructor`, owners: [], signature: `constructor()`, field: `tag = 'a'`, body: [{ kind: `primeCall` }], tests: [] }, + { id: `W7.prime`, owners: [], signature: `prime(): void`, body: [{ kind: `setTag` }], tests: [] }, + { id: `W7.run`, owners: [], body: [{ kind: `pushTag` }], tests: [] }, + { id: `W6.run`, owners: [], body: [push], tests: [] }, + { id: `W6.step`, owners: [], body: [push], tests: [] }, + // An assertion judges, and a second campaign catches what the first misses. + { id: `w_assert`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed seed`, 1, { judgeBy: `assertion` })] }, + { id: `w_campaigns`, owners: [`a`], body: [kill], tests: [judging(`a`, `fixed seed`, 1, { then: { route: `random seed`, input: 7 } })] }, + // A soft assertion fails in the random campaign, and the test goes on to + // register a fixed seed: the verdict keeps the random route. + { id: `w_soft`, owners: [`a`], body: [kill], tests: [judging(`a`, `random seed`, 7, { judgeBy: `soft`, then: { route: `fixed seed`, input: 1 } })] }, + { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, + { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, + { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, +] + +// The witnesses whose outcomes depend on what real test runs record: law +// verdicts, assertions, soft assertions, routes, crashes, framework checks, +// nondeterminism, observations, ordinary tests, and several owners. The +// rest test the static half, which gap-report-static-oracle checks +// in-process. +const captureWitness = new Set([ + `w_kill`, + `w_both`, + `w_random`, + `w_agree`, + `w_agreeboth`, + `w_crash`, + `w_frame`, + `w_unregistered`, + `w_flaky`, + `w_observe`, + `w_expect`, + `w_noobs`, + `w_unreached`, + `w_ordinary`, + `w_foreign`, + `w_union`, + `w_unowned`, + `w_assert`, + `w_campaigns`, + `w_soft`, +]) + +// ## Rendering and layout +// +// `resolve` renders the source, one statement per line (a mixed or probe +// block takes four, an `else` statement two), and records each statement's +// first line and text. Free functions come first in index order, then each +// class's methods. A preamble defines `devBuild()`, now an ordinary function +// that only the older probe form calls; the probe helpers `report` and +// `reportTwice`; and the mixed helper `reportAndPush`, which probes and also +// writes output. The witness +// function `w_probeonly` is a probe helper declared after its caller. The +// model reads the layout; the driver writes the text. +// The decided probe form: one statement, a `NODE_ENV` guard and the hook +// called with the state's name and its condition. +// The guard's `production` may be quoted with `'` or a backtick, as the lint +// rule accepts. +const probeText = (probe, condition, quote = `'`) => + `process.env.NODE_ENV !== ${quote}production${quote} && (globalThis as { __oracleProbe?: (name: string, condition: unknown) => void }).__oracleProbe?.(${probe}, ${condition});` +// The form before the decision, which is production code now. +const oldProbeCallText = (probe) => + `(globalThis as { __oracleProbe?: (probe: string) => void }).__oracleProbe?.(${probe});` +const oldProbeText = (probe) => + `if (devBuild() && process.env.NODE_ENV !== 'production') ${oldProbeCallText(probe)}` +const preamble = [ + `function devBuild(): boolean {`, + ` try {`, + ` return process.env.NODE_ENV !== 'production'`, + ` } catch {`, + ` return false`, + ` }`, + `}`, + `function report(probe: string): void {`, + ` ${probeText(`probe`, `true`)}`, + `}`, + `function reportTwice(probe: string): void {`, + ` report(probe);`, + ` report(probe);`, + `}`, + `export function reportAndPush(probe: string, out: Array): void {`, + ` ${probeText(`probe`, `true`)}`, + ` out.push('h');`, + `}`, + `export function apply(fn: (x: number, out: Array) => void, x: number, out: Array): void {`, + ` fn(x, out);`, + `}`, + `export function make(cls: new () => { run(x: number, out: Array): void }, x: number, out: Array): void {`, + ` new cls().run(x, out);`, + `}`, +] +const preambleStatements = { + devBuild: `return process.env.NODE_ENV !== 'production'`, + reportTwice: `report(probe);`, + reportAndPushPush: `out.push('h');`, + applyCall: `fn(x, out);`, + makeCall: `new cls().run(x, out);`, +} + +const pushText = (stmt) => + stmt.quote === `'` + ? `out.push('s${stmt.label}');` + : `out.push(\`s${stmt.label}\`);` +// Statements that reach another function, each rendered by `callText`. +const referenceKinds = new Set([ + `call`, + `libCall`, + `valueCall`, + `newCall`, + `fieldCall`, + `bindCall`, + `factoryCall`, +]) +const condText = (stmt) => + stmt.not ? `!(x ${stmt.op} ${stmt.k})` : `x ${stmt.op} ${stmt.k}` + +/** The source lines of one statement. */ +function renderStatement(stmt, callText) { + const probe = `'p${stmt.label}'` + switch (stmt.kind) { + case `push`: + return [pushText(stmt)] + case `if`: + return [`if (${condText(stmt)}) ${pushText(stmt)}`] + case `throwIf`: + return [ + `if (x > 100) throw new Error('${stmt.message === `assertion` ? `expected x to be at most 100` : `boom`}');`, + ] + case `guard`: + return [probeText(probe, `x ${stmt.op} ${stmt.k}`, stmt.quote)] + case `oldProbe`: + return [oldProbeText(probe)] + case `mixed`: + return [ + `if (${condText(stmt)}) {`, + ` ${probeText(probe, `true`)}`, + ` ${pushText(stmt)}`, + `}`, + ] + case `helperCall`: + return [`${stmt.helper}(${probe});`] + case `mixedHelperCall`: + return [`reportAndPush(${probe}, out);`] + case `probeIf`: + return [`if (${condText(stmt)}) report(${probe});`] + case `probeBlock`: + return [ + `if (${condText(stmt)}) {`, + ` report(${probe});`, + ` ${probeText(probe, `true`)}`, + `}`, + ] + case `elseIf`: + return [`if (${condText(stmt)}) report(${probe});`, `else ${pushText(stmt)}`] + case `primeCall`: + return [`this.prime();`] + case `setTag`: + return [`this.tag = 'b';`] + case `pushTag`: + return [`out.push(this.tag);`] + case `call`: + case `libCall`: + case `valueCall`: + case `newCall`: + case `fieldCall`: + case `bindCall`: + case `factoryCall`: + return [callText] + } + throw new Error(`unknown statement kind ${stmt.kind}`) +} + +function resolveHistory(history) { + let label = 0 + const containerOf = (index) => history.containers[index] ?? `free` + // Generated functions: methods take names in order within their class. + const used = { C0: 0, C1: 0 } + const generated = history.functions.map((body, index) => { + const container = containerOf(index) + const id = + container === `free` + ? `f${index}` + : `${container}.${METHOD_NAMES[used[container]++]}` + return { + id, + container, + overload: container === `free` && history.overloads[index] === true, + generated: true, + owners: { a: [`a`], b: [`b`], both: [`a`, `b`], none: [] }[ + history.owners[index] ?? `none` + ], + body, + } + }) + // A call goes to a later function, by name to a free function, or through + // `this` to a method of the same class; any other target is a push. + const classOf = (id) => (id.includes(`.`) ? id.split(`.`)[0] : `free`) + for (const [index, fn] of generated.entries()) + fn.body = fn.body.map((stmt) => { + const later = generated.slice(index + 1) + const pick = (candidates) => + candidates.length > 0 + ? candidates[stmt.target % candidates.length].id + : undefined + const reference = { + valueCall: () => pick(later.filter((t) => t.container === `free`)), + newCall: () => pick(later.filter((t) => t.container !== `free`)), + fieldCall: () => + fn.container === `free` + ? undefined + : pick( + later.filter( + (t) => t.container !== `free` && t.container !== fn.container, + ), + ), + bindCall: () => + fn.container === `free` + ? undefined + : pick(later.filter((t) => t.container === fn.container)), + factoryCall: () => + pick(later.filter((t) => t.container !== `free` && t.id.endsWith(`.run`))), + }[stmt.kind] + if (reference) { + const to = reference() + return to ? { kind: stmt.kind, to } : { kind: `push`, quote: `'` } + } + if (stmt.kind !== `call`) return stmt + const target = later[stmt.target % Math.max(1, later.length)] + if ( + target && + (target.container === `free` || target.container === fn.container) + ) + return { kind: `call`, to: target.id } + return { kind: `push`, quote: `'` } + }) + const library = history.library.map((body, index) => ({ + id: `g${index}`, + file: LIB, + container: `free`, + generated: true, + library: true, + owners: { a: [`a`], b: [`b`], none: [] }[ + history.libraryOwners[index] ?? `none` + ], + body, + })) + // A library call names a library function; with none, it is a push. + for (const fn of generated) + fn.body = fn.body.map((stmt) => + stmt.kind !== `libCall` + ? stmt + : library.length > 0 + ? { + kind: `libCall`, + to: library[stmt.target % library.length].id, + alias: stmt.alias, + } + : { kind: `push`, quote: `'` }, + ) + const functions = [ + ...generated, + ...library, + ...(history.witness + ? witness + .filter((w) => history.witness === true || captureWitness.has(w.id)) + .map((w) => ({ + ...w, + container: w.id.includes(`.`) ? w.id.split(`.`)[0] : `free`, + generated: false, + })) + : []), + ].map((fn) => ({ + file: SOURCE, + ...fn, + body: fn.body.map((stmt) => ({ ...stmt, label: ++label })), + })) + const byId = new Map(functions.map((fn) => [fn.id, fn])) + const callText = (stmt) => + stmt.kind === `valueCall` + ? `apply(${stmt.to}, x, out);` + : stmt.kind === `factoryCall` + ? `make(${classOf(stmt.to)}, x, out);` + : stmt.kind === `newCall` + ? `new ${classOf(stmt.to)}().${stmt.to.split(`.`)[1]}(x, out);` + : stmt.kind === `fieldCall` + ? `this.peer${classOf(stmt.to)}.${stmt.to.split(`.`)[1]}(x, out);` + : stmt.kind === `bindCall` + ? `this.${stmt.to.split(`.`)[1]}.bind(this)(x, out);` + : stmt.kind === `libCall` + ? `${stmt.alias ? `h_${stmt.to}` : stmt.to}(x, out);` + : byId.get(stmt.to).container === `free` + ? `${stmt.to}(x, out);` + : `this.${stmt.to.split(`.`)[1]}(x, out);` + + // The main file starts with its import from the library, then the + // preamble, then free functions, then each class. The library holds its + // functions alone. + const imported = [ + ...new Set( + functions.flatMap((fn) => + fn.body + .filter((stmt) => stmt.kind === `libCall`) + .map((stmt) => (stmt.alias ? `${stmt.to} as h_${stmt.to}` : stmt.to)), + ), + ), + ] + const libLines = [] + const lines = [`import { ${imported.join(`, `)} } from './lib'`, ...preamble] + const emit = (fn, indent) => { + const name = fn.container === `free` ? fn.id : fn.id.split(`.`)[1] + if (fn.overload) + lines.push( + `${indent}export function ${name}(x: number, out: Array): void`, + ) + lines.push( + fn.container === `free` + ? `${indent}export function ${name}(x: number, out: Array): void {` + : `${indent}${fn.signature ?? `${name}(x: number, out: Array): void`} {`, + ) + for (const stmt of fn.body) { + stmt.line = lines.length + 1 + stmt.lines = renderStatement( + stmt, + referenceKinds.has(stmt.kind) ? callText(stmt) : undefined, + ) + for (const line of stmt.lines) lines.push(`${indent} ${line}`) + } + lines.push(`${indent}}`) + } + for (const fn of functions) + if (fn.container === `free` && fn.file === SOURCE) emit(fn, ``) + for (const cls of [...new Set(functions.map((f) => f.container))].filter( + (c) => c !== `free`, + )) { + lines.push(`export class ${cls} {`) + // A field holds each class that a method here calls through a field. + for (const peer of new Set( + functions + .filter((fn) => fn.container === cls) + .flatMap((fn) => fn.body) + .filter((stmt) => stmt.kind === `fieldCall`) + .map((stmt) => classOf(stmt.to)), + )) + lines.push(` peer${peer} = new ${peer}()`) + for (const fn of functions) + if (fn.container === cls && fn.field) lines.push(` ${fn.field}`) + for (const fn of functions) if (fn.container === cls) emit(fn, ` `) + lines.push(`}`) + } + const source = `${lines.join(`\n`)}\n` + // The library is laid out the same way, in its own file. + { + const mainLines = lines.splice(0) + for (const fn of functions) if (fn.file === LIB) emit(fn, ``) + libLines.push(...lines.splice(0)) + lines.push(...mainLines) + } + const librarySource = `${libLines.join(`\n`)}\nexport {}\n` + + const generatedIds = generated.map((fn) => fn.id) + const tests = [ + ...history.tests.map((t) => ({ + ...t, + fn: generatedIds[t.fn % generatedIds.length], + })), + ...functions.flatMap((fn) => + fn.generated ? [] : fn.tests.map((t) => ({ ...t, fn: fn.id })), + ), + ] + const pushes = functions.filter((fn) => fn.file === SOURCE).flatMap((fn) => + fn.body + .filter((stmt) => [`push`, `if`, `mixed`, `elseIf`].includes(stmt.kind)) + .map((stmt) => ({ fn: fn.id, stmt })), + ) + const authored = + history.authored && pushes.length > 0 + ? (() => { + const target = pushes[history.authored.stmt % pushes.length] + const find = + history.authored.kind === `stale` + ? `out.push('nope');` + : history.authored.kind === `ambiguous` + ? `out.push(` + : pushText(target.stmt) + return { + fn: target.fn, + label: target.stmt.label, + find, + replace: + history.authored.kind === `apply` + ? pushText(target.stmt).replace( + `s${target.stmt.label}`, + `s${target.stmt.label}!`, + ) + : `out.push('x');`, + applies: source.split(find).length - 1 === 1, + line: source.slice(0, source.indexOf(find)).split(`\n`).length, + kind: history.authored.kind, + } + })() + : undefined + return { + functions, + tests, + authored, + wholeFile: history.wholeFile, + template: history.template, + fault: history.fault, + stability: history.stability, + steps: history.steps ?? [], + history, + source, + librarySource, + } +} + +// ## Model +// +// The model is an interpreter of the grammar, not of TypeScript. Each +// statement owns the sites that law 1 promises for its form, in document +// order, at the line `resolve` laid out, with the statement text that law 6 +// anchors them to. Law 2 marks probe-only sites, deriving probe helpers +// among the generated free functions by the fixpoint the law states. +// Ownership (law 3) comes from the declared entries and the call +// statements, so it is a property of the source, not of what any test ran. +// A mutant is one active site: the interpreter forces or swaps its +// condition, drops its negation, or skips its call. Running each owner test +// with and without it gives the test's output, whether it threw, which sites +// it reached, and which outcomes each `if` took. Law 4's ladder turns those +// facts into the mutant's outcome. Nothing here reads the instrumenter or the +// CLI. +const swapped = { + '<': `<=`, + '<=': `<`, + '>': `>=`, + '>=': `>`, + '===': `!==`, + '!==': `===`, +} +const holds = (a, op, b) => + op === `<` + ? a < b + : op === `<=` + ? a <= b + : op === `>` + ? a > b + : op === `>=` + ? a >= b + : op === `===` + ? a === b + : a !== b +// A test's campaigns: its own route and input, then an optional second one. +const partsOf = (t) => [ + { route: t.route, input: t.input }, + ...(t.then ? [t.then] : []), +] +// A campaign's route kind; a test that registered nothing has none. +const routeOf = (t, part = t) => + !t.registered + ? `unknown` + : [`fixed seed`, `fixed history`, `case`].includes(part.route) + ? `reproducible` + : part.route === `random seed` + ? `random` + : `framework` + +/** Law 6's normalization: the statement's tokens, joined by single spaces. */ +function tokens(text) { + const pattern = + /'[^']*'|`[^`]*`|[A-Za-z_$][\w$]*|\d+|\?\.|=>|!==|===|&&|\|\||<=|>=|[^\s\w]/g + return text.match(pattern).join(` `) +} + +/** The functions whose every statement is probe-only (law 2), by fixpoint. */ +function probeHelpers(plan) { + // The preamble's helpers satisfy the same rule; they are not in the plan. + const helpers = new Set([`report`, `reportTwice`]) + const probeOnly = (stmt) => + [`guard`, `helperCall`, `probeIf`, `probeBlock`].includes(stmt.kind) || + (stmt.kind === `call` && helpers.has(stmt.to)) + for (let grew = true; grew; ) { + grew = false + for (const fn of plan.functions) + if ( + fn.container === `free` && + !helpers.has(fn.id) && + fn.body.every(probeOnly) + ) { + helpers.add(fn.id) + grew = true + } + } + return { helpers, probeOnly } +} + +function sitesOf(plan) { + const { helpers, probeOnly } = probeHelpers(plan) + const sites = [] + const fileOf = new Map(plan.functions.map((fn) => [fn.id, fn.file])) + const add = (fn, line, operator, text, extra = {}) => + sites.push({ + id: sites.length, + file: fileOf.get(fn) ?? SOURCE, + function: fn, + line, + operator, + text: tokens(text), + probe: false, + ...extra, + }) + // A decided probe statement: `&&`, then the guard's `!==`, then a + // comparison in its condition. It has no call statement to delete. + const guard = (fn, line, probe, condition, quote) => { + const text = probeText(probe, condition, quote) + add(fn, line, `binary-swap`, text, { probe: true }) + add(fn, line, `binary-swap`, text, { probe: true }) + if (condition !== `true`) add(fn, line, `binary-swap`, text, { probe: true }) + } + // `devBuild()` is an ordinary function now; only the older probe form calls it. + add(`devBuild`, 4, `binary-swap`, preambleStatements.devBuild, { + role: `devBuild`, + }) + guard(`report`, 10, `probe`, `true`) + add(`reportTwice`, 13, `delete-call`, preambleStatements.reportTwice, { + probe: true, + }) + add(`reportTwice`, 14, `delete-call`, preambleStatements.reportTwice, { + probe: true, + }) + guard(`reportAndPush`, 17, `probe`, `true`) + add(`reportAndPush`, 18, `delete-call`, preambleStatements.reportAndPushPush, { + role: `helperPush`, + }) + add(`apply`, 21, `delete-call`, preambleStatements.applyCall, { + role: `applyCall`, + }) + add(`make`, 24, `delete-call`, preambleStatements.makeCall, { + role: `makeCall`, + }) + for (const fn of plan.functions) { + const inHelper = helpers.has(fn.id) + for (const stmt of fn.body) { + const whole = stmt.lines.join(` `) + const probe = inHelper || probeOnly(stmt) + const at = (operator, role, line, text) => + add(fn.id, line, operator, text, { stmt: stmt.label, role, probe }) + const condition = () => { + at(`if-true`, `true`, stmt.line, whole) + at(`if-false`, `false`, stmt.line, whole) + if (stmt.not) at(`drop-not`, `not`, stmt.line, whole) + at(`binary-swap`, `swap`, stmt.line, whole) + } + if (stmt.kind === `push` || stmt.kind === `pushTag`) + at(`delete-call`, `push`, stmt.line, whole) + if (stmt.kind === `primeCall`) at(`delete-call`, `call`, stmt.line, whole) + if (stmt.kind === `if`) { + condition() + at(`delete-call`, `push`, stmt.line, pushText(stmt)) + } + if (stmt.kind === `throwIf`) condition() + if (stmt.kind === `guard`) + guard( + fn.id, + stmt.line, + `'p${stmt.label}'`, + `x ${stmt.op} ${stmt.k}`, + stmt.quote, + ) + if (stmt.kind === `oldProbe`) { + at(`if-true`, `oldTrue`, stmt.line, whole) + at(`if-false`, `oldFalse`, stmt.line, whole) + at(`binary-swap`, `oldAnd`, stmt.line, whole) + at(`binary-swap`, `oldNe`, stmt.line, whole) + at(`delete-call`, `oldCall`, stmt.line, oldProbeCallText(`'p${stmt.label}'`)) + } + if (stmt.kind === `mixed`) { + condition() + guard(fn.id, stmt.line + 1, `'p${stmt.label}'`, `true`) + at(`delete-call`, `push`, stmt.line + 2, pushText(stmt)) + } + if (stmt.kind === `helperCall`) + add(fn.id, stmt.line, `delete-call`, whole, { probe: true }) + if (stmt.kind === `mixedHelperCall` || referenceKinds.has(stmt.kind)) + at(`delete-call`, `call`, stmt.line, whole) + if (stmt.kind === `probeIf`) { + condition() + add(fn.id, stmt.line, `delete-call`, `report('p${stmt.label}');`, { + probe: true, + }) + } + if (stmt.kind === `probeBlock`) { + condition() + add(fn.id, stmt.line + 1, `delete-call`, `report('p${stmt.label}');`, { + probe: true, + }) + guard(fn.id, stmt.line + 2, `'p${stmt.label}'`, `true`) + } + if (stmt.kind === `elseIf`) { + // An `if` with a production `else` is production; its probe branch + // alone is probe-only. + condition() + add(fn.id, stmt.line, `delete-call`, `report('p${stmt.label}');`, { + probe: true, + }) + at(`delete-call`, `push`, stmt.line + 1, pushText(stmt)) + } + } + } + // Order on a line, and the anchor's occurrence index within a function. + const onLine = new Map() + const anchors = new Map() + for (const site of sites) { + const lineKey = `${site.file} ${site.line} ${site.operator}` + site.ordinal = onLine.get(lineKey) ?? 0 + onLine.set(lineKey, site.ordinal + 1) + const base = `${site.file}#${site.function}:${site.operator}:${site.text}` + const count = (anchors.get(base) ?? 0) + 1 + anchors.set(base, count) + site.key = count === 1 ? base : `${base}~${count}` + } + return sites +} + +/** Each function's owners: the files whose entries' call trees contain it. */ +function ownersOf(plan) { + // A reference is an edge: to the function or method it names; to `apply` + // and the value passed to it; and, for a class passed as a value, to every + // method of that class. `new C().m()` reaches only `m` (and C's + // constructor, which the grammar does not write). + const methodsOf = (cls) => + plan.functions.filter((fn) => fn.container === cls).map((fn) => fn.id) + const calls = new Map( + plan.functions.map((fn) => [ + fn.id, + fn.body.flatMap((stmt) => + stmt.kind === `valueCall` + ? [`apply`, stmt.to] + : stmt.kind === `factoryCall` + ? [`make`, ...methodsOf(stmt.to.split(`.`)[0])] + : stmt.kind === `newCall` + ? [stmt.to, `${stmt.to.split(`.`)[0]}.constructor`] + : stmt.kind === `primeCall` + ? [`W7.prime`] + : referenceKinds.has(stmt.kind) + ? [stmt.to] + : stmt.kind === `mixedHelperCall` + ? [`reportAndPush`] + : stmt.kind === `oldProbe` + ? [`devBuild`] + : [], + ), + ]), + ) + const owners = new Map() + const visit = (id, file) => { + const set = owners.get(id) ?? new Set() + if (set.has(file)) return + set.add(file) + owners.set(id, set) + for (const callee of calls.get(id) ?? []) visit(callee, file) + } + for (const fn of plan.functions) + for (const file of fn.owners) visit(fn.id, file) + // A whole-file entry names the main source, so it owns only its functions. + const inLibrary = new Set( + plan.functions.filter((fn) => fn.file === LIB).map((fn) => fn.id), + ) + return (id) => + new Set([ + ...(owners.get(id) ?? []), + ...(plan.wholeFile === `none` || inLibrary.has(id) ? [] : [plan.wholeFile]), + ]) +} + +/** One call of a function with at most one active mutation. */ +function interpret(plan, fnId, x, sites, active) { + const trace = [] + const reached = new Set() + const outcomes = [] + const siteOf = (stmt, role) => + sites.find((s) => s.stmt === stmt.label && s.role === role) + const helperPush = sites.find((s) => s.role === `helperPush`) + const isActive = (site) => + site !== undefined && active?.site !== undefined && active.site === site.id + const condition = (stmt) => { + for (const role of [`true`, `false`, `not`, `swap`]) + if (siteOf(stmt, role)) reached.add(siteOf(stmt, role).id) + const op = stmt.kind === `throwIf` ? `>` : stmt.op + const k = stmt.kind === `throwIf` ? 100 : stmt.k + let value = holds(x, isActive(siteOf(stmt, `swap`)) ? swapped[op] : op, k) + if (stmt.not && !isActive(siteOf(stmt, `not`))) value = !value + outcomes.push([stmt.line, value]) + if (isActive(siteOf(stmt, `true`))) return true + if (isActive(siteOf(stmt, `false`))) return false + return value + } + const write = (stmt) => { + const site = siteOf(stmt, `push`) + reached.add(site.id) + if (isActive(site)) return + trace.push( + active?.authored === stmt.label ? `s${stmt.label}!` : `s${stmt.label}`, + ) + } + const run = (id, object = { tag: `a` }) => { + const fn = plan.functions.find((f) => f.id === id) + for (const stmt of fn.body) { + if (stmt.kind === `push`) write(stmt) + if (stmt.kind === `pushTag`) { + const site = siteOf(stmt, `push`) + reached.add(site.id) + if (!isActive(site)) trace.push(object.tag) + } + if (stmt.kind === `setTag`) object.tag = `b` + if (stmt.kind === `primeCall`) { + const site = siteOf(stmt, `call`) + reached.add(site.id) + if (!isActive(site)) run(`W7.prime`, object) + } + if ((stmt.kind === `if` || stmt.kind === `mixed`) && condition(stmt)) + write(stmt) + if (stmt.kind === `elseIf` && !condition(stmt)) write(stmt) + if (stmt.kind === `probeIf` || stmt.kind === `probeBlock`) + condition(stmt) + if (stmt.kind === `throwIf` && condition(stmt)) throw new Error(`boom`) + if (stmt.kind === `oldProbe`) { + // `devBuild()` is true under test, so the guard holds and the probe + // is called; none of it writes output. + for (const role of [`oldTrue`, `oldFalse`, `oldAnd`, `oldNe`, `oldCall`]) + reached.add(siteOf(stmt, role).id) + reached.add(sites.find((s) => s.role === `devBuild`).id) + outcomes.push([stmt.line, true]) + } + if (referenceKinds.has(stmt.kind) || stmt.kind === `mixedHelperCall`) { + const site = siteOf(stmt, `call`) + reached.add(site.id) + if (isActive(site)) continue + if (stmt.kind === `valueCall` || stmt.kind === `factoryCall`) { + // `apply` calls the function it was given; `make` constructs the + // class and calls its `run`. + const inner = sites.find( + (s) => s.role === (stmt.kind === `valueCall` ? `applyCall` : `makeCall`), + ) + reached.add(inner.id) + if (isActive(inner)) continue + } + if (stmt.kind === `newCall`) { + // `new` runs the class's constructor, then the method, on one object. + const object = { tag: `a` } + const ctor = `${stmt.to.split(`.`)[0]}.constructor` + if (plan.functions.some((f) => f.id === ctor)) run(ctor, object) + run(stmt.to, object) + } else if (stmt.kind !== `mixedHelperCall`) run(stmt.to) + else { + reached.add(helperPush.id) + if (!isActive(helperPush)) trace.push(`h`) + } + } + } + } + let threw = false + try { + run(fnId) + } catch { + threw = true + } + return { trace, threw, reached, outcomes } +} + +function judge(plan, tests, sites, active) { + // A test runs its campaigns in order. Each failing judgment is a verdict + // that carries its campaign's route; an assertion is a verdict under an + // unnamed law. A verdict or a crash stops the test, except a soft + // assertion, which lets it go on. A failed test's own route is the route + // active when it ended. + const runs = tests + .filter((t) => !t.flaky) + .map((t) => { + let changedAny = false + const found = [] + for (const part of partsOf(t)) { + const base = interpret(plan, t.fn, part.input, sites) + const mutated = interpret(plan, t.fn, part.input, sites, active) + const changed = + !mutated.threw && mutated.trace.join() !== base.trace.join() + changedAny ||= changed + const route = routeOf(t, part) + if (mutated.threw) + return { t, route, crash: true, verdicts: found, changed: changedAny } + if (changed && t.judges) { + found.push({ + route, + law: t.judgeBy === `verdict` ? t.law : `(unnamed)`, + }) + if (t.judgeBy !== `soft`) + return { t, route, verdicts: found, changed: true } + } + } + return { + t, + route: routeOf(t, partsOf(t).at(-1)), + verdicts: found, + changed: changedAny, + } + }) + const verdicts = (route) => + runs.flatMap((r) => + r.verdicts.filter((v) => v.route === route).map((v) => v.law), + ) + const reproducible = verdicts(`reproducible`) + if (reproducible.length > 0) + return reproducible.every((law) => law === `reach agreement`) + ? `agreement-only-kill` + : `killed` + if (verdicts(`random`).length > 0) return `random-only-kill` + if (verdicts(`unknown`).length > 0) return `unregistered-kill` + const failing = runs.filter((r) => r.crash || r.verdicts.length > 0) + if (failing.some((r) => r.route !== `framework`)) return `crashed` + if (failing.length > 0) return `framework-error` + return runs.some((r) => r.changed) ? `comparison-gap` : `survived` +} + +/** Which declaration fault, if any, must make the run fail, and its cause. */ +function refusal(plan) { + if (plan.fault === `badEntry`) return { cause: `${SOURCE}#nope` } + if (plan.fault === `missingSource`) return { cause: `src/missing.ts` } + if (plan.fault === `nonLiteral`) return { cause: `b.test.ts` } + if (plan.fault === `brokenOwner`) return { cause: `b.test.ts` } + return undefined +} + +function model(plan) { + const refused = refusal(plan) + if (refused) return { refused } + const sites = sitesOf(plan) + const owners = ownersOf(plan) + const ownerTests = (id) => + plan.tests.filter( + (t) => ORACLE_FILES.includes(t.file) && owners(id).has(t.file), + ) + const kindOf = (site) => { + if (owners(site.function).size === 0) return `unowned` + const tests = ownerTests(site.function) + const reached = new Set() + const taken = new Map() + for (const t of tests) + for (const part of partsOf(t)) { + const run = interpret(plan, t.fn, part.input, sites) + for (const id of run.reached) reached.add(id) + for (const [line, value] of run.outcomes) + taken.set(line, new Set([...(taken.get(line) ?? []), value])) + } + if (!reached.has(site.id)) return `unreached` + const reaching = tests.filter((t) => + partsOf(t).some((part) => + interpret(plan, t.fn, part.input, sites).reached.has(site.id), + ), + ) + if (reaching.every((t) => t.flaky)) return `indeterminate` + const kind = judge(plan, tests, sites, { site: site.id }) + if (kind !== `survived`) return kind + return (taken.get(site.line)?.size ?? 2) < 2 + ? `generator-gap` + : `no-observed-difference` + } + const mutants = sites.map((site) => ({ + key: site.key, + source: site.file, + function: site.function, + line: site.line, + operator: site.operator, + ordinal: site.ordinal, + probe: site.probe, + kind: site.probe ? undefined : kindOf(site), + })) + const authored = + plan.authored && + (!plan.authored.applies + ? `does-not-apply` + : owners(plan.authored.fn).size === 0 + ? `unowned` + : judge(plan, ownerTests(plan.authored.fn), sites, { + authored: plan.authored.label, + })) + const isGap = (kind) => + kind !== undefined && ![`killed`, `unowned`].includes(kind) + const count = (kind) => mutants.filter((m) => m.kind === kind).length + // Each gap as the summary lists it: its key, its kind, and its line. + const entries = [ + ...mutants + .filter((m) => isGap(m.kind)) + .map((m) => ({ key: m.key, kind: m.kind, line: String(m.line) })), + ...(isGap(authored) + ? [ + { + key: `${SOURCE}#authored:a1`, + kind: authored, + line: String(plan.authored.applies ? plan.authored.line : undefined), + }, + ] + : []), + ].sort((a, b) => (a.key < b.key ? -1 : 1)) + const gaps = entries.map((e) => e.key) + return { + mutants, + authored, + authoredLine: plan.authored?.applies ? plan.authored.line : undefined, + gaps, + entries, + unowned: [ + ...mutants.filter((m) => m.kind === `unowned`).map((m) => m.key), + ...(authored === `unowned` ? [`${SOURCE}#authored:a1`] : []), + ].sort(), + counts: { + mutants: mutants.length + (plan.authored ? 1 : 0), + authored: plan.authored ? 1 : 0, + killed: count(`killed`) + (authored === `killed` ? 1 : 0), + gaps: gaps.length, + probe: mutants.filter((m) => m.probe).length, + }, + } +} + +const testName = (t, index) => + t.name === `plain` + ? `check ${index}` + : t.route === `fixed seed` + ? `fixed seed ${index + 1}` + : t.route === `fixed history` + ? `fixed history: h${index}` + : t.route === `case` + ? `case ${index}` + : t.route === `calibration` + ? `calibration: c${index}` + : t.route === `probe calibration` + ? `probe p${index} always fires` + : t.route +const callFor = (fn) => + fn.includes(`.`) + ? `new sut.${fn.split(`.`)[0]}().${fn.split(`.`)[1]}` + : `sut.${fn}` +const quoteEntry = (plan, entry) => + plan.template ? `\`${entry}\`` : `'${entry}'` + +function oracleTestFile(plan, file) { + const sites = sitesOf(plan) + const entries = [ + ...(plan.wholeFile === file ? [SOURCE] : []), + ...plan.functions + .filter((fn) => fn.owners.includes(file)) + .map((fn) => `${fn.file}#${fn.id}`), + ...(file === `b` && plan.fault === `badEntry` ? [`${SOURCE}#nope`] : []), + ...(file === `b` && plan.fault === `missingSource` + ? [`src/missing.ts#f0`] + : []), + ].map((entry) => quoteEntry(plan, entry)) + const owns = + file === `b` && plan.fault === `nonLiteral` + ? [`const OWNS = [${entries.join(`, `)}]`, `declareOracle({ owns: OWNS })`] + : [`declareOracle({ owns: [${entries.join(`, `)}] })`] + const out = [ + `import { describe, expect, test } from 'vitest'`, + `import * as sut from '../src/sut'`, + ...(file === `b` ? [`import { strict } from './strict'`] : []), + `function declareOracle(_parts: { owns: Array }): void {}`, + ...owns, + `const g = globalThis as { __oracleRoute?: (route: string) => void; __oracleObserve?: (c: string, v: unknown) => void; __oracleVerdict?: (v: { property: string; law: string; checkpoint: string }) => void }`, + ] + // Whether the file's tests judge, which law 9's edits change: file b's + // follow its helper module, and file a's hold only under seed 1. The + // driver's cold runs before any edit use seed 1 and a strict helper, so + // the gate is open there and the model need not read it. + const gate = + file === `a` + ? `process.env.TANSTACK_DB_ORACLE_SEED === '1'` + : file === `b` + ? `strict` + : `true` + plan.tests.forEach((t, index) => { + if (t.file !== file) return + // Each campaign registers its route, calls the function, observes, and + // judges the unmutated output, which the model computes. + const campaign = (part, i) => { + const expected = JSON.stringify( + interpret(plan, t.fn, part.input, sites).trace, + ) + const out = `out${i}` + return [ + ...(t.registered ? [` g.__oracleRoute?.('${part.route}')`] : []), + ` const ${out}: Array = []`, + ` ${callFor(t.fn)}(${part.input}, ${out})`, + t.channel === `expect` + ? ` expect(${out}).toBeDefined()` + : ` g.__oracleObserve?.('trace', ${out})`, + ...(t.flaky + ? [` g.__oracleObserve?.('clock', performance.now())`] + : []), + ...(!t.judges + ? [] + : t.judgeBy !== `verdict` + ? [ + ` if (${gate}) expect${t.judgeBy === `soft` ? `.soft` : ``}(${out}).toEqual(${expected})`, + ] + : [ + ` if (${gate} && JSON.stringify(${out}) !== '${expected}') {`, + ` g.__oracleVerdict?.({ property: 'fixture-${file}', law: '${t.law}', checkpoint: 'trace' })`, + ` throw new Error('violation')`, + ` }`, + ]), + ] + } + out.push( + `describe('t${index}', () => {`, + ` test('${testName(t, index)}', () => {`, + ...partsOf(t).flatMap(campaign), + ` })`, + `})`, + ) + }) + if (file === `b` && plan.fault === `brokenOwner`) + out.push( + `test('fixed seed 999', () => {`, + ` g.__oracleRoute?.('fixed seed')`, + ` throw new Error('this owner fails with no mutant active')`, + `})`, + ) + // A file with no tests still loads, so the CLI can read what it owns. + if (!plan.tests.some((t) => t.file === file)) + out.push(`test.skip('no tests', () => {})`) + return `${out.join(`\n`)}\n` +} + +function ordinaryTestFile(plan) { + const sites = sitesOf(plan) + const out = [ + `import { expect, test } from 'vitest'`, + `import * as sut from '../src/sut'`, + ] + plan.tests.forEach((t, index) => { + if (t.file !== `c`) return + const expected = JSON.stringify(interpret(plan, t.fn, t.input, sites).trace) + out.push( + `test('ordinary ${index}', () => {`, + ` const out: Array = []`, + ` ${callFor(t.fn)}(${t.input}, out)`, + ` expect(out).toEqual(${expected})`, + `})`, + ) + }) + if (!plan.tests.some((t) => t.file === `c`)) + out.push(`test.skip('no tests', () => {})`) + return `${out.join(`\n`)}\n` +} + +/** The source after the stability edit, which keeps every output the same. */ +function edited(plan) { + if (plan.stability === `prepend`) { + const [imports, ...rest] = plan.source.split(`\n`) + return [ + imports, + `export function zz(x: number, out: Array): void {`, + ` if (x > 1) out.push('u');`, + `}`, + ...rest, + ].join(`\n`) + } + if (plan.stability === `insertGuard`) { + const lines = plan.source.split(`\n`) + const at = plan.functions[0].body[0].line - 1 + const indent = lines[at].match(/^\s*/)[0] + lines.splice(at, 0, `${indent}${probeText(`'zz'`, `true`)}`) + return lines.join(`\n`) + } + // Whitespace only: drop spaces around comparisons, add them in calls. + return plan.source + .replaceAll(/ (<=|>=|===|!==|<|>) /g, `$1`) + .replaceAll(`(x, out)`, `( x,out )`) +} + +const AUTHORED = `mutants/authored-mutant.json` + +// Law 9's fixture state: the history, and what the edits outside it set. +const initialState = (plan) => ({ + history: plan.history, + prepend: false, + strict: true, + seed: 1, + newOracle: false, +}) + +/** The fixture package's files in one state. */ +function fixtureFiles(plan, state = initialState(plan)) { + const files = { + 'package.json': `{ "name": "gap-fixture", "private": true, "type": "module" }\n`, + 'vite.config.ts': `import { defineConfig } from 'vitest/config'\nexport default defineConfig({ test: { include: ['tests/**/*.test.ts'] } })\n`, + [SOURCE]: state.prepend + ? edited({ ...plan, stability: `prepend` }) + : plan.source, + [LIB]: plan.librarySource, + 'tests/strict.ts': `export const strict = ${state.strict}\n`, + 'tests/c.test.ts': ordinaryTestFile(plan), + } + for (const file of ORACLE_FILES) + files[`tests/${file}.test.ts`] = oracleTestFile(plan, file) + // The new oracle file d owns the first generated function and judges it. + if (state.newOracle) { + const target = plan.functions.find((fn) => fn.generated && !fn.library) + files[`tests/d.test.ts`] = oracleTestFile( + { + ...plan, + functions: plan.functions.map((fn) => + fn === target ? { ...fn, owners: [...fn.owners, `d`] } : fn, + ), + tests: [ + ...plan.tests, + { ...judging(`d`, `fixed seed`, 7), fn: target.id }, + ], + }, + `d`, + ) + } + if (plan.authored) + files[AUTHORED] = JSON.stringify({ + id: `a1`, + source: SOURCE, + find: plan.authored.find, + replace: plan.authored.replace, + intent: `pushes a different label`, + author: `gap-report oracle`, + }) + return files +} + +// The paths each fixture directory holds, so a state that lacks a file of +// the state before it removes that file. +const written = new Map() +function writeFixture(dir, files) { + for (const sub of [`src`, `tests`, `mutants`]) + mkdirSync(join(dir, sub), { recursive: true }) + for (const path of written.get(dir) ?? []) + if (!(path in files)) rmSync(join(dir, path), { force: true }) + for (const [path, text] of Object.entries(files)) + writeFileSync(join(dir, path), text) + written.set(dir, Object.keys(files)) +} + +/** + * One of law 9's edits applied to a fixture state. Edits inside the history + * are rendered again by `resolve`, so the fixture stays consistent: a test's + * expected output follows its function's new source. + */ +function applyEdit(state, edit) { + const h = state.history + if (edit === `test`) { + const first = Math.max( + 0, + h.tests.findIndex((t) => t.file === `a`), + ) + return { + ...state, + history: { + ...h, + tests: h.tests.map((t, i) => + i === first ? { ...t, judges: !t.judges } : t, + ), + }, + } + } + if (edit === `source`) { + const fi = h.functions.findIndex((body) => + body.some((stmt) => stmt.kind === `if`), + ) + const functions = + fi < 0 + ? [ + [{ kind: `push`, quote: `'` }, ...h.functions[0]].slice(0, 5), + ...h.functions.slice(1), + ] + : h.functions.map((body, i) => { + if (i !== fi) return body + const at = body.findIndex((stmt) => stmt.kind === `if`) + return body.map((stmt, j) => + j === at ? { ...stmt, k: (stmt.k + 1) % 10 } : stmt, + ) + }) + return { ...state, history: { ...h, functions } } + } + if (edit === `prepend`) return { ...state, prepend: !state.prepend } + if (edit === `owns`) { + const next = { a: `none`, b: `a`, both: `b`, none: `a` } + const owners = [...h.owners] + owners[0] = next[owners[0] ?? `none`] + return { ...state, history: { ...h, owners } } + } + if (edit === `authored`) + return { + ...state, + history: { + ...h, + authored: + h.authored?.kind === `apply` + ? { ...h.authored, kind: `stale` } + : { stmt: (h.authored?.stmt ?? -1) + 1, kind: `apply` }, + }, + } + if (edit === `helper`) return { ...state, strict: !state.strict } + if (edit === `seed`) return { ...state, seed: state.seed === 1 ? 2 : 1 } + if (edit === `newOracle`) return { ...state, newOracle: !state.newOracle } + return state +} + + +/** + * The summary's counts, gap keys, unowned keys, and each listed gap's kind + * and line, read from its text as the author reads it. + */ +function readSummary(summary) { + const header = summary.match( + /^(\d+) mutants \((\d+) authored\)\. (\d+) killed by a law on a reproducible route, .*?\*\*(\d+) gaps\*\*/m, + ) + const probe = summary.match(/ (\d+) mutants of state-probe code/) + // A key is read as Markdown reads a code span: it opens with a run of + // backticks and ends at the next run of exactly that length, and one + // space of padding on each side is dropped. A fence that a backtick in + // the key can close is therefore read as a shorter key. + const codeSpanAt = (text) => { + const fence = text.match(/^`+/)?.[0] + if (!fence) return undefined + const rest = text.slice(fence.length) + const close = [...rest.matchAll(/`+/g)].find( + (run) => run[0].length === fence.length, + ) + if (!close) return undefined + const content = rest.slice(0, close.index) + return content.startsWith(` `) && content.endsWith(` `) + ? content.slice(1, -1) + : content + } + const keysAfter = (label) => + summary + .split(`\n`) + .flatMap((line) => { + const at = line.match(new RegExp(`^ *- ${label}: `)) + const key = at && codeSpanAt(line.slice(at[0].length)) + return key === undefined || key === null ? [] : [key] + }) + return { + counts: header + ? { + mutants: Number(header[1]), + authored: Number(header[2]), + killed: Number(header[3]), + gaps: Number(header[4]), + probe: probe ? Number(probe[1]) : 0, + } + : undefined, + gaps: keysAfter(`Key`).sort(), + unowned: keysAfter(`Unowned`).sort(), + // Each listed gap's kind and line, from the bullet above its key. + entries: (() => { + const lines = summary.split(`\n`) + return lines + .flatMap((line, index) => { + const key = line.startsWith(` - Key: `) + ? codeSpanAt(line.slice(` - Key: `.length)) + : undefined + const head = lines[index - 1]?.match(/^- \*\*([\w-]+)\*\*, line (\S+?):/) + return key === undefined ? [] : [{ key, kind: head?.[1], line: head?.[2] }] + }) + .sort((a, b) => (a.key < b.key ? -1 : 1)) + })(), + } +} + +export { + captureWitness, + readSummary, + SOURCE, + LIB, + ORACLE_FILES, + METHOD_NAMES, + comparison, + quote, + constant, + statement, + libStatement, + routes, + testCase, + edits, + histories, + kill, + push, + judging, + witness, + probeText, + oldProbeCallText, + oldProbeText, + preamble, + preambleStatements, + pushText, + referenceKinds, + condText, + renderStatement, + resolveHistory, + swapped, + holds, + partsOf, + routeOf, + tokens, + probeHelpers, + sitesOf, + ownersOf, + interpret, + judge, + refusal, + model, + testName, + callFor, + quoteEntry, + oracleTestFile, + ordinaryTestFile, + edited, + AUTHORED, + initialState, + fixtureFiles, + written, + writeFixture, + applyEdit, +} diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs new file mode 100644 index 0000000000..cea91cfe7f --- /dev/null +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -0,0 +1,1000 @@ +/** + * # What does the gap report owe the oracle author? + * + * The gap report (`index.mjs`) is the input to the author's loop: the author + * reads each gap, states the law it reveals, and re-plans the oracle. A gap + * the report hides, a kill it credits wrongly, or a key that moves sends that + * loop the wrong way. This oracle states what the report owes for one source + * file and the test files of its package, and checks the real CLI against a + * small model. + * + * Laws, checked in this order: + * + * 1. **Enumeration.** The report runs exactly the operator mutants that the + * CLI's documented interface (the header of `schemata.mjs`) promises for + * each source form: `if-true` and `if-false` for an `if` condition, + * `binary-swap` for a comparison or a logical operator, `drop-not` for + * `!`, and `delete-call` for a call statement. Each names its function + * (`name`, or `Class.method` for a method) and its line. + * 2. **Probe-only code.** A mutant is left out as probe code exactly when its + * site is probe-only code: a probe statement in the decided form, + * `process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.(name, + * condition)` (the receiver may be cast), including its condition; a call + * statement to a probe helper, which is a function declaration whose every + * statement is probe-only; an `if` with no `else` whose branch is + * probe-only; or a block of probe-only statements. Code beside a probe is + * production code and is judged: a mixed block's condition and other + * statements, an `if` whose `else` does work, a function that probes and + * also writes output, and any other probe shape, such as the older + * `if (devBuild() && …) probe(name)` form and its `devBuild()` helper. + * 3. **Ownership.** The CLI discovers every oracle file in the package: a + * test file that calls `declareOracle({ owns })`. Each entry names a source + * file (`src/x.ts`, owning all of it) or a function in it + * (`src/x.ts#run`, `src/x.ts#Class.method`), as a string or a plain + * template literal. Owning a function owns its static reference tree within + * the package, as the type checker resolves it: every function, method, + * or class that its body refers to, whether it calls it by name, through + * an import or its alias, through `this` or a typed field, through + * `.bind`, or passes it as a value. A class passed as a value brings all + * its methods, because which one is called is unknown statically; `new + * C()` brings only C's constructor (named `C.constructor`), which brings + * what its body refers to, and C's methods are owned when something in + * the tree refers to them. A + * function's owners are the oracle files whose trees contain it; a mutant + * in a function with no owner is `unowned` and is listed as a warning, not + * a gap. An owns entry that names nothing, an owns list that is not a + * literal, and an owner whose tests fail with no mutant active each make + * the run fail and name the cause, because each would otherwise hide gaps. + * 4. **Classification across owners.** Each mutant is judged only by the + * tests of its function's owners. Each test's route is the one the + * framework registered for it (`__oracleRoute`), never its name; a test + * that runs several campaigns registers each one, and a verdict takes the + * route active when it fired, even when a soft assertion lets the test go + * on to another campaign; a test that registered none has an unknown + * route. A law verdict names its law; an `expect` assertion failure, as + * older oracles judge, is a verdict under an unnamed law (transitional, + * maintainer decision of 2026-11-05). A test whose observations + * vary between two unmutated runs is nondeterministic and judges nothing. + * The mutant is `killed` when an owner test on a reproducible route (a + * fixed seed, a fixed history, or an enumerated case) reports a law + * verdict, unless reach agreement is its only law, which makes it an + * `agreement-only-kill`. Failing that, a verdict on the random campaign + * makes it a `random-only-kill`, and a verdict on an unknown route an + * `unregistered-kill`; an owner test outside the framework's own checks + * that fails with no verdict, whatever its message, makes it `crashed`; + * and a failing framework check alone (a calibration, a probe + * calibration, or the determinism test) makes it a `framework-error`. A + * mutant that no owner test reaches is `unreached`; one that only + * nondeterministic owner tests could judge is `indeterminate`. A mutant + * that no owner test fails is a survivor: a `comparison-gap` when an owner + * test's observation or asserted value changed, a `generator-gap` when an + * `if` on its line never took one of its outcomes in an owner test, and + * `no-observed-difference` otherwise. A test in a file that is not an + * oracle file never changes any of this. Only `killed` closes an owned + * mutant; every other class is a gap. + * 5. **Authored mutants.** An authored mutant whose text occurs exactly once + * is judged by law 4 against the owners of the function it edits, without + * the generator-gap class, and is reported at the line of its edit; one + * whose text occurs zero or several times is `does-not-apply`. Its key is + * its id, whatever its file is called. + * 6. **Keys.** Each mutant's key is its anchor: + * `file#function:operator:statement`, where `statement` is the text of the + * innermost statement that holds the site, as tokens joined by single + * spaces, in full; a repeat of the same anchor in one function gets `~n` + * in document order. Keys are unique. + * 7. **Report completeness.** The summary lists every gap exactly once under + * its key, with its kind and line, lists every unowned mutant as a + * warning, and its counts of mutants, kills, gaps, and left-out probe code + * are the model's. + * 8. **Key stability.** Adding an unrelated function, adding a probe guard to + * an existing function, or reformatting whitespace changes no existing + * mutant's key or outcome. + * 9. **Incremental equals cold.** A check through the daemon (`--daemon`) + * reports, at every state of a sequence of edits, what a cold run of that + * state reports: the same exit status and refusal cause, the same run + * identity, results, and summary. Only how the result was produced may + * differ: durations, the options that name the output directory and the + * daemon, and the counts of reused and computed evidence. The edits change + * a test, a source statement, the source's line numbers, an owns list, + * the authored mutant, a helper module that one oracle file imports, the + * seed, and the set of oracle files; they also restart the daemon and + * revert the last edit. The law says nothing unless the daemon reuses + * evidence, so the reach requires a check that reused evidence, one that + * reused some and recomputed some, one that reused evidence from disk + * after a restart, and, for each content edit, a cold result that the + * edit changed: an edit that changes nothing cannot expose a stale cache. + * + * Authority: the maintainer's decisions in the design runs v11, v14, and v19 + * (`~/.cache/oracle-grammar/`, 2026-11-04 and 2026-11-05; not yet in the + * repository's decision register): a kill is a law verdict on a + * deterministic route, and a crash, a timeout, a driver failure, or an + * ordinary-test kill is not (v11 P7, v14 P6, v19 P3); the route comes from + * registration (v19 A4); gaps are judged across every owner of a function, + * and owning an entry owns its static call tree (v19 P6, v19.1, v19.2); the + * probe form (v11, confirmed 2026-11-05: "this is a standalone framework now + * so we can't use the devBuild() stuff"); only probe-only code is left + * out (v19 P5, A5); gaps are anchored by function and normalized statement + * text with an occurrence index (v11, "An allowance lists its gaps"); unowned + * code is a warning (v14 P7); and no quiet exclusions (AGENTS.md). Law 1's + * operator list is the CLI's documented interface. ORC-006 names the outcome + * kinds this report must keep distinct: assertion failure, timeout, setup + * failure, runtime failure outside the comparison, unreached, and survival. + * A kill by reach agreement alone is a gap (maintainer decision, 2026-11-05). + * Law 9 is the maintainer's: incremental runs must be fast enough for an + * agent that checks hundreds of times, through a daemon whose evidence + * table is a disk-backed TanStack DB collection, and an incremental run must + * equal a cold one (v19.3, 2026-11-05); the daemon is a cache over the + * evidence, never the record (v19 freeze). + * + * Limits. Sources are one file of exported functions and classes over one + * number. Not generated: loops (so no timeouts or setup errors), multi-line + * conditions, arrow functions and functions passed as values, nested + * functions, two functions on one line, getters and setters, + * `await`, `new`, and `void` statements, `--only`, `--sample`, and + * `--diff-base`, re-exports, namespace imports, and calls into another + * package (a package's tree stops at its boundary, by the maintainer's + * decision of 2026-11-05). A probe-bound state is not generated, so the claim that + * reach agreement kills every probe mutant is not checked. Gap answers and + * the gate are not part of the CLI until answers become evidence-log events, + * which waits on the maintainer's decision about how an answer maps to a law. + * Law 9's edits do not include a file that a test reads through `fs`, an + * environment variable other than the seed, a new file that changes how an + * unchanged import resolves, a change to the Vite config or the installed + * packages, or two checks at once. + * + * Structure: + * 1. The grammar draws functions, classes, owners, tests, an authored + * mutant, a declaration fault, and an edit for the stability law. + * `resolve` renders them to files and lays out lines. + * 2. The model runs each mutant through a small interpreter of that grammar + * and predicts every report fact, without the instrumenter or the CLI. + * 3. The driver writes a scratch package, runs the real CLI on it, and + * records its results, summary, and exit status. For law 9 it then + * applies each edit, and runs each state cold and through the daemon. + * 4. The check compares them law by law. + */ +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import fc from 'fast-check' +import { declareOracle } from '../../packages/db/tests/oracle-declaration.ts' +import { + oracleProperty, + pass, + violation, +} from '../../packages/db/tests/oracle-property.ts' + +import { + SOURCE, + LIB, + quote, + statement, + edits, + histories, + push, + judging, + witness, + resolveHistory, + probeHelpers, + ownersOf, + interpret, + refusal, + model, + edited, + AUTHORED, + initialState, + fixtureFiles, + written, + writeFixture, + applyEdit, + readSummary, +} from './gap-report-model-oracle.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const repo = resolve(here, `../..`) + +// ## Production driver +// +// The driver writes the plan into a scratch package inside this directory, so +// the package resolves the repository's TypeScript and Vitest, and runs the +// real CLI on it with a fixed seed. It names no test files: the CLI must find +// the oracle files and their owners itself. Each oracle file registers each +// test's route through `__oracleRoute`, as the framework does, unless the +// history says not to. The driver records the exit status, each mutant's +// result, and the summary's counts, gap keys, and unowned keys. A stability +// history runs the CLI again on the edited source. +// The child gets a clean environment: no state from this Vitest worker. +const cleanEnv = () => + Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => + !name.startsWith(`VITEST`) && !name.startsWith(`TANSTACK_DB_ORACLE`), + ), + ) + +/** + * Writes the fixture's files and runs the CLI on it: cold, or through the + * daemon (law 9). A file of an earlier state that this state lacks is + * removed. + */ +function runCli(dir, files, { seed = 1, daemon = false } = {}) { + writeFixture(dir, files) + const out = join(dir, daemon ? `out-daemon` : `out`) + rmSync(out, { recursive: true, force: true }) + const args = [ + join(here, `index.mjs`), + ...(daemon ? [`--daemon`] : []), + `--package`, + dir, + `--source`, + `${SOURCE},${LIB}`, + `--seed`, + String(seed), + // Four warm contexts run mutants in parallel; results do not depend on + // the count. + `--contexts`, + `4`, + `--out`, + out, + ] + if (files[AUTHORED] !== undefined) args.push(`--mutants`, join(dir, `mutants`)) + const env = cleanEnv() + const child = spawnSync(process.execPath, args, { + cwd: repo, + encoding: `utf8`, + timeout: 300_000, + maxBuffer: 64 * 1024 * 1024, + env, + }) + let results + let report + let summary = `` + try { + report = JSON.parse(readFileSync(join(out, `results.json`), `utf8`)) + results = report.results + summary = readFileSync(join(out, `summary.md`), `utf8`) + } catch { + // A run that wrote no report is recorded by its exit status and output. + } + return { + exit: child.status, + stderr: child.status === 0 ? undefined : `${child.stderr}`.slice(-1500), + reported: results !== undefined, + // Law 9 compares everything the run reports except how it was + // produced: durations, the options it was given (the output directory + // and the daemon flag differ by construction), and the counts of reused + // and computed evidence. + // Paths are recorded relative to the package, so two runs of one + // history in different scratch directories record the same report. + report: + report && + JSON.parse( + JSON.stringify({ + run: report.run, + excluded: report.excluded, + nondeterministic: report.nondeterministic, + results: report.results.map(({ seconds, ...rest }) => rest), + summary, + }).replaceAll(`${dir}/`, `/`), + ), + evidence: report?.evidence, + // A refused run is compared by the cause it names. + refusal: + child.status === 0 + ? undefined + : `${child.stderr}`.match(/^Error: (.*)$/m)?.[1], + mutants: (results ?? []).map((r) => ({ + key: r.key, + source: r.source, + function: r.function, + line: r.line, + operator: r.operator, + authored: r.authored === true, + outcome: r.outcome, + class: r.class, + laws: r.laws, + probe: r.probeGuard === true, + })), + ...readSummary(summary), + } +} + +/** + * Law 9: the daemon checks each state of the steps, and the same state is + * run cold. A cold run is deterministic, so a state seen before (after + * `restart` or `revert`) keeps its first cold result. + */ +function incremental(dir, plan, cli, observe, states) { + const cold = new Map() + const fingerprint = (files, seed) => JSON.stringify([files, seed]) + cold.set(fingerprint(fixtureFiles(plan), 1), cli) + // A cold result without what the report names as its run, so that two + // states compare by what they report. + const result = (run) => + JSON.stringify([ + run.exit, + run.refusal, + run.report && { ...run.report, run: undefined, summary: undefined }, + ]) + const trail = [initialState(plan)] + let previous = cli + const checkAt = (step) => { + const state = trail.at(-1) + const files = fixtureFiles(resolveHistory(state.history), state) + const key = fingerprint(files, state.seed) + if (!cold.has(key)) cold.set(key, runCli(dir, files, { seed: state.seed })) + const coldRun = cold.get(key) + const daemon = runCli(dir, files, { seed: state.seed, daemon: true }) + observe(`incremental`, { + step, + cold: { + exit: coldRun.exit, + refusal: coldRun.refusal, + report: coldRun.report, + }, + daemon: { + exit: daemon.exit, + refusal: daemon.refusal, + report: daemon.report, + stderr: daemon.stderr, + }, + }) + const reused = daemon.evidence?.reused ?? 0 + const computed = daemon.evidence?.computed ?? 0 + if (step !== `start` && reused > 0) + states.add(`incremental check reused evidence`) + if (step !== `start` && reused > 0 && computed > 0) + states.add(`incremental check reused some evidence and recomputed some`) + if (step === `restart` && reused > 0) + states.add(`evidence reused after a daemon restart`) + // An edit that changes the cold result is one a stale cache could miss. + if (step !== `start` && result(previous) !== result(coldRun)) + states.add(`edit ${step} changed the cold result`) + previous = coldRun + } + const stop = () => + spawnSync( + process.execPath, + [join(here, `index.mjs`), `--stop-daemon`, `--package`, dir], + { cwd: repo, encoding: `utf8`, timeout: 60_000, env: cleanEnv() }, + ) + try { + checkAt(`start`) + for (const step of plan.steps) { + if (step === `restart`) stop() + else if (step === `revert`) { + if (trail.length > 1) trail.pop() + } else trail.push(applyEdit(trail.at(-1), step)) + checkAt(step) + } + } finally { + stop() + } +} + +async function driver(plan, observe) { + const dir = mkdtempSync(join(here, `.scratch-`)) + try { + const files = fixtureFiles(plan) + const cli = runCli(dir, files) + observe(`cli`, cli) + // Reach comes from what the CLI reported, mapped onto the plan. + const states = new Set() + // Each kind of declaration fault is its own state, so each refusal has + // a history that depends on it. + if (plan.fault !== `none` && cli.exit !== 0) + states.add(`refused ${plan.fault}`) + const kindAt = new Map( + plan.functions.flatMap((fn) => + fn.body.map((s) => [`${fn.file}:${s.line}`, s.kind]), + ), + ) + for (const m of cli.mutants) { + if (m.authored) { + states.add( + m.outcome !== `does-not-apply` + ? `authored mutant` + : plan.authored.kind === `ambiguous` + ? `ambiguous authored mutant` + : `stale authored mutant`, + ) + continue + } + if (m.probe) { + if (m.function === `w_probeonly`) states.add(`derived probe helper`) + if (m.function === `w_callschain`) + states.add(`derived probe helper chain`) + continue + } + const generatedFn = plan.functions.find( + (fn) => fn.generated && fn.id === m.function, + ) + if ( + generatedFn && + generatedFn.owners.length === 0 && + plan.wholeFile !== `none` && + m.outcome !== `unowned` + ) + states.add(`owned by a whole-file entry`) + if (m.function === `w_overloaded` && m.outcome !== `unowned`) + states.add(`overloaded function owned`) + if (m.function === `w_both` && m.outcome === `killed`) + states.add(`killed on both routes`) + if (m.function === `wg0` && m.outcome === `killed`) + states.add(`owned across files`) + if (m.function === `wg1` && m.outcome === `killed`) + states.add(`owned through an aliased import`) + if (m.source === LIB && m.outcome === `unowned`) + states.add(`unowned in another file`) + if (m.function === `w_valued` && m.outcome === `killed`) + states.add(`owned through a value`) + if (m.function === `W3.run` && m.outcome === `killed`) + states.add(`owned through a field`) + if (m.function === `W7.constructor` && m.outcome === `killed`) + states.add(`constructor owned through new`) + if (m.function === `W4.step` && m.outcome === `unowned`) + states.add(`a constructed class brings only what is referenced`) + if (m.function === `W6.step` && m.outcome !== `unowned`) + states.add(`a class passed as a value brings its methods`) + if ( + m.function === `w_assert` && + m.outcome === `killed` && + m.laws?.includes(`(unnamed)`) + ) + states.add(`killed under an unnamed law`) + if (m.function === `w_campaigns` && m.outcome === `random-only-kill`) + states.add(`second campaign's route attributed`) + if (m.function === `w_soft` && m.outcome === `random-only-kill`) + states.add(`a soft verdict keeps its campaign's route`) + if (m.function === `W5.step` && m.outcome === `killed`) + states.add(`owned through bind`) + if (kindAt.get(`${m.source}:${m.line}`) === `oldProbe`) states.add(`old probe form judged`) + if (kindAt.get(`${m.source}:${m.line}`) === `mixed` && m.operator === `if-true`) + states.add(`production beside a probe`) + if (kindAt.get(`${m.source}:${m.line}`) === `elseIf` && m.operator === `if-true`) + states.add(`production else judged`) + if (m.function === `reportAndPush`) states.add(`mixed helper judged`) + if (m.function === `w_leaf` && m.outcome !== `unowned`) + states.add(`owned through a call chain`) + if (m.function === `W0.step` && m.outcome !== `unowned`) + states.add(`method owned through this`) + if (m.function === `w_union` && m.outcome === `killed`) + states.add(`killed by a second owner`) + const named = { + killed: `killed`, + 'random-only-kill': `random-only kill`, + 'agreement-only-kill': `agreement-only kill`, + 'unregistered-kill': `unregistered kill`, + crashed: `crash gap`, + 'framework-error': `framework-only failure`, + unreached: `unreached`, + unowned: `unowned`, + }[m.outcome] + if (named) states.add(named) + if (m.class === `comparison-gap`) states.add(`comparison gap`) + if (m.class === `generator-gap`) states.add(`generator gap`) + if (m.class === `no-observed-difference`) + states.add(`no observed difference`) + if (m.class === `indeterminate`) states.add(`indeterminate`) + } + if (plan.stability !== `none` && cli.exit === 0) { + const moved = { ...files, [SOURCE]: edited(plan) } + observe(`stability`, runCli(dir, moved)) + states.add(`stability rerun`) + } + if (plan.steps.length > 0) incremental(dir, plan, cli, observe, states) + for (const state of states) observe(`reach`, state) + } finally { + written.delete(dir) + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // Cleanup must not replace the observation or its failure. + } + } +} + +// ## Comparison +// +// The check matches the CLI's mutants to the model's by function, line, +// operator, and order on the line, then checks each law in turn and reports +// the first that fails. A run that must be refused is checked for that +// alone. A run that did not complete fails `completion`, so a crash of the +// tool is not read as a wrong enumeration. +/** + * Law 9: each check through the daemon reports what the cold run of the same + * state reports. A refused state is compared by the cause it names. + */ +function incrementalCheck(observations) { + for (const { value } of observations.filter( + (o) => o.checkpoint === `incremental`, + )) { + const { step, cold, daemon } = value + if ( + daemon.exit !== cold.exit || + daemon.refusal !== cold.refusal || + (cold.report !== undefined) !== (daemon.report !== undefined) + ) + return violation( + `incremental`, + `after ${step}, the daemon's check exited ${daemon.exit} (${daemon.refusal ?? daemon.stderr ?? `no refusal`}) and the cold run exited ${cold.exit} (${cold.refusal ?? `no refusal`})`, + `incremental equals cold`, + { actual: daemon, expected: cold }, + ) + if (!cold.report) continue + if (JSON.stringify(daemon.report) === JSON.stringify(cold.report)) continue + const at = cold.report.results.findIndex( + (r, i) => + JSON.stringify(r) !== JSON.stringify(daemon.report.results[i]), + ) + return violation( + `incremental`, + `after ${step}, the daemon's report differs from the cold run's${at >= 0 ? ` at ${cold.report.results[at].key}` : ``}`, + `incremental equals cold`, + at >= 0 + ? { actual: daemon.report.results[at], expected: cold.report.results[at] } + : { actual: daemon.report, expected: cold.report }, + ) + } + return undefined +} + +function check(plan, observations, expected) { + const cli = observations.find((o) => o.checkpoint === `cli`)?.value + if (!cli) return violation(`cli`, `no CLI run was observed`, `completion`) + if (expected.refused) { + if (cli.exit === 0) + return violation( + `cli`, + `the run completed although ${expected.refused.cause} must refuse it`, + `ownership`, + ) + if (!`${cli.stderr}`.includes(expected.refused.cause)) + return violation( + `cli`, + `the refusal does not name ${expected.refused.cause}: ${cli.stderr}`, + `ownership`, + ) + return incrementalCheck(observations) ?? pass + } + if (cli.exit !== 0 || !cli.reported) + return violation( + `cli`, + `the CLI did not complete (exit ${cli.exit}): ${cli.stderr ?? `no results`}`, + `completion`, + ) + const seen = new Map() + const actual = cli.mutants + .filter((m) => !m.authored) + .map((m) => { + const key = `${m.source} ${m.line} ${m.operator}` + const ordinal = seen.get(key) ?? 0 + seen.set(key, ordinal + 1) + return { ...m, ordinal } + }) + const site = (m) => + `${m.source}:${m.function}:${m.line}:${m.operator}#${m.ordinal}` + if ( + actual.map(site).sort().join() !== expected.mutants.map(site).sort().join() + ) + return violation(`cli`, `the mutant set differs`, `enumeration`, { + actual: actual.map(site).sort(), + expected: expected.mutants.map(site).sort(), + }) + const pairs = expected.mutants.map((m) => [ + m, + actual.find((a) => site(a) === site(m)), + ]) + for (const [owed, got] of pairs) + if (owed.probe !== got.probe) + return violation( + `cli`, + `${site(got)}: probe code is ${got.probe}, owed ${owed.probe}`, + `probe-only code`, + { actual: got, expected: owed }, + ) + for (const [owed, got] of pairs) { + if (owed.probe) continue + const kind = got.class ?? got.outcome + if (kind !== owed.kind) + return violation( + `cli`, + `${site(got)}: ${kind}, owed ${owed.kind}`, + owed.kind === `unowned` || kind === `unowned` + ? `ownership` + : `classification`, + { actual: got, expected: owed }, + ) + } + const authored = cli.mutants.find((m) => m.authored) + const authoredKind = authored && (authored.class ?? authored.outcome) + if (authoredKind !== expected.authored) + return violation( + `cli`, + `the authored mutant is ${authoredKind}, owed ${expected.authored}`, + `authored mutants`, + { actual: authoredKind, expected: expected.authored }, + ) + for (const [owed, got] of pairs) + if (got.key !== owed.key) + return violation( + `cli`, + `${site(got)}: key ${JSON.stringify(got.key)}, owed ${JSON.stringify(owed.key)}`, + `keys`, + { actual: got.key, expected: owed.key }, + ) + if (authored && authored.key !== `${SOURCE}#authored:a1`) + return violation(`cli`, `the authored key is ${authored.key}`, `keys`) + if (expected.authoredLine !== undefined && authored.line !== expected.authoredLine) + return violation( + `cli`, + `the authored mutant is reported at line ${authored.line}, owed ${expected.authoredLine}`, + `authored mutants`, + ) + if ( + cli.gaps.join(`\n`) !== expected.gaps.join(`\n`) || + JSON.stringify(cli.entries) !== JSON.stringify(expected.entries) || + cli.unowned.join(`\n`) !== expected.unowned.join(`\n`) || + JSON.stringify(cli.counts) !== JSON.stringify(expected.counts) + ) + return violation( + `cli`, + `the summary does not list exactly the owed gaps, unowned warnings, and counts`, + `report completeness`, + { + actual: { entries: cli.entries, unowned: cli.unowned, counts: cli.counts }, + expected: { + entries: expected.entries, + unowned: expected.unowned, + counts: expected.counts, + }, + }, + ) + const moved = observations.find((o) => o.checkpoint === `stability`)?.value + if (moved) { + if (moved.exit !== 0 || !moved.reported) + return violation( + `stability`, + `the CLI did not complete on the edited source (exit ${moved.exit}): ${moved.stderr ?? `no results`}`, + `completion`, + ) + const describe = (m) => `${m.key} ${m.class ?? m.outcome} ${m.probe}` + const before = cli.mutants.map(describe).sort() + const keys = new Set(cli.mutants.map((m) => m.key)) + const after = moved.mutants + .filter((m) => keys.has(m.key)) + .map(describe) + .sort() + if (before.join(`\n`) !== after.join(`\n`)) + return violation( + `stability`, + `the ${plan.stability} edit changed existing keys or outcomes`, + `key stability`, + { actual: after, expected: before }, + ) + } + return incrementalCheck(observations) ?? pass +} + +declareOracle({ + check: [check, incrementalCheck], + model: [model, interpret, ownersOf, probeHelpers], + grammar: [histories], +}) + +// ## Calibrations +// +// Each one hands the check a wrong report, so a check that cannot see the +// difference fails here instead of passing every CLI. +const corruptCli = (change) => (trace) => { + const index = trace.findIndex((o) => o.checkpoint === `cli`) + if (index < 0) return undefined + const value = change(structuredClone(trace[index].value)) + if (value === undefined) return undefined + return trace.map((o, i) => (i === index ? { ...o, value } : o)) +} +const firstWhere = (predicate, change) => + corruptCli((cli) => { + if (cli.exit !== 0) return undefined + const m = cli.mutants.find((x) => !x.authored && predicate(x)) + if (!m) return undefined + change(m) + return cli + }) +const calibrations = [ + { + name: `a refused run is reported as complete`, + law: `ownership`, + corrupt: corruptCli((cli) => + cli.exit === 0 ? undefined : { ...cli, exit: 0 }, + ), + }, + { + name: `a mutant is dropped from the enumeration`, + law: `enumeration`, + corrupt: corruptCli((cli) => { + const index = cli.mutants.findIndex((m) => !m.authored) + if (cli.exit !== 0 || index < 0) return undefined + cli.mutants.splice(index, 1) + return cli + }), + }, + { + name: `production code is reported as probe code`, + law: `probe-only code`, + corrupt: firstWhere( + (m) => !m.probe, + (m) => { + m.probe = true + }, + ), + }, + { + name: `a crash is reported as a kill`, + law: `classification`, + corrupt: firstWhere( + (m) => !m.probe && m.outcome === `crashed`, + (m) => { + m.outcome = `killed` + }, + ), + }, + { + name: `an owned mutant is reported as unowned`, + law: `ownership`, + corrupt: firstWhere( + (m) => !m.probe && m.outcome !== `unowned`, + (m) => { + m.outcome = `unowned` + delete m.class + }, + ), + }, + { + name: `a key drops its statement text`, + law: `keys`, + corrupt: firstWhere( + () => true, + (m) => { + m.key = m.key.slice(0, m.key.lastIndexOf(`:`)) + }, + ), + }, + { + name: `the summary omits a gap`, + law: `report completeness`, + corrupt: corruptCli((cli) => { + if (cli.exit !== 0 || cli.gaps.length === 0) return undefined + cli.gaps.shift() + return cli + }), + }, + { + name: `an edit moves a key`, + law: `key stability`, + corrupt: (trace) => { + const cli = trace.find((o) => o.checkpoint === `cli`)?.value + const index = trace.findIndex((o) => o.checkpoint === `stability`) + if (!cli || index < 0) return undefined + const value = structuredClone(trace[index].value) + const keys = new Set(cli.mutants.map((m) => m.key)) + const kept = value.mutants.find((m) => keys.has(m.key)) + if (!kept) return undefined + kept.key = `${kept.key}~moved` + return trace.map((o, i) => (i === index ? { ...o, value } : o)) + }, + }, +] + +calibrations.push({ + name: `the daemon reports another outcome than the cold run`, + law: `incremental equals cold`, + corrupt: (trace) => { + const index = trace.findIndex( + (o) => o.checkpoint === `incremental` && o.value.daemon.report, + ) + if (index < 0) return undefined + const value = structuredClone(trace[index].value) + const changed = value.daemon.report.results[0] + if (!changed) return undefined + changed.outcome = changed.outcome === `killed` ? `unreached` : `killed` + return trace.map((o, i) => (i === index ? { ...o, value } : o)) + }, +}) + +// Law 9's states: reuse, a mix of reuse and recomputation, reuse from disk +// after a restart, and each content edit changing the cold result, without +// which a stale cache would match the cold run by chance. +const incrementalStates = [ + `incremental check reused evidence`, + `incremental check reused some evidence and recomputed some`, + `evidence reused after a daemon restart`, + ...edits + .filter((edit) => edit !== `restart`) + .map((edit) => `edit ${edit} changed the cold result`), +] +// The static states (probe structure and ownership through the reference +// tree) are gap-report-static-oracle's: it reaches each in every campaign, +// in-process. This oracle still judges those laws in every history it runs, +// and counts how often it reaches them. +const staticStates = [ + `production beside a probe`, + `production else judged`, + `derived probe helper`, + `mixed helper judged`, + `owned through a call chain`, + `method owned through this`, + `derived probe helper chain`, + `overloaded function owned`, + `owned by a whole-file entry`, + `old probe form judged`, + `owned across files`, + `owned through an aliased import`, + `unowned in another file`, + `owned through a value`, + `owned through a field`, + `a constructed class brings only what is referenced`, + `constructor owned through new`, + `a class passed as a value brings its methods`, + `owned through bind`, +] +const required = [ + `killed by a second owner`, + `killed`, + `random-only kill`, + `agreement-only kill`, + `unregistered kill`, + `crash gap`, + `framework-only failure`, + `indeterminate`, + `comparison gap`, + `generator gap`, + `no observed difference`, + `unreached`, + `unowned`, + `authored mutant`, + `stale authored mutant`, + `ambiguous authored mutant`, + `stability rerun`, + `refused badEntry`, + `refused missingSource`, + `refused nonLiteral`, + `refused brokenOwner`, + `killed on both routes`, + `killed under an unnamed law`, + `second campaign's route attributed`, + `a soft verdict keeps its campaign's route`, + ...incrementalStates, +] +const faults = [`badEntry`, `missingSource`, `nonLiteral`, `brokenOwner`] +const separate = [ + `stale authored mutant`, + `ambiguous authored mutant`, + ...faults.map((fault) => `refused ${fault}`), + ...incrementalStates, +] +const witnessed = required.filter((s) => !separate.includes(s)) + +oracleProperty({ + id: `scripts.gap-report`, + law: { + statement: `The gap report enumerates the promised operator mutants, leaves out exactly the probe-only code, discovers every oracle that owns a function's call tree and refuses broken ownership, judges each mutant only by its owners' tests on their registered routes and counts only law verdicts on reproducible routes as kills, anchors each mutant by its function and normalized statement, lists every gap and unowned mutant once with consistent counts, keeps keys stable under unrelated edits, and reports through its daemon what a cold run reports at every state of a sequence of edits.`, + authority: `The maintainer's decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), not yet in the repository's decision register, including that a kill by reach agreement alone is a gap and that an incremental run through the daemon equals a cold one (v19.3); AGENTS.md on quiet exclusions; oracle-rubric ORC-006 outcome kinds; the CLI's documented operator list in schemata.mjs.`, + limits: `No loops, multi-line conditions, arrow or nested functions, two functions on one line, getters and setters, or await, new, and void statements; two source files of one package, with no re-exports, namespace imports, or calls into another package; no --only, --sample, or --diff-base; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks.`, + owner: `scripts/oracle-gaps`, + }, + histories: { + arbitrary: histories, + states: required, + measured: staticStates, + runs: 2, + fixedSeeds: [20261105], + constructed: [ + { + name: `every outcome class`, + enters: witnessed, + // A whole-file owns entry would own the unowned witness, so the + // construction keeps ownership per function. + build: (generated) => ({ + ...generated, + witness: `capture`, + wholeFile: `none`, + fault: `none`, + authored: { stmt: 0, kind: `apply` }, + stability: `insertGuard`, + steps: null, + }), + }, + { + name: `a stale authored mutant and a reformat`, + enters: [`stale authored mutant`, `stability rerun`], + build: (generated) => ({ + ...generated, + witness: false, + functions: [ + [{ kind: `push`, quote: `'` }, ...generated.functions[0].slice(0, 4)], + ...generated.functions.slice(1), + ], + fault: `none`, + authored: { stmt: 0, kind: `stale` }, + stability: `reformat`, + }), + }, + { + name: `an ambiguous authored mutant`, + enters: [`ambiguous authored mutant`], + build: (generated) => ({ + ...generated, + witness: false, + functions: [ + [{ kind: `push`, quote: `'` }, ...generated.functions[0].slice(0, 4)], + ...generated.functions.slice(1), + ], + wholeFile: `none`, + fault: `none`, + authored: { stmt: 0, kind: `ambiguous` }, + }), + }, + { + // Two small owned functions, so that each check is quick, and every + // edit in an order where each one changes the cold result: file b's + // helper (only b's evidence is recomputed), the seed, a revert to + // the state before it (all reused), then the rest, and a restart. + name: `an edit sequence through the daemon`, + enters: incrementalStates, + build: (generated) => ({ + ...generated, + functions: [ + [{ kind: `if`, op: `>`, k: 4, not: false, quote: `'` }], + [{ kind: `push`, quote: `'` }], + ], + containers: [], + overloads: [], + owners: [`a`, `b`], + library: [], + libraryOwners: [], + wholeFile: `none`, + template: false, + tests: [ + { ...judging(`a`, `fixed seed`, 5), fn: 0 }, + { ...judging(`b`, `fixed seed`, 1), fn: 1 }, + ], + witness: false, + authored: { stmt: 0, kind: `apply` }, + fault: `none`, + stability: `none`, + steps: [ + `helper`, + `seed`, + `revert`, + `test`, + `source`, + `prepend`, + `owns`, + `authored`, + `newOracle`, + `restart`, + ], + }), + }, + ...faults.map((fault) => ({ + name: `a declaration fault: ${fault}`, + enters: [`refused ${fault}`], + build: (generated) => ({ ...generated, fault }), + })), + ], + }, + resolve: resolveHistory, + model, + driver, + check, + calibrations, + timeout: 900_000, +}) diff --git a/scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs new file mode 100644 index 0000000000..41dfc2c0ea --- /dev/null +++ b/scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs @@ -0,0 +1,372 @@ +/** + * # The gap report's static laws, checked in-process + * + * Laws 1, 2, 3 (its static part), 6, and 8 (its key part) of the gap report + * (gap-report-oracle.property.test.mjs states them in full) decide what the + * report owes before any test runs: which operator mutants exist, which are + * probe-only code, which oracle files own each function's call tree, which + * declarations refuse the run, and each mutant's key. The CLI computes all of + * them in `analyze` (analysis.mjs) and `assignKeys` (report.mjs), from files + * alone. This oracle writes each generated fixture to a scratch package and + * calls those functions directly, without Vitest or a child process, so a + * history costs tens of milliseconds and a campaign a few seconds. The + * end-to-end oracle keeps the laws that need real test runs. + * + * Laws, checked in this order: + * + * - **Refusal.** An owns entry that names no function, an entry that names a + * missing file, and an owns list that is not a literal each refuse, naming + * the cause. An owner whose tests fail is a runtime refusal, so here it + * must not refuse. + * - **Enumeration.** The mutant set is the model's: each site by its source, + * function, line, operator, and order on the line. + * - **Probe-only code.** Each mutant is marked as probe code exactly when the + * model says its site is probe-only code. + * - **Ownership.** A mutant's function has an owner exactly when the model + * says it is not `unowned`. + * - **Keys.** Each mutant's key is the model's anchor. + * - **Key stability.** After the history's stability edit, every mutant + * that existed before keeps its key, its probe mark, and its ownership. + * + * Authority: as for the end-to-end oracle (the maintainer's gap-check + * decisions in design runs v11, v14, and v19). The split itself follows the + * frozen design's plan for the checker's oracle (v19 P10, F4: an oracle over + * the checker's pure core, beside its process shell) and the maintainer's + * budget of a few seconds for an incremental test run (2026-11-05). + * + * Limits: the grammar and model are the end-to-end oracle's + * (gap-report-model-oracle.mjs), with its limits. Whether a mutant is + * killed, and anything else that needs a test run, is the end-to-end + * oracle's. + */ +import { mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { declareOracle } from '../../packages/db/tests/oracle-declaration.ts' +import { + oracleProperty, + pass, + violation, +} from '../../packages/db/tests/oracle-property.ts' +import { analyze } from './analysis.mjs' +import { + LIB, + SOURCE, + edited, + fixtureFiles, + histories, + interpret, + model, + ownersOf, + probeHelpers, + resolveHistory, + writeFixture, + written, +} from './gap-report-model-oracle.mjs' +import { assignKeys } from './report.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const ts = createRequire(join(here, `../../package.json`))(`typescript`) + +// ## Production driver +// +// The driver writes the fixture as the end-to-end driver does, lists the +// test files the fixture's Vitest config includes (`tests/**/*.test.ts`), +// and calls `analyze` and `assignKeys`. It records a refusal's message, or +// each mutant's site, probe mark, ownership, and key. For a stability +// history it rewrites the source and records the same again. +function analyzeFixture(dir, files) { + writeFixture(dir, files) + const tests = readdirSync(join(dir, `tests`)) + .filter((name) => name.endsWith(`.test.ts`)) + .map((name) => join(dir, `tests`, name)) + try { + const { mutants, owned } = analyze(ts, dir, [SOURCE, LIB], tests) + assignKeys(mutants) + return { + mutants: mutants.map((m) => ({ + source: m.source, + function: m.function, + line: m.line, + operator: m.operator, + probe: m.probeGuard === true, + owned: owned.of(m.source, m.function).size > 0, + key: m.key, + })), + } + } catch (error) { + return { refusal: String(error?.message ?? error) } + } +} + +const ownedStates = { + w_leaf: `owned through a call chain`, + 'W0.step': `method owned through this`, + wg0: `owned across files`, + wg1: `owned through an aliased import`, + w_valued: `owned through a value`, + 'W3.run': `owned through a field`, + 'W5.step': `owned through bind`, + 'W7.constructor': `constructor owned through new`, + 'W6.step': `a class passed as a value brings its methods`, + w_overloaded: `overloaded function owned`, +} + +async function driver(plan, observe) { + const dir = realpathSync(mkdtempSync(join(here, `.scratch-`))) + try { + const files = fixtureFiles(plan) + const before = analyzeFixture(dir, files) + observe(`static`, before) + const states = new Set() + if (before.refusal) states.add(`refused ${plan.fault}`) + for (const m of before.mutants ?? []) { + if (m.probe) { + if (m.function === `w_probeonly`) states.add(`derived probe helper`) + if (m.function === `w_callschain`) states.add(`derived probe helper chain`) + continue + } + if (m.owned && ownedStates[m.function]) states.add(ownedStates[m.function]) + if (!m.owned) states.add(`unowned`) + if (m.function === `W4.step` && !m.owned) + states.add(`a constructed class brings only what is referenced`) + if (m.source === LIB && !m.owned) states.add(`unowned in another file`) + if (m.function === `reportAndPush`) states.add(`mixed helper judged`) + const generated = plan.functions.find( + (fn) => fn.generated && fn.id === m.function, + ) + if (generated?.owners.length === 0 && plan.wholeFile !== `none` && m.owned) + states.add(`owned by a whole-file entry`) + const kind = plan.functions + .flatMap((fn) => fn.body.map((s) => [`${fn.file}:${s.line}`, s.kind])) + .find(([at]) => at === `${m.source}:${m.line}`)?.[1] + if (kind === `oldProbe`) states.add(`old probe form judged`) + if (kind === `mixed` && m.operator === `if-true`) + states.add(`production beside a probe`) + if (kind === `elseIf` && m.operator === `if-true`) + states.add(`production else judged`) + } + if (plan.stability !== `none` && !before.refusal) { + observe(`stability`, analyzeFixture(dir, { ...files, [SOURCE]: edited(plan) })) + states.add(`stability rerun`) + } + for (const state of states) observe(`reach`, state) + } finally { + written.delete(dir) + rmSync(dir, { recursive: true, force: true }) + } +} + +// ## Comparison +// +// Mutants are matched to the model's by source, function, line, operator, +// and order on the line, then each law is checked in turn. +const siteOf = (m) => `${m.source}:${m.function}:${m.line}:${m.operator}#${m.ordinal}` +const withOrdinals = (mutants) => { + const seen = new Map() + return mutants.map((m) => { + const at = `${m.source} ${m.line} ${m.operator}` + const ordinal = seen.get(at) ?? 0 + seen.set(at, ordinal + 1) + return { ...m, ordinal } + }) +} + +function check(plan, observations, expected) { + const got = observations.find((o) => o.checkpoint === `static`)?.value + if (!got) return violation(`static`, `no analysis was observed`, `completion`) + // An owner that fails is found only when its tests run. + const refused = plan.fault === `brokenOwner` ? undefined : expected.refused + if (refused) { + if (!got.refusal) + return violation(`static`, `the analysis did not refuse ${refused.cause}`, `refusal`) + if (!got.refusal.includes(refused.cause)) + return violation( + `static`, + `the refusal does not name ${refused.cause}: ${got.refusal}`, + `refusal`, + ) + return pass + } + if (got.refusal) + return violation(`static`, `the analysis refused: ${got.refusal}`, `refusal`) + const owedMutants = plan.fault === `brokenOwner` ? model({ ...plan, fault: `none` }).mutants : expected.mutants + const actual = withOrdinals(got.mutants) + if (actual.map(siteOf).sort().join() !== owedMutants.map(siteOf).sort().join()) + return violation(`static`, `the mutant set differs`, `enumeration`, { + actual: actual.map(siteOf).sort(), + expected: owedMutants.map(siteOf).sort(), + }) + const pairs = owedMutants.map((m) => [m, actual.find((a) => siteOf(a) === siteOf(m))]) + for (const [owed, m] of pairs) + if (owed.probe !== m.probe) + return violation(`static`, `${siteOf(m)}: probe code is ${m.probe}, owed ${owed.probe}`, `probe-only code`) + for (const [owed, m] of pairs) + if (!owed.probe && m.owned !== (owed.kind !== `unowned`)) + return violation( + `static`, + `${siteOf(m)}: ${m.owned ? `owned` : `unowned`}, owed ${owed.kind === `unowned` ? `unowned` : `owned`}`, + `ownership`, + ) + for (const [owed, m] of pairs) + if (m.key !== owed.key) + return violation(`static`, `${siteOf(m)}: key ${JSON.stringify(m.key)}, owed ${JSON.stringify(owed.key)}`, `keys`) + const moved = observations.find((o) => o.checkpoint === `stability`)?.value + if (moved) { + if (moved.refusal) + return violation(`stability`, `the edited source was refused: ${moved.refusal}`, `key stability`) + const describe = (m) => `${m.key} ${m.probe} ${m.owned}` + const keys = new Set(got.mutants.map((m) => m.key)) + const before = got.mutants.map(describe).sort() + const after = moved.mutants.filter((m) => keys.has(m.key)).map(describe).sort() + if (before.join(`\n`) !== after.join(`\n`)) + return violation( + `stability`, + `the ${plan.stability} edit changed existing keys, probe marks, or ownership`, + `key stability`, + { actual: after, expected: before }, + ) + } + return pass +} + +declareOracle({ + check: [check], + model: [model, interpret, ownersOf, probeHelpers], + grammar: [histories], +}) + +// ## Calibrations +// +// Each hands the check a wrong analysis. +const corrupt = (checkpoint, change) => (trace) => { + const index = trace.findIndex((o) => o.checkpoint === checkpoint) + if (index < 0 || !trace[index].value.mutants?.length) return undefined + const value = structuredClone(trace[index].value) + if (change(value) === false) return undefined + return trace.map((o, i) => (i === index ? { ...o, value } : o)) +} +const calibrations = [ + { + name: `a mutant is dropped`, + law: `enumeration`, + corrupt: corrupt(`static`, (v) => void v.mutants.shift()), + }, + { + name: `production code is marked as probe code`, + law: `probe-only code`, + corrupt: corrupt(`static`, (v) => { + const m = v.mutants.find((x) => !x.probe) + if (!m) return false + m.probe = true + }), + }, + { + name: `an owned mutant is reported unowned`, + law: `ownership`, + corrupt: corrupt(`static`, (v) => { + const m = v.mutants.find((x) => !x.probe && x.owned) + if (!m) return false + m.owned = false + }), + }, + { + name: `a key drops its statement`, + law: `keys`, + corrupt: corrupt(`static`, (v) => { + v.mutants[0].key = v.mutants[0].key.slice(0, v.mutants[0].key.lastIndexOf(`:`)) + }), + }, + { + name: `an edit moves a key`, + law: `key stability`, + corrupt: (trace) => { + const before = trace.find((o) => o.checkpoint === `static`)?.value + const keys = new Set(before?.mutants?.map((m) => m.key) ?? []) + return corrupt(`stability`, (v) => { + const m = v.mutants.find((x) => keys.has(x.key)) + if (!m) return false + m.key = `${m.key}~moved` + })(trace) + }, + }, +] + +const faults = [`badEntry`, `missingSource`, `nonLiteral`] +const required = [ + ...Object.values(ownedStates), + `derived probe helper`, + `derived probe helper chain`, + `mixed helper judged`, + `unowned`, + `a constructed class brings only what is referenced`, + `unowned in another file`, + `owned by a whole-file entry`, + `old probe form judged`, + `production beside a probe`, + `production else judged`, + `stability rerun`, + ...faults.map((fault) => `refused ${fault}`), +] + +oracleProperty({ + id: `scripts.gap-report-static`, + law: { + statement: `The gap report's static half enumerates the promised operator mutants, marks exactly the probe-only code, refuses broken owns declarations, gives each function the owners whose call trees contain it, anchors each mutant's key, and keeps keys, probe marks, and ownership stable under unrelated edits.`, + authority: `The maintainer's gap-check decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), as for scripts.gap-report; the frozen design's plan for an oracle over the checker's pure core (v19 P10, F4); the maintainer's few-seconds budget for an incremental test run (2026-11-05).`, + limits: `The grammar and model of scripts.gap-report, with its limits. Outcomes that need test runs, and the failing-owner refusal, belong to scripts.gap-report.`, + owner: `scripts/oracle-gaps`, + }, + histories: { + arbitrary: histories, + states: required, + runs: 40, + fixedSeeds: [20261105], + constructed: [ + { + name: `every static state`, + enters: required.filter( + (s) => + ![`owned by a whole-file entry`, ...faults.map((f) => `refused ${f}`)].includes(s), + ), + build: (generated) => ({ + ...generated, + witness: true, + wholeFile: `none`, + fault: `none`, + stability: `insertGuard`, + steps: null, + }), + }, + { + name: `a whole-file owner`, + enters: [`owned by a whole-file entry`], + build: (generated) => ({ + ...generated, + functions: [ + [{ kind: `push`, quote: `'` }, ...generated.functions[0].slice(0, 4)], + ...generated.functions.slice(1), + ], + owners: [], + wholeFile: `a`, + witness: false, + fault: `none`, + stability: `none`, + steps: null, + }), + }, + ...faults.map((fault) => ({ + name: `a declaration fault: ${fault}`, + enters: [`refused ${fault}`], + build: (generated) => ({ ...generated, fault, steps: null }), + })), + ], + }, + resolve: resolveHistory, + model, + driver, + check, + calibrations, + timeout: 120_000, +}) diff --git a/scripts/oracle-gaps/index.mjs b/scripts/oracle-gaps/index.mjs index 9d5a17d8ef..b8638be762 100644 --- a/scripts/oracle-gaps/index.mjs +++ b/scripts/oracle-gaps/index.mjs @@ -1,630 +1,89 @@ #!/usr/bin/env node -// Gap report prototype: which production behaviour do the oracles leave -// unexercised or unchecked? +// Gap report: which production behaviour do the oracles that own it leave +// unexercised or unjudged? // -// The report does not locate bugs or judge laws. It lists places where an -// oracle could improve, so that each one can be closed or given a reason -// (oracle-framework design, decisions D41 and D42). For each source file it: +// The report does not locate bugs or judge laws. It lists gaps: evidence that +// the owning oracles cannot see some difference in production. The author +// states the law each gap reveals and re-plans the oracle (report.mjs). For +// the source files given, it: // -// 1. Compiles every mutant into one instrumented copy (schemata.mjs) and -// serves that copy to Vitest through a plugin. Nothing is written to the -// source file. -// 2. Runs all given test files twice with no mutant active. Each test reports -// which mutation sites it reached, which outcomes each `if` took, and a -// hash of the values it asserted about. Tests whose hash differs between -// the two runs are nondeterministic. -// 3. Runs each mutant against the oracle files whose tests reached its site, -// in one warm Vitest process, stopping at the first failure. Whole files -// run, because tests in one file can share state (jsdom's localStorage -// persists across them), so skipping some can change the others. -// 4. Classifies each run: `killed` (an assertion failed), `crashed` (a test -// failed with a non-assertion error), `survived`, `timeout`, or -// `setup-error` (an error outside any test). A site that no oracle test -// reached is `unreached`. Killed and crashed are both detections. Because -// a run stops at its first failure, which of the two a mutant gets can -// depend on which file ran first. -// 5. Sorts each survivor: -// - `comparison-gap`: asserted values, or an oracle property's recorded -// observations, changed but no assertion failed. -// - `generator-gap`: no asserted value changed, and an `if` on the mutated -// line never took one of its outcomes in any oracle test. -// - `no-observed-difference`: no asserted value changed and every `if` -// outcome on the line occurred. This is not evidence of no contract: the -// explored histories may simply never distinguish the edit (D42). -// - `indeterminate`: only nondeterministic tests reached the site. +// 1. Compiles every operator mutant into one instrumented copy of each file +// (schemata.mjs) and serves that copy to Vitest through a plugin. Nothing +// is written to the source file. State-probe code is marked and left out. +// 2. Finds every test file the package's Vitest config includes. A test file +// that calls `declareOracle({ owns })` is an oracle file; owning an entry +// function owns its static call tree (owners.mjs). A declaration that +// names nothing or is not a literal list refuses the run, because it would +// hide gaps. +// 3. Runs every test file twice with no mutant active. Each test reports the +// mutation sites it reached, the outcomes each `if` took, the values it +// asserted on and observed, its registered route, and its law verdicts +// (capture-setup.mjs). A test whose values differ between the runs is +// nondeterministic and judges nothing. An oracle file that fails here +// refuses the run; an ordinary file is left out and listed. +// 4. Runs each mutant against the owner files whose tests reached it, in a +// warm Vitest process, and classifies it (see `outcomeOf`): `killed` only +// by a law verdict on a reproducible route; otherwise a gap. A mutant in a +// function no oracle owns is `unowned`, a warning. Survivors are sorted +// into `comparison-gap`, `generator-gap`, `no-observed-difference`, and +// `indeterminate`. +// 5. Runs a survivor against the ordinary tests that reach it, for +// information only: an ordinary test never closes a gap. // -// Ordinary test files given with --tests form a second tier. A mutant that no -// oracle kills is run against the ordinary tests that reach it, and a kill -// there is reported as `killed-by-ordinary-test` (D41 (d)). +// The pipeline lives in check.mjs, as a session that keeps Vitest warm. A +// cold run opens one session for one check. With --daemon, the CLI sends the +// check to a daemon for the package (daemon.mjs), starting it if needed; the +// daemon keeps its session and an evidence store on disk, reuses every unit +// of evidence whose inputs are unchanged, and reports what a cold run would. // -// --seed sets TANSTACK_DB_ORACLE_SEED, so random campaigns run the same -// histories every time and captured values stay comparable. +// The rules come from the maintainer's decisions in the gap-check design +// (runs v11, v14, and v19 under ~/.cache/oracle-grammar; not yet in the +// repository's decision register). The CLI's own oracle is +// gap-report-oracle.property.test.mjs. +// +// --seed sets TANSTACK_DB_ORACLE_SEED in every test run, so random campaigns +// run the same histories every time and captured values stay comparable. // // Usage: -// node scripts/oracle-gaps/index.mjs --package --oracles -// --source [--tests ] [--seed ] -// [--diff-base ] [--out ] [--only ] +// node scripts/oracle-gaps/index.mjs --package --source +// [--daemon] (check through the package's daemon) +// [--seed ] [--mutants ] [--out ] [--only ] +// [--diff-base ] // [--test-timeout ] (default 15000; also bounds hooks and teardown. // Each mutant run also has a wall-clock limit of -// 10 s plus three times its files' unmutated time.) -// [--sample ] (a fixed-seed sample of n mutants per source) +// 10 s plus three times its file's unmutated time.) +// [--sample ] (a fixed-seed sample of n mutants per source; +// the report marks the run as partial) // [--contexts ] (warm contexts, one worker each; default // the machine's parallelism minus 2, at most 8) -import { spawnSync } from 'node:child_process' -import { - appendFileSync, - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { createRequire } from 'node:module' -import { availableParallelism } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { instrument } from './schemata.mjs' - -const here = dirname(fileURLToPath(import.meta.url)) - -function parseArgs(argv) { - const args = {} - for (let i = 0; i < argv.length; i += 1) { - const [key, inline] = argv[i].replace(/^--/, ``).split(`=`) - args[key] = inline ?? argv[++i] - } - if (!args.package || !args.oracles || !args.source) - throw new Error(`--package, --oracles and --source are required`) - return { - // Test reports name files by their real path (on macOS, /tmp is - // /private/tmp), so the package path is resolved the same way. - pkg: realpathSync(resolve(args.package)), - oracles: args.oracles.split(`,`), - // Each warm context runs one mutant at a time with one worker. Measured on - // main's LocalStorage owners (320 mutants): 2 contexts 297 s, 4 185 s, - // 8 137 s, with identical oracle-tier classes. - contexts: Number( - args.contexts ?? Math.min(8, Math.max(2, availableParallelism() - 2)), - ), - tests: args.tests ? args.tests.split(`,`) : [], - sources: args.source.split(`,`), - seed: args.seed, - diffBase: args[`diff-base`], - out: resolve(args.out ?? `/tmp/oracle-gaps`), - only: args.only ? new Set(args.only.split(`,`)) : undefined, - // A fixed-seed random sample of this many mutants per source file, for - // surveys across many modules. - sample: args.sample ? Number(args.sample) : undefined, - // A mutant can make a test hang. Oracles that run in milliseconds do not - // need Vitest's default limits, which would make each hang cost seconds. - testTimeout: Number(args[`test-timeout`] ?? 15000), - } -} - -const options = parseArgs(process.argv.slice(2)) -mkdirSync(options.out, { recursive: true }) -const requireTarget = createRequire(join(options.pkg, `package.json`)) -const ts = requireTarget(`typescript`) - -// 1. Instrument every source file once. -const instrumented = {} -const mutants = [] -for (const source of options.sources) { - const path = realpathSync(join(options.pkg, source)) - const result = instrument( - ts, - path, - readFileSync(path, `utf8`), - source, - mutants.length, - ) - instrumented[path] = result.code - mutants.push(...result.mutants) -} -const schemataFile = join(options.out, `schemata.json`) -writeFileSync(schemataFile, JSON.stringify(instrumented)) - -// The wrapper config adds the harness setup to the package's own config and -// a plugin that serves the instrumented sources in place of the originals. -const wrapper = join(options.pkg, `.oracle-gaps.vitest.config.ts`) -const harness = join(options.pkg, `.oracle-gaps.capture-setup.mjs`) -const stateFile = join(options.out, `state.json`) -function installHarness() { - copyFileSync(join(here, `capture-setup.mjs`), harness) - writeFileSync( - wrapper, - `import { readFileSync, realpathSync } from 'node:fs' -import { mergeConfig } from 'vitest/config' -import base from './vite.config.ts' -const schemata = JSON.parse(readFileSync(process.env.ORACLE_GAPS_SCHEMATA, 'utf8')) -export default mergeConfig(base, { - plugins: [{ - name: 'oracle-gaps-schemata', - enforce: 'pre', - load(id) { - const path = id.split('?')[0] - try { return schemata[realpathSync(path)] } catch { return undefined } - }, - }], - test: { setupFiles: ['./.oracle-gaps.capture-setup.mjs'] }, -}) -`, - ) -} -function removeHarness() { - rmSync(wrapper, { force: true }) - rmSync(harness, { force: true }) -} - -// Lines changed relative to the diff base, for diff-scoped summaries. -function diffLines(source) { - if (!options.diffBase) return undefined - const child = spawnSync( - `git`, - [`diff`, `-U0`, options.diffBase, `--`, source], - { cwd: options.pkg, encoding: `utf8` }, - ) - const lines = new Set() - for (const match of child.stdout.matchAll( - /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm, - )) { - const start = Number(match[1]) - for (let line = start; line < start + Number(match[2] ?? 1); line += 1) - lines.add(line) - } - return lines -} - -let runCounter = 0 -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - -// A mutant can hang a run outside any test, in a hook or teardown, where no -// test timeout applies. A run with a time limit that passes it is cancelled -// and reported as timed out; its context is then replaced (see the mutant -// loop), because a cancelled run can leave work behind in its workers. -async function run( - vitest, - specs, - { mutant = null, select = null, limitMs } = {}, -) { - const capture = join(options.out, `run-${++runCounter}.jsonl`) - const state = { mutant, select: select && [...select], capture } - // Vitest 3 and 4 both expose the root project's `provide`; the state file - // remains for setups that cannot `inject`. - vitest.getRootProject?.().provide(`oracleGaps`, state) - writeFileSync(stateFile, JSON.stringify(state)) - const started = Date.now() - const running = vitest.runTestSpecifications(specs, false) - let timer - const result = - limitMs === undefined - ? await running - : await Promise.race([ - running, - new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), limitMs) - }), - ]) - clearTimeout(timer) - if (result === undefined) { - await Promise.race([ - vitest.cancelCurrentRun(`keyboard-input`).catch(() => undefined), - sleep(5000), - ]) - return { - timedOut: true, - result: { testModules: [], unhandledErrors: [] }, - modules: [], - rows: new Map(), - seconds: (Date.now() - started) / 1000, - } - } - // The result also lists test modules from earlier runs that this run did - // not execute, with their old results. Only this run's files count. - const ran = new Set(specs.map((spec) => spec.moduleId)) - const modules = result.testModules.filter((module) => - ran.has(module.moduleId), - ) - const rows = new Map() - if (existsSync(capture)) { - for (const line of readFileSync(capture, `utf8`) - .split(`\n`) - .filter(Boolean)) { - const row = JSON.parse(line) - if (!select || select.has(row.key)) rows.set(row.key, row) - } - rmSync(capture) - } - return { result, modules, rows, seconds: (Date.now() - started) / 1000 } -} - -// A failure counts as a kill only when it is an assertion. A test that fails -// with any other error after setup is `crashed`: production threw where the -// oracle did not expect it, which the oracle noticed but did not judge -// (ORC-006 "runtime failure after setup outside the intended comparison"). -// An error outside any test is `setup-error`. Typed verdicts will make this -// exact; for now the error text decides. -function outcomeOf({ result, modules, timedOut }) { - if (timedOut) return { outcome: `timeout`, detail: `run time limit` } - const failures = [] - for (const module of modules) { - for (const error of module.errors?.() ?? []) - failures.push({ assertion: false, setup: true, message: error.message }) - for (const testCase of module.children.allTests()) { - const testResult = testCase.result() - if (testResult.state !== `failed`) continue - for (const error of testResult.errors ?? []) { - const message = `${error.name ?? ``}: ${error.message ?? ``}` - failures.push({ - test: testCase.fullName, - // An oracle property's check reports a typed OracleViolation. - assertion: - /OracleViolation|AssertionError|Property failed|expected .+ to |Counterexample/.test( - message, - ), - timeout: /Test timed out/.test(message), - message: message.slice(0, 300), - }) - } - } - } - for (const error of result.unhandledErrors ?? []) - failures.push({ - assertion: false, - message: String(error?.message ?? error), - }) - if (failures.length === 0) return { outcome: `survived` } - if (failures.some((f) => f.assertion)) - return { - outcome: `killed`, - by: failures.filter((f) => f.assertion).map((f) => f.test), - } - if (failures.every((f) => f.timeout)) return { outcome: `timeout` } - if (failures.some((f) => f.test && !f.timeout)) - return { - outcome: `crashed`, - by: failures.filter((f) => f.test).map((f) => f.test), - detail: failures.find((f) => f.test).message, - } - return { outcome: `setup-error`, detail: failures[0].message } -} - -process.env.ORACLE_GAPS_STATE = stateFile -process.env.ORACLE_GAPS_SCHEMATA = schemataFile -if (options.seed) process.env.TANSTACK_DB_ORACLE_SEED = options.seed - -installHarness() -// Vitest resolves test-file filters and parts of its project setup against -// the working directory, so the runner works from the package directory. -process.chdir(options.pkg) -const { createVitest } = await import( - pathToFileURL(requireTarget.resolve(`vitest/node`)).href -) -// Two warm contexts with one worker each run two mutants at a time. A mutant -// run usually executes one file, so one context with two workers would leave -// a worker idle. The unmutated runs use the first context. -const contexts = [] -let exitCode = 0 -const newContext = () => - createVitest(`test`, { - root: options.pkg, - config: wrapper, - watch: false, - maxWorkers: 1, - bail: Number(process.env.ORACLE_GAPS_BAIL ?? 1), - testTimeout: options.testTimeout, - hookTimeout: options.testTimeout, - teardownTimeout: options.testTimeout, - typecheck: { enabled: false }, - coverage: { enabled: false }, - reporters: [{}], - }) -// Each context's specs are its own objects, so specs are looked up per -// context by file. -const contextSpecs = new Map() -async function warmContext() { - const context = await newContext() - // Vitest 3 calls this step `init`; Vitest 4 renamed it `standalone`. - await (context.standalone ? context.standalone() : context.init()) - const map = new Map() - for (const file of [...options.oracles, ...options.tests]) { - // Filters resolve against the working directory, not `root`, so each - // file is passed by its absolute path. - const path = realpathSync(join(options.pkg, file)) - const specs = await context.globTestSpecifications([path]) - if (specs.length === 0) throw new Error(`no test file matches ${file}`) - map.set(path, specs) - } - contextSpecs.set(context, map) - return context -} -try { - for (let i = 0; i < options.contexts; i += 1) - contexts.push(await warmContext()) - const vitest = contexts[0] - const fileSpecs = contextSpecs.get(vitest) - const oracleFiles = new Set( - options.oracles.map((f) => realpathSync(join(options.pkg, f))), - ) - const fileOf = (key) => key.split(` > `)[0] - - // 2. Two unmutated runs, with the files split across the contexts. A file - // that fails with no mutant active is left out of the report: it cannot - // detect anything, and it may read source text that instrumentation changes. - // Each file's unmutated duration, which sets a mutant run's time limit. - const fileSeconds = new Map() - const limitFor = (keys) => - 10_000 + - 3000 * - [...new Set(keys.map(fileOf))].reduce( - (sum, file) => sum + (fileSeconds.get(file) ?? 10), - 0, - ) - const runAcross = async (files) => { - const parts = contexts.map(() => []) - files.forEach((file, index) => parts[index % contexts.length].push(file)) - const runs = await Promise.all( - contexts.map((context, index) => - run( - context, - parts[index].flatMap((file) => contextSpecs.get(context).get(file)), - ), - ), - ) - const rows = new Map(runs.flatMap((r) => [...r.rows])) - for (const r of runs) - for (const module of r.modules) - fileSeconds.set( - module.moduleId, - Math.max( - fileSeconds.get(module.moduleId) ?? 0, - (module.diagnostic?.().duration ?? 0) / 1000, - ), - ) - const failed = new Set() - for (const r of runs) - for (const module of r.modules) - if ( - outcomeOf({ result: r.result, modules: [module] }).outcome !== - `survived` - ) - failed.add(module.moduleId) - return { rows, failed, seconds: Math.max(...runs.map((r) => r.seconds)) } - } - let files = [...fileSpecs.keys()] - const first = await runAcross(files) - const excluded = [...first.failed] - if (excluded.length > 0) { - console.error( - `left out, failing with no mutant active: ${excluded.join(`, `)}`, - ) - files = files.filter((file) => !first.failed.has(file)) - for (const file of excluded) { - fileSpecs.delete(file) - oracleFiles.delete(file) - for (const key of [...first.rows.keys()]) - if (fileOf(key) === file) first.rows.delete(key) - } - } - const second = await runAcross(files) - const nondeterministic = new Set() - for (const [key, row] of first.rows) { - const again = second.rows.get(key) - if ( - !row.background && - (again?.hash !== row.hash || again?.observed !== row.observed) - ) - nondeterministic.add(key) - } - console.error( - `unmutated: ${[...first.rows.values()].filter((r) => !r.background).length} tests in ${first.seconds.toFixed(1)}s and ${second.seconds.toFixed(1)}s; nondeterministic: ${nondeterministic.size}`, - ) - - // Which tests reached each site, and which `if` outcomes never occurred in - // an oracle test. - const reachedBy = new Map() - const outcomeSeen = new Map() - for (const [key, row] of first.rows) { - for (const id of row.hits) - reachedBy.set(id, [...(reachedBy.get(id) ?? []), key]) - if (row.background || !oracleFiles.has(fileOf(key))) continue - for (const [id, [truthy, falsy]] of row.outcomes) { - const seen = outcomeSeen.get(id) ?? [false, false] - outcomeSeen.set(id, [seen[0] || truthy, seen[1] || falsy]) - } - } - // Reach over every site, by source, so a sampled survey still reports - // exact reach. - const reach = Object.fromEntries( - options.sources.map((source) => { - const own = mutants.filter((m) => m.source === source) - const reached = own.filter((m) => - (reachedBy.get(m.number) ?? []).some((k) => oracleFiles.has(fileOf(k))), - ) - return [source, { sites: own.length, reachedByAnOracle: reached.length }] - }), - ) - const missingOutcomeLines = new Set() - for (const mutant of mutants) { - if (mutant.operator !== `if-true`) continue - const [truthy, falsy] = outcomeSeen.get(mutant.number) ?? [false, false] - if (reachedBy.has(mutant.number) && !(truthy && falsy)) - missingOutcomeLines.add(`${mutant.source}:${mutant.line}`) - } +// node scripts/oracle-gaps/index.mjs --stop-daemon --package +import { realpathSync } from 'node:fs' +import { parseArgs } from './args.mjs' +import { openSession } from './check.mjs' +import { requestDaemon, stopDaemon } from './daemon.mjs' +import { memoryStore } from './evidence.mjs' - // 3–5. Mutants. - const diffs = new Map(options.sources.map((s) => [s, diffLines(s)])) - const results = [] - const started = Date.now() - let queue = mutants.filter((m) => !options.only || options.only.has(m.id)) - if (options.sample !== undefined) { - // A fixed-seed shuffle, so a survey samples the same mutants every run. - let seed = 20251102 - const random = () => - (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31 - queue = options.sources.flatMap((source) => { - const own = queue.filter((m) => m.source === source) - for (let i = own.length - 1; i > 0; i -= 1) { - const j = Math.floor(random() * (i + 1)) - ;[own[i], own[j]] = [own[j], own[i]] +if (import.meta.url === `file://${realpathSync(process.argv[1])}`) { + let exitCode = 0 + try { + const options = parseArgs(process.argv.slice(2)) + if (options.stop) await stopDaemon(options.pkg) + else if (options.daemon) { + const reply = await requestDaemon(options.pkg, process.argv.slice(2)) + if (reply.stderr) process.stderr.write(reply.stderr) + if (reply.stdout) process.stdout.write(reply.stdout) + exitCode = reply.exitCode + } else { + const session = await openSession(options.pkg) + try { + console.log(await session.check(options, memoryStore())) + } finally { + await session.close() } - return own.slice(0, options.sample) - }) - } - // Results are appended as they finish, so a stopped survey keeps them. - const partial = join(options.out, `results.jsonl`) - rmSync(partial, { force: true }) - async function evaluate(mutant, vitest) { - let replace = false - const specsFor = (keys) => - [...new Set(keys.map(fileOf))].flatMap( - (f) => contextSpecs.get(vitest).get(f) ?? [], - ) - mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) - const keys = reachedBy.get(mutant.number) ?? [] - const oracleKeys = keys.filter((k) => oracleFiles.has(fileOf(k))) - const testKeys = keys.filter((k) => !oracleFiles.has(fileOf(k))) - const oracleTargets = [...new Set(oracleKeys.map(fileOf))] - const testTargets = [...new Set(testKeys.map(fileOf))] - const result = { - ...mutant, - oracleFiles: oracleTargets, - ordinaryFiles: testTargets, - } - if (oracleKeys.length > 0) { - const mutated = await run(vitest, specsFor(oracleKeys), { - mutant: mutant.number, - limitMs: limitFor(oracleKeys), - }) - if (mutated.timedOut) replace = true - Object.assign(result, outcomeOf(mutated), { seconds: mutated.seconds }) - if (result.outcome === `survived`) { - const compared = [...mutated.rows.keys()].filter( - (k) => !mutated.rows.get(k).background && !nondeterministic.has(k), - ) - // A changed asserted value or a changed property observation, with no - // failure, is a difference that the check did not judge. - result.changedTests = compared.filter( - (k) => - mutated.rows.get(k)?.hash !== first.rows.get(k)?.hash || - mutated.rows.get(k)?.observed !== first.rows.get(k)?.observed, - ) - result.class = - result.changedTests.length > 0 - ? `comparison-gap` - : compared.length === 0 - ? `indeterminate` - : missingOutcomeLines.has(`${mutant.source}:${mutant.line}`) - ? `generator-gap` - : `no-observed-difference` - } - } else result.outcome = `unreached` - if ( - (result.outcome === `survived` || result.outcome === `unreached`) && - testKeys.length > 0 - ) { - const tier = await run(vitest, specsFor(testKeys), { - mutant: mutant.number, - limitMs: limitFor(testKeys), - }) - if (tier.timedOut) replace = true - result.testOutcome = outcomeOf(tier).outcome - // A crash in an ordinary test is a detection, as it is for an oracle. - if ( - result.outcome === `unreached` && - (result.testOutcome === `killed` || result.testOutcome === `crashed`) - ) - result.outcome = `killed-by-ordinary-test` } - results.push(result) - appendFileSync(partial, `${JSON.stringify(result)}\n`) - console.error( - `${mutant.id} L${mutant.line} ${mutant.operator} ${result.outcome}${result.class ? ` (${result.class})` : ``}${result.testOutcome ? ` [ordinary: ${result.testOutcome}]` : ``}`, - ) - return replace + } catch (error) { + console.error(error) + exitCode = 1 } - await Promise.all( - contexts.map(async (initial, slot) => { - let context = initial - for (let mutant = queue.shift(); mutant; mutant = queue.shift()) { - if (!(await evaluate(mutant, context))) continue - // A cancelled run can leave work behind, so its context is replaced. - await Promise.race([ - context.close().catch(() => undefined), - sleep(10_000), - ]) - context = await warmContext() - contexts[slot] = context - } - }), - ) - results.sort( - (a, b) => a.number - b.number || a.source.localeCompare(b.source), - ) - const mutantSeconds = (Date.now() - started) / 1000 - console.error( - `mutant phase: ${results.length} mutants in ${mutantSeconds.toFixed(1)}s`, - ) - writeFileSync( - join(options.out, `results.json`), - JSON.stringify( - { - options: { ...options, only: undefined }, - excluded, - reach, - nondeterministic: [...nondeterministic], - mutantSeconds, - results, - }, - null, - 1, - ), - ) - const summary = summarize(results) - writeFileSync(join(options.out, `summary.md`), summary) - console.log(summary) -} catch (error) { - console.error(error) - exitCode = 1 -} finally { - for (const context of contexts) await context.close() - removeHarness() -} -process.exit(exitCode) - -function summarize(results) { - const count = (rows, predicate) => rows.filter(predicate).length - const line = (label, rows) => - `| ${label} | ${rows.length} | ${count(rows, (r) => r.outcome === `killed`)} | ${count(rows, (r) => r.class === `comparison-gap`)} | ${count(rows, (r) => r.class === `generator-gap`)} | ${count(rows, (r) => r.class === `no-observed-difference`)} | ${count(rows, (r) => r.class === `indeterminate`)} | ${count(rows, (r) => r.outcome === `unreached` || r.outcome === `killed-by-ordinary-test`)} | ${count(rows, (r) => r.outcome === `crashed`)} | ${count(rows, (r) => r.outcome === `setup-error` || r.outcome === `timeout`)} | ${count(rows, (r) => r.testOutcome === `killed` || r.testOutcome === `crashed`)} |` - const header = - `| Scope | Mutants | Killed by an oracle | Comparison gap | Generator gap | No observed difference | Indeterminate | No oracle reaches it | Crashed (not an assertion) | Setup error or timeout | Of the uncaught, an ordinary test kills |\n` + - `|---|---|---|---|---|---|---|---|---|---|---|` - const byFunction = new Map() - for (const r of results) - byFunction.set(r.function, [...(byFunction.get(r.function) ?? []), r]) - const rows = [line(`all`, results)] - if (results.some((r) => r.inDiff !== undefined)) - rows.push( - line( - `diff`, - results.filter((r) => r.inDiff), - ), - ) - const functions = [...byFunction] - .filter(([, rs]) => rs.some((r) => r.outcome !== `killed`)) - .map(([name, rs]) => line(`fn ${name}`, rs)) - return [ - `# Gap report`, - ``, - header, - ...rows, - ``, - `## Functions with gaps`, - ``, - header, - ...functions, - ``, - ].join(`\n`) + process.exit(exitCode) } diff --git a/scripts/oracle-gaps/owners.mjs b/scripts/oracle-gaps/owners.mjs new file mode 100644 index 0000000000..f0cfac8c84 --- /dev/null +++ b/scripts/oracle-gaps/owners.mjs @@ -0,0 +1,207 @@ +// Ownership: which oracle files judge a function. +// +// An oracle file names what it owns in `declareOracle({ owns: [...] })`. Each +// entry is a source path relative to the package, either alone (the whole +// file) or with an entry function (`src/x.ts#run`, `src/x.ts#Class.method`). +// Owning an entry function owns its static reference tree within the package, +// as the TypeScript type checker resolves it: every function, method, or +// class that its body refers to, whether it calls it by name, through an +// import or its alias, through `this` or a typed field, through `.bind`, or +// passes it as a value. A class passed as a value brings all its methods; +// `new C()` brings only C's constructor (maintainer decision). The tree +// is read from the source, not from what any test ran, so code an oracle +// never reaches stays owned and shows up as a gap rather than dropping out of +// the oracle's scope. It stops at the package's boundary: a declaration +// outside the package, such as another package or the standard library, is +// not followed (maintainer decisions of 2026-11-05). +// +// A function is a function declaration, a method (named `Class.method`), a +// constructor (named `Class.constructor`), or a function assigned to a name; a mutant belongs to the innermost one whose +// lines hold it. A function's owners are the oracle files whose trees contain +// it. A gap is judged across all of them: a mutant is killed when any owner +// kills it, and a function with no owner is reported as `unowned`, a warning +// rather than a gap. +import { relative, resolve } from 'node:path' + +/** + * Whether a test file calls `declareOracle` (which makes it an oracle file), + * the `owns` entries it names, and a problem when the list cannot be read + * statically: it must be a literal list of strings or plain template + * literals, because an entry the CLI cannot read would hide gaps. + */ +export function readOwns(ts, path, text) { + const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) + const result = { declares: false, entries: [], problem: undefined } + const visit = (node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === `declareOracle` + ) { + result.declares = true + const parts = node.arguments[0] + for (const property of parts && ts.isObjectLiteralExpression(parts) + ? parts.properties + : []) + if (property.name?.getText(file) === `owns`) { + const list = ts.isPropertyAssignment(property) + ? property.initializer + : undefined + if (!list || !ts.isArrayLiteralExpression(list)) { + result.problem = `its owns list is not a literal list, so the gap report cannot read what it owns` + continue + } + for (const element of list.elements) + if (ts.isStringLiteralLike(element)) result.entries.push(element.text) + else + result.problem = `its owns entry ${element.getText(file)} is not a literal string` + } + } + ts.forEachChild(node, visit) + } + visit(file) + return result +} + +/** The named functions of one source file, with their nodes and line spans. */ +export function functionsIn(ts, file) { + const lineOf = (pos) => file.getLineAndCharacterOfPosition(pos).line + 1 + const functions = new Map() + const visit = (node, cls) => { + const owner = + ts.isClassDeclaration(node) && node.name ? node.name.text : cls + const name = + ts.isFunctionDeclaration(node) && node.name && node.body + ? node.name.text + : ts.isMethodDeclaration(node) && node.name && node.body + ? `${owner ?? ``}.${node.name.getText(file)}` + : ts.isConstructorDeclaration(node) && node.body + ? `${owner ?? ``}.constructor` + : ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + (ts.isArrowFunction(node.initializer) || + ts.isFunctionExpression(node.initializer)) + ? node.name.text + : undefined + if (name !== undefined) + functions.set(name, { + node: ts.isVariableDeclaration(node) ? node.initializer : node, + start: lineOf(node.getStart(file)), + end: lineOf(node.end), + }) + ts.forEachChild(node, (child) => visit(child, owner)) + } + visit(file, undefined) + return functions +} + +/** + * Owners by source and function. `oracles` maps each oracle file to its + * `owns` entries; `program` is a TypeScript program over the package's + * sources and `pkg` the package's directory. + */ +export function ownership(ts, program, pkg, oracles) { + const checker = program.getTypeChecker() + const inPackage = (fileName) => { + const path = relative(pkg, fileName) + return !path.startsWith(`..`) && !path.split(/[\\/]/).includes(`node_modules`) + } + const indexed = new Map() + const functionsOf = (source) => { + if (!indexed.has(source)) { + const file = program.getSourceFile(resolve(pkg, source)) + indexed.set(source, file ? functionsIn(ts, file) : undefined) + } + return indexed.get(source) + } + // The package functions a declaration names: itself, or, for a class used + // as a value, all its methods. `new C()` names only C's constructor; C's + // methods are owned where the tree refers to them. + const targetsOf = (declaration, constructed) => { + const file = declaration.getSourceFile() + if (!inPackage(file.fileName)) return [] + const source = relative(pkg, file.fileName) + const functions = functionsOf(source) + if (ts.isClassDeclaration(declaration) && declaration.name) { + const prefix = `${declaration.name.text}.` + if (constructed) + return functions.has(`${prefix}constructor`) + ? [{ source, fn: `${prefix}constructor` }] + : [] + return [...functions.keys()] + .filter((name) => name.startsWith(prefix)) + .map((fn) => ({ source, fn })) + } + for (const [fn, entry] of functions) + if ( + entry.node === declaration || + entry.node === declaration.initializer + ) + return [{ source, fn }] + return [] + } + // Every package function, method, or class an owned body refers to. + const referencesOf = (node) => { + const found = [] + const visit = (child) => { + if (ts.isIdentifier(child)) { + let symbol = checker.getSymbolAtLocation(child) + if (symbol && symbol.flags & ts.SymbolFlags.Alias) + symbol = checker.getAliasedSymbol(symbol) + const constructed = + ts.isNewExpression(child.parent) && child.parent.expression === child + for (const declaration of symbol?.declarations ?? []) + found.push(...targetsOf(declaration, constructed)) + } + ts.forEachChild(child, visit) + } + if (node.body) visit(node.body) + return found + } + const owners = new Map() + const add = (source, fn, oracle) => { + const key = `${source}#${fn}` + owners.set(key, new Set([...(owners.get(key) ?? []), oracle])) + } + for (const [oracle, entries] of oracles) + for (const entry of entries) { + const [source, entryFunction] = entry.split(`#`) + if (entryFunction === undefined) { + add(source, `*`, oracle) + continue + } + const seen = new Set() + const walk = (at) => { + const key = `${at.source}#${at.fn}` + if (seen.has(key)) return + seen.add(key) + add(at.source, at.fn, oracle) + const entryNode = functionsOf(at.source)?.get(at.fn)?.node + if (entryNode) for (const next of referencesOf(entryNode)) walk(next) + } + if (functionsOf(source)?.has(entryFunction)) + walk({ source, fn: entryFunction }) + } + return { + has: (source, fn) => functionsOf(source)?.has(fn) === true, + // A whole-file entry owns every function in the file. + of: (source, fn) => + new Set([ + ...(owners.get(`${source}#${fn}`) ?? []), + ...(owners.get(`${source}#*`) ?? []), + ]), + /** The innermost function that contains a line. */ + functionAt: (source, line) => { + let best + for (const [name, span] of functionsOf(source) ?? []) + if ( + span.start <= line && + line <= span.end && + (!best || span.start >= best.start) + ) + best = { name, start: span.start } + return best?.name ?? `` + }, + } +} diff --git a/scripts/oracle-gaps/report.mjs b/scripts/oracle-gaps/report.mjs new file mode 100644 index 0000000000..98372533cf --- /dev/null +++ b/scripts/oracle-gaps/report.mjs @@ -0,0 +1,182 @@ +// The check report: the gaps an oracle's author must answer. +// +// The report is evidence, not a fix. A surviving mutant or an unreached +// branch does not say which model rule to add; it says the oracle cannot see +// some difference in production. The author reads each gap, states the law it +// reveals (missing, misstated, or unenforced), names that law's authority, +// and re-plans the oracle around the law rather than around the line, then +// runs the check again. Recording that answer as an evidence-log event, and +// the gate that reads it, wait on the maintainer's decision about how an +// answer maps to a law; until then the report only lists the gaps. +// +// A gap is identified by an anchor that survives unrelated edits and +// reformatting: the source file, the function (`name`, or `Class.method`), +// the operator, and the tokens of the innermost statement that holds the +// site, joined by single spaces and kept in full, with `~n` for the nth +// repeat of one anchor in a function. An authored mutant's key is its id. + +/** Outcomes that are gaps. Only a law verdict on a reproducible route kills. */ +export const gapOutcomes = new Set([ + `survived`, + `crashed`, + `unreached`, + `random-only-kill`, + `agreement-only-kill`, + `unregistered-kill`, + `framework-error`, + `timeout`, + `setup-error`, + `does-not-apply`, +]) + +export function assignKeys(results) { + const seen = new Map() + for (const result of results) { + if (result.authored) { + result.key = `${result.source}#authored:${result.authoredId}` + continue + } + const base = `${result.source}#${result.function ?? ``}:${result.operator}:${result.anchor}` + const count = (seen.get(base) ?? 0) + 1 + seen.set(base, count) + result.key = count === 1 ? base : `${base}~${count}` + } +} + +// What each gap shows, phrased as the question the author answers. +const questions = { + 'generator-gap': `An \`if\` here never took one of its outcomes in an owner's history. Which legal input makes it take the other one, and which law governs that input? If no public use can reach it, say why (an equivalent mutant, which needs a maintainer's approval).`, + 'comparison-gap': `Observed values changed, but no law rejected them. Which law forbids the changed value? Its observation, checkpoint, or comparison is missing or too loose.`, + 'no-observed-difference': `Every \`if\` outcome here occurred, yet nothing observed changed. Either the mutant is equivalent (say why; a maintainer approves it), or no explored history tells it apart: which history would, and which law would it test?`, + indeterminate: `Only nondeterministic owner tests reach this site, so their failures judge nothing. Which source of nondeterminism hides it?`, + survived: `The mutant changed production and no law rejected it. Which law does the intent violate? Which observation or history would show it?`, + unreached: `No owner's test executes this code. Which law covers its behavior? Extend the oracle's domain to reach it; a limit needs a maintainer's decision.`, + 'random-only-kill': `Only the random campaign rejected this mutant, and its seed changes in every CI run. Which state did the counterexample reach? Keep it as a fixed or constructed history.`, + 'agreement-only-kill': `Only reach agreement rejected this mutant: production took a different path, but no behavioral law saw a different result. Is the behavior really the same, or is a behavioral law missing?`, + 'unregistered-kill': `Only a test with no registered route rejected this mutant, so its route is unknown. Register it through the framework, or make a reproducible test see the difference.`, + crashed: `Production threw where no law judged it. A crash is not a kill: which law should reject this behavior, and at which checkpoint?`, + 'framework-error': `Only a framework check (a calibration, probe calibration, or the determinism test) failed, with no law violation. The oracle noticed the change but no law judged it.`, + timeout: `The run did not end. Which law does a run that never ends violate, and what bounds the driver?`, + 'setup-error': `The run failed outside any test. The mutant may break module loading; say whether that is a detection.`, + 'does-not-apply': `The authored mutant's text does not occur exactly once in the source. Update or retire the mutant.`, +} + +export function gapsOf(results) { + return results.filter( + (result) => !result.probeGuard && gapOutcomes.has(result.outcome), + ) +} + +/** A key as a Markdown code span whose fence outruns any backticks in it. */ +export function codeSpan(text) { + const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((r) => r.length)) + const fence = `\``.repeat(longest + 1) + return `${fence} ${text} ${fence}` +} + +function change(result) { + if (result.authored) return `${result.intent} (authored by ${result.author})` + return `${codeSpan(String(result.original).replace(/\s+/g, ` `))} → ${codeSpan(String(result.replacement))}` +} + +export function renderReport({ results, run, excluded }) { + const gaps = gapsOf(results) + const guards = results.filter((r) => r.probeGuard) + const unowned = results.filter((r) => !r.probeGuard && r.outcome === `unowned`) + const killed = results.filter((r) => r.outcome === `killed` && !r.probeGuard) + const crashed = results.filter( + (r) => r.outcome === `crashed` && !r.probeGuard, + ) + const lines = [ + `# Oracle check report`, + ``, + `Run ${run.id}. Inputs: ${run.inputs}.`, + ``, + `${results.length} mutants (${results.filter((r) => r.authored).length} authored). ` + + `${killed.length} killed by a law on a reproducible route, ` + + `${crashed.length} crashed production, ` + + `**${gaps.length} gaps**.` + + (unowned.length > 0 + ? ` ${unowned.length} mutants are in functions no oracle owns (warnings).` + : ``) + + (guards.length > 0 + ? ` ${guards.length} mutants of state-probe code (guards, helpers, and calls to them) are left out; reach agreement and the bundle check own them.` + : ``), + ``, + ] + if (excluded.length > 0) + lines.push( + `Ordinary test files left out because they fail with no mutant active: ${excluded.join(`, `)}.`, + ``, + ) + lines.push( + `## How to use this report`, + ``, + `A gap is evidence that the oracle cannot see some difference in production. It does not say which model rule to add. For each gap:`, + ``, + `1. State the law it reveals: one that is missing, misstated, or not enforced. Several gaps often reveal one law.`, + `2. Name the law's authority: a contract document and section, or a maintainer decision.`, + `3. Re-plan the oracle around that law: its grammar or domain, model, observations, or comparison. Do not patch the line.`, + `4. Run the check again.`, + ``, + `Rerun one mutant with \`--only \`.`, + ``, + `## Gaps`, + ``, + ) + const byFunction = new Map() + for (const gap of gaps) { + const group = gap.authored ? `authored mutants` : `fn ${gap.function}` + byFunction.set(group, [...(byFunction.get(group) ?? []), gap]) + } + if (gaps.length === 0) lines.push(`None.`, ``) + for (const [group, rows] of byFunction) { + lines.push(`### ${group} (${rows[0].source})`, ``) + for (const gap of rows) { + const kind = gap.class ?? gap.outcome + lines.push( + `- **${kind}**, line ${gap.line}: ${change(gap)}`, + ` - Key: ${codeSpan(gap.key)}; rerun: \`--only ${gap.id}\``, + ` - ${questions[kind] ?? questions.survived}`, + ) + if (gap.oracleFiles?.length) + lines.push(` - Owners: ${gap.oracleFiles.join(`, `)}`) + if (gap.changedTests?.length) + lines.push( + ` - Changed observations in: ${gap.changedTests + .slice(0, 3) + .map((k) => k.split(` > `).slice(1).join(` > `)) + .join(`; `)}`, + ) + if (gap.laws?.length) + lines.push(` - Laws that rejected it: ${gap.laws.join(`, `)}`) + if (gap.detail) lines.push(` - Detail: ${gap.detail.slice(0, 200)}`) + } + lines.push(``) + } + if (unowned.length > 0) + lines.push( + `## Unowned (warnings)`, + ``, + `No oracle's \`owns\` call tree contains these functions, so no test judges them.`, + ``, + ...unowned.map((r) => `- Unowned: ${codeSpan(r.key)}`), + ``, + ) + const byLaw = new Map() + for (const result of killed) + for (const law of result.laws ?? []) + byLaw.set(law, (byLaw.get(law) ?? 0) + 1) + lines.push( + `## Kills by law`, + ``, + `A law that kills nothing may be untested by the mutants, or redundant.`, + ``, + `| Law | Mutants it rejected |`, + `|---|---|`, + ...[...byLaw] + .sort((a, b) => b[1] - a[1]) + .map(([law, count]) => `| ${law} | ${count} |`), + ) + return lines.join(`\n`) +} diff --git a/scripts/oracle-gaps/schemata.mjs b/scripts/oracle-gaps/schemata.mjs index 2fc6671185..a9fa2358ed 100644 --- a/scripts/oracle-gaps/schemata.mjs +++ b/scripts/oracle-gaps/schemata.mjs @@ -33,29 +33,49 @@ const logical = new Map([ [`??`, `&&`], ]) -function functionName(ts, node) { - if (node.name && ts.isIdentifier(node.name)) return node.name.text - const parent = node.parent - if ( - parent && - (ts.isVariableDeclaration(parent) || - ts.isPropertyAssignment(parent) || - ts.isPropertyDeclaration(parent)) && - ts.isIdentifier(parent.name) +// A mutant's anchor is the innermost statement that holds its site, as its +// tokens joined by single spaces, so formatting does not move it. +function anchorOf(ts, file, node) { + let statement = node + while ( + statement.parent && + !( + statement.kind >= ts.SyntaxKind.FirstStatement && + statement.kind <= ts.SyntaxKind.LastStatement + ) + ) + statement = statement.parent + const scanner = ts.createScanner( + ts.ScriptTarget.Latest, + true, + ts.LanguageVariant.Standard, + statement.getText(file), ) - return parent.name.text - return undefined + const tokens = [] + for ( + let token = scanner.scan(); + token !== ts.SyntaxKind.EndOfFileToken; + token = scanner.scan() + ) { + // The scanner splits `>=` and `>>`; the parser rejoins them this way. + if (token === ts.SyntaxKind.GreaterThanToken) scanner.reScanGreaterToken() + tokens.push(scanner.getTokenText()) + } + return tokens.join(` `) } // `offset` keeps site numbers unique across every instrumented source file, -// because one switch serves them all. +// because one switch serves them all. A mutant names no function: the runner +// names it by the function declaration whose lines hold it (owners.mjs), so +// keys and ownership read one source. export function instrument(ts, path, text, source, offset = 0) { const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) const lineOf = (pos) => file.getLineAndCharacterOfPosition(pos).line + 1 const mutants = [] // `at` is the node whose line the mutant reports: the operator token for a // swap and the condition for an `if`, as the earlier runner reported them. - const add = (at, operator, fn, original, replacement) => { + const add = (at, operator, original, replacement) => { + const probeGuard = guarded const local = mutants.length + 1 const id = offset + local mutants.push({ @@ -65,37 +85,136 @@ export function instrument(ts, path, text, source, offset = 0) { source, line: lineOf(at.getStart(file)), operator, - function: fn, original, replacement, + anchor: anchorOf(ts, file, at), + ...(probeGuard ? { probeGuard: true } : {}), }) return id } + // Probe-only code is reported apart from the gaps: a probe compiles to + // nothing in production builds (the bundle check owns it), and a deleted or + // misplaced probe is reach agreement's to catch. Probe-only code is, by the + // syntax tree: + // - a probe statement in the decided form, + // `process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.(name, condition)`, + // whose receiver may be cast with `as`, including its condition; + // - a call statement to a probe helper, a function declaration whose every + // statement is probe-only (found by repeating until no helper is added); + // - an `if` with no `else` whose branch is probe-only, or a block whose + // statements all are. + // Code beside a probe, and any other probe shape, is production code. + const helpers = new Set() + const unwrap = (node) => { + let inner = node + while (ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner)) + inner = inner.expression + return inner + } + const isGuard = (node) => + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken && + node.left.getText(file) === `process.env.NODE_ENV` && + ts.isStringLiteralLike(node.right) && + node.right.text === `production` + const isHook = (node) => { + const call = unwrap(node) + if (!ts.isCallExpression(call) || call.arguments.length !== 2) return false + const callee = call.expression + return ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === `__oracleProbe` && + ts.isIdentifier(unwrap(callee.expression)) && + unwrap(callee.expression).text === `globalThis` + ) + } + const isProbeStatement = (node) => + ts.isExpressionStatement(node) && + ts.isBinaryExpression(node.expression) && + node.expression.operatorToken.kind === + ts.SyntaxKind.AmpersandAmpersandToken && + isGuard(unwrap(node.expression.left)) && + isHook(node.expression.right) + const callsHelper = (node) => { + const target = + ts.isExpressionStatement(node) && ts.isCallExpression(node.expression) + ? node.expression.expression + : undefined + return ( + target !== undefined && + ts.isIdentifier(target) && + helpers.has(target.text) + ) + } + const isProbeOnly = (node) => + isProbeStatement(node) || + callsHelper(node) || + (ts.isIfStatement(node) && + node.elseStatement === undefined && + isProbeOnly(node.thenStatement)) || + (ts.isBlock(node) && + node.statements.length > 0 && + node.statements.every(isProbeOnly)) + const declarations = [] + const collect = (node) => { + if (ts.isFunctionDeclaration(node) && node.name && node.body) + declarations.push(node) + ts.forEachChild(node, collect) + } + collect(file) + for (let grew = true; grew; ) { + grew = false + for (const node of declarations) { + if (helpers.has(node.name.text)) continue + if ( + node.body.statements.length > 0 && + node.body.statements.every(isProbeOnly) + ) { + helpers.add(node.name.text) + grew = true + } + } + } + const isProbeGuard = (node) => + isProbeOnly(node) || + (ts.isFunctionDeclaration(node) && + node.name !== undefined && + helpers.has(node.name.text)) + let guarded = false + // Emit `node` with its children's instrumented text spliced in, then wrap // the node itself if it is a mutation site. Sites are numbered before their // children, matching the order of the earlier per-file runner. - function emit(node, fn) { - const name = ts.isFunctionLike(node) ? (functionName(ts, node) ?? fn) : fn + function emit(node) { + const outer = guarded + guarded ||= isProbeGuard(node) + try { + return emitNode(node) + } finally { + guarded = outer + } + } + function emitNode(node) { const ids = {} if (ts.isBinaryExpression(node)) { const op = node.operatorToken.getText(file) const swap = comparisons.get(op) ?? logical.get(op) if (swap) - ids.binary = add(node.operatorToken, `binary-swap`, name, op, swap) + ids.binary = add(node.operatorToken, `binary-swap`, op, swap) } if ( ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken ) - ids.not = add(node, `drop-not`, name, `!`, ``) + ids.not = add(node, `drop-not`, `!`, ``) if (ts.isIfStatement(node)) { const condition = node.expression.getText(file) - ids.ifTrue = add(node.expression, `if-true`, name, condition, `true`) - ids.ifFalse = add(node.expression, `if-false`, name, condition, `false`) + ids.ifTrue = add(node.expression, `if-true`, condition, `true`) + ids.ifFalse = add(node.expression, `if-false`, condition, `false`) } if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) - ids.call = add(node, `delete-call`, name, node.getText(file), `;`) + ids.call = add(node, `delete-call`, node.getText(file), `;`) const children = [] ts.forEachChild(node, (child) => { @@ -109,7 +228,7 @@ export function instrument(ts, path, text, source, offset = 0) { let position = start for (const child of children) { const childStart = child.getStart(file) - const childText = emit(child, name) + const childText = emit(child) out += text.slice(position, childStart) emitted.set(child, { text: childText, at: out.length }) out += childText @@ -142,6 +261,6 @@ export function instrument(ts, path, text, source, offset = 0) { return out } - const code = emit(file, ``) + const code = emit(file) return { code, mutants } } diff --git a/scripts/oracle-gaps/vitest.config.mjs b/scripts/oracle-gaps/vitest.config.mjs new file mode 100644 index 0000000000..61cee12ed2 --- /dev/null +++ b/scripts/oracle-gaps/vitest.config.mjs @@ -0,0 +1,15 @@ +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +// The gap report's own oracle. It runs the CLI on scratch packages, so it +// runs in Node, one file at a time, with no coverage. +export default defineConfig({ + test: { + root: dirname(fileURLToPath(import.meta.url)), + include: [`*.property.test.mjs`], + environment: `node`, + coverage: { enabled: false }, + maxWorkers: 1, + }, +}) From b172b3636ec1ac3f170bb0fc2384c72671d2df81 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 15:29:41 -0700 Subject: [PATCH 037/168] docs(skills): list the oracle weaknesses that reviewers must hunt by hand Lint, reach checks, coverage, and calibrations cannot see a run that is not checked, a model that copies production, a grammar narrower than its law, easy declared states, or a late checkpoint. The oracle-review skill now names each one and what to check for it. Co-authored-by: Isaac --- .agents/skills/oracle-review/SKILL.md | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.agents/skills/oracle-review/SKILL.md b/.agents/skills/oracle-review/SKILL.md index 4e22dc063b..b8201b4178 100644 --- a/.agents/skills/oracle-review/SKILL.md +++ b/.agents/skills/oracle-review/SKILL.md @@ -82,6 +82,44 @@ Report each one as a finding: fix, a coverage-map record, or a maintainer decision. - A closure claim without a mutant that fails the oracle. +Lint, reach checks, coverage, and calibrations cannot see the weaknesses +below. Treat them as your main job, and hunt for each one in the oracle under +review, not only in the diff: + +- **Run but not checked.** A history executes the code, but the comparison + never observes its effect. Coverage and declared reach both pass. Find a + line the oracle claims, and name the mutant on it that would survive. +- **A model that copies production.** The model mirrors production's branches, + classifications, or helpers, so both agree on the same wrong answer. Compare + the model's structure with production, not only its results. +- **A grammar narrower than the law.** The law's prose claims a value domain, + action, or case that the grammar never generates. Examples are a type the + generator never produces, an exclusion stated in prose but missing from the + declared limits, and a size or length setting that makes part of the + declared range unreachable. Check each claim in the law against a generator + dimension. +- **Easy declared states and probes.** A declared state or probe is placed + where it is always true, or is defined by a weaker condition than the state + it names. Its count rises without showing that histories enter the + interesting state. Check each definition against the state that the law + needs. +- **A late or coarse checkpoint.** The comparison runs only at the end of a + history or observes only a summary, so a wrong intermediate observation + disappears. Ask whether a user could see the wrong value before the + checkpoint. +- **A limit that hides in-scope work.** A declared limit, waiver, or + coverage-map entry excludes a legal case that the law promises. Check that + each limit's decision covers exactly what it excludes. +- **A dismissed survivor.** A surviving mutant is called equivalent or out of + scope without an argument from the contract. Require that argument, or a + witness. +- **An invariant guard without an invariant witness.** A throw for a + contradictory collaborator signal or impossible state has no history that + reaches it. Require an invariant witness: a fault-injected step that supplies + the contradictory signal, with the expected observation that production + fails at the boundary before it releases established state or publishes + success. + Report supported findings and remaining uncertainty. Do not invent findings, retire laws, or rewrite expectations to make a review come out decisive. From 626dd161ac1ab35ebd3a1f3e537b44c495699324 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 15:33:43 -0700 Subject: [PATCH 038/168] refactor(probes): allow pure arrow conditions and migrate multiset's probes A probe condition may now hold an arrow function with an expression body, held to the same rule as the rest of the condition, so a condition can scan a collection (maintainer decision). An arrow with a block body is still refused. multiset.ts now calls `__oracleProbe(name, condition)` behind the `NODE_ENV` guard, its `reportState` and `reportPairsWithoutKey` helpers are inlined, and its `devBuild()` helper is gone. Law: lint.probe-form, with the arrow clause. RED: the unchanged rule reported the legal arrow conditions (census and both campaigns failed). GREEN: 5 of 5; db-ivm 49 of 49 files, including reach agreement in multiset-consolidate. Mutant: a rule that also allows block-bodied arrows fails the census. Size: db-ivm gzip unchanged. Co-authored-by: Isaac --- packages/db-ivm/src/multiset.ts | 83 ++++++++++++++++--- .../probe-form-oracle.property.test.js | 20 ++++- .../eslint-plugin-oracle/rules/probe-form.js | 22 +++-- 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/packages/db-ivm/src/multiset.ts b/packages/db-ivm/src/multiset.ts index feb8904598..3241e7c490 100644 --- a/packages/db-ivm/src/multiset.ts +++ b/packages/db-ivm/src/multiset.ts @@ -86,16 +86,20 @@ export class MultiSet { // Keyed multisets hold [string | number key, value] pairs. Their values are // compared by reference, which avoids hashing. Join tuples (values of // length 2) are unpacked so ['A', null] and [null, 'X'] stay distinct. - if ( - inner.every( - ([data]) => - Array.isArray(data) && - data.length === 2 && - (typeof data[0] === `string` || typeof data[0] === `number`), - ) - ) { - return consolidateKeyed(inner) - } + const keyed = inner.every( + ([data]) => + Array.isArray(data) && + data.length === 2 && + (typeof data[0] === `string` || typeof data[0] === `number`), + ) + // Development-only oracle probe; a production build removes it. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#keyed-identity`, keyed) + if (keyed) return consolidateKeyed(inner) // Unkeyed data of a single primitive type is its own identity, and the // result holds the Map key, so -0 becomes 0. Anything else is compared @@ -107,9 +111,36 @@ export class MultiSet { primitiveType ??= type return type !== primitiveType }) - return requireHash - ? consolidateBy(inner, hash) - : consolidateBy(inner, (data) => data, true) + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#primitive-identity`, !requireHash) + if (!requireHash) return consolidateBy(inner, (data) => data, true) + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#structural-identity`, true) + // The pairs-without-key scan runs only in development builds, where the + // probe's condition is evaluated. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `db-ivm#only-pairs-without-key`, + inner.every( + ([data]) => + Array.isArray(data) && + data.length === 2 && + !(typeof data[0] === `string` || typeof data[0] === `number`), + ), + ) + return consolidateBy(inner, hash) } extend(other: MultiSet | MultiSetArray): void { @@ -147,6 +178,19 @@ function consolidateKeyed(inner: MultiSetArray): MultiSet { let bySecond = byFirst.get(value[0]) if (!bySecond) byFirst.set(value[0], (bySecond = new Map())) entry = bySecond.get(value[1]) + // Development-only oracle probes; a production build removes them. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#records-merged`, entry !== undefined) + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#join-tuples-merged`, entry !== undefined) if (!entry) { bySecond.set(value[1], (entry = [data, 0])) entries.push(entry) @@ -155,6 +199,12 @@ function consolidateKeyed(inner: MultiSetArray): MultiSet { let byValue = leaves.get(key) if (!byValue) leaves.set(key, (byValue = new Map())) entry = byValue.get(value) + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#records-merged`, entry !== undefined) if (!entry) { byValue.set(value, (entry = [data, 0])) entries.push(entry) @@ -179,6 +229,13 @@ function consolidateBy( for (const [data, multiplicity] of inner) { const identity = identityOf(data) const entry = consolidated.get(identity) + // Development-only oracle probe; a production build removes it. + process.env.NODE_ENV !== `production` && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#records-merged`, entry !== undefined) if (entry) entry[1] += multiplicity else consolidated.set(identity, [data, multiplicity]) } diff --git a/scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js b/scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js index cbdf902ccf..4ff18163f9 100644 --- a/scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js +++ b/scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js @@ -12,8 +12,11 @@ * string literal or a template literal without substitutions, and it names a * state as `package#name`. The statement has exactly two arguments, * and the condition contains no assignment, update, `new`, `delete`, `await`, - * `yield`, or function or class expression. A call is allowed in a condition: - * a lint cannot prove that a call is pure, which is a limit of this law. + * `yield`, function expression, class expression, or arrow function with a + * block body. An arrow function with an expression body is allowed, under the + * same rule for its body, so a condition can scan a collection + * (`rows.every((row) => …)`). A call is allowed in a condition: a lint cannot + * prove that a call is pure, which is a limit of this law. * * Authority: the maintainer's decision on the probe form, which replaces the * `devBuild()` guard ("this is a standalone framework now so we can't use the @@ -152,10 +155,15 @@ const LEGAL_CONDITIONS = [ { text: `!flag`, calls: false }, { text: `-input`, calls: false }, { text: `void 0`, calls: false }, + // Arrows with an expression body (maintainer decision, 2026-11-05). + { text: `items.every((item) => item > 0)`, calls: true, arrow: true }, + { text: `items.some(([a, b]) => a === b && typeof a === 'string')`, calls: true, arrow: true }, + { text: `(() => flag)()`, calls: true, arrow: true }, ] // Each expression is a forbidden form: an assignment, an update, `new`, -// `delete`, `await`, `yield`, a function, an arrow, or a class expression. +// `delete`, `await`, `yield`, a function or class expression, an arrow with a +// block body, or an arrow whose expression body holds a forbidden form. // The generated program is an async generator, so `await` and `yield` parse. const FORBIDDEN_CONDITIONS = [ `(flag = true)`, @@ -166,7 +174,9 @@ const FORBIDDEN_CONDITIONS = [ `delete obj.key`, `(await flag)`, `(yield input)`, - `(() => flag)()`, + `(() => { return flag })()`, + `items.every((item) => (item = 1))`, + `items.some((item) => item++)`, `(function () { return flag })()`, `(class {})`, ] @@ -387,6 +397,7 @@ const LEGAL_SUBSTATES = [ `legal:template-name`, `legal:guard-parens`, `legal:call-condition`, + `legal:arrow-condition`, ] const STATES = [...Object.keys(MODEL), ...LEGAL_SUBSTATES] const reach = createOracleReach(PROPERTY, STATES, { minimum: 1 }) @@ -400,6 +411,7 @@ function recordStates(program) { if (site.quote === '`') reach.reach(`legal:template-name`) if (site.guardParens) reach.reach(`legal:guard-parens`) if (site.cond.calls) reach.reach(`legal:call-condition`) + if (site.cond.arrow) reach.reach(`legal:arrow-condition`) } } diff --git a/scripts/eslint-plugin-oracle/rules/probe-form.js b/scripts/eslint-plugin-oracle/rules/probe-form.js index d4a0fb96ae..71eb0464b8 100644 --- a/scripts/eslint-plugin-oracle/rules/probe-form.js +++ b/scripts/eslint-plugin-oracle/rules/probe-form.js @@ -10,9 +10,11 @@ * is `globalThis`, optionally cast with `as`. Its name is a literal or a * template literal without substitutions, of the form `package#name`. It has * exactly two arguments, and the condition contains no assignment, update, - * `new`, `delete`, `await`, `yield`, or function or class expression. A call - * is allowed in the condition: a syntactic rule cannot prove that a call is - * pure. + * `new`, `delete`, `await`, `yield`, function or class expression, or arrow + * function with a block body. An arrow with an expression body is allowed, + * and its body is held to the same rule, so a condition can scan a + * collection (maintainer decision, 2026-11-05). A call is allowed in the + * condition: a syntactic rule cannot prove that a call is pure. * * The law and its authority are in * scripts/eslint-plugin-oracle/probe-form-oracle.property.test.js, which also @@ -24,8 +26,10 @@ const PROBE = `__oracleProbe` const NAME = /^[a-z][a-z0-9-]*#[a-zA-Z][a-zA-Z0-9.-]*$/ const PRODUCTION = `production` -// The expression kinds that the condition must not contain. A function, an -// arrow, or a class expression can hide a mutation or an await. +// The expression kinds that the condition must not contain. A function or a +// class expression can hide a mutation or an await. An arrow cannot when its +// body is an expression, which this check reads like the rest of the +// condition; an arrow with a block body is refused in `containsForbidden`. const FORBIDDEN = new Set([ `AssignmentExpression`, `UpdateExpression`, @@ -33,7 +37,6 @@ const FORBIDDEN = new Set([ `AwaitExpression`, `YieldExpression`, `FunctionExpression`, - `ArrowFunctionExpression`, `ClassExpression`, ]) @@ -51,6 +54,11 @@ function childrenOf(node) { function containsForbidden(node) { if (FORBIDDEN.has(node.type)) return true if (node.type === `UnaryExpression` && node.operator === `delete`) return true + if ( + node.type === `ArrowFunctionExpression` && + node.body.type === `BlockStatement` + ) + return true return childrenOf(node).some(containsForbidden) } @@ -145,7 +153,7 @@ function probeMentions(expression) { return [callee.property, ...receiver] } -const message = `\`__oracleProbe\` may be called only as a state probe: one expression statement, \`process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.('package#name', condition)\`, with a name literal of the form package#name and a condition that has no assignment, update, new, delete, await, yield, or function or class expression.` +const message = `\`__oracleProbe\` may be called only as a state probe: one expression statement, \`process.env.NODE_ENV !== 'production' && globalThis.__oracleProbe?.('package#name', condition)\`, with a name literal of the form package#name and a condition that has no assignment, update, new, delete, await, yield, function or class expression, or arrow with a block body.` export default { meta: { From fe2f23eca79ac7de5a1b6d92e39da5143147e3e5 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 15:48:23 -0700 Subject: [PATCH 039/168] fix(oracle-gaps): resolve a daemon request's paths against the caller The daemon runs from the package directory, so it read a relative `--package`, `--out`, or `--mutants` against the wrong directory. The client now sends its working directory with each request, and the daemon resolves the paths against it. Found running the CLI on offline-transactions and db-ivm. The CLI oracle's driver now calls the daemon with paths relative to the repository root. RED: law 9 failed at the first check ("the daemon's check exited 1 ..."). GREEN: 26 of 26. Co-authored-by: Isaac --- scripts/oracle-gaps/args.mjs | 12 ++++++++---- scripts/oracle-gaps/daemon.mjs | 7 ++++--- .../oracle-gaps/gap-report-oracle.property.test.mjs | 13 +++++++++---- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/scripts/oracle-gaps/args.mjs b/scripts/oracle-gaps/args.mjs index 81ed73c8a0..ff482d7d9a 100644 --- a/scripts/oracle-gaps/args.mjs +++ b/scripts/oracle-gaps/args.mjs @@ -5,7 +5,11 @@ import { availableParallelism } from 'node:os' import { resolve } from 'node:path' const flags = new Set([`daemon`, `stop-daemon`]) -export function parseArgs(argv) { +/** + * The options in `argv`. Paths are resolved against `cwd`, the caller's + * working directory, which the daemon receives with each request. + */ +export function parseArgs(argv, cwd = process.cwd()) { const args = {} for (let i = 0; i < argv.length; i += 1) { const [key, inline] = argv[i].replace(/^--/, ``).split(`=`) @@ -14,7 +18,7 @@ export function parseArgs(argv) { if (!args.package) throw new Error(`--package is required`) // Test reports name files by their real path (on macOS, /tmp is // /private/tmp), so the package path is resolved the same way. - const pkg = realpathSync(resolve(args.package)) + const pkg = realpathSync(resolve(cwd, args.package)) if (args[`stop-daemon`]) return { stop: true, pkg } if (!args.source) throw new Error(`--package and --source are required`) return { @@ -29,7 +33,7 @@ export function parseArgs(argv) { sources: args.source.split(`,`), seed: args.seed, diffBase: args[`diff-base`], - out: resolve(args.out ?? `/tmp/oracle-gaps`), + out: resolve(cwd, args.out ?? `/tmp/oracle-gaps`), only: args.only ? new Set(args.only.split(`,`)) : undefined, // A fixed-seed random sample of this many mutants per source file, for // surveys across many modules. @@ -38,7 +42,7 @@ export function parseArgs(argv) { // need Vitest's default limits, which would make each hang cost seconds. testTimeout: Number(args[`test-timeout`] ?? 15000), // A directory of authored mutants (see loadAuthored). - mutants: args.mutants ? resolve(args.mutants) : undefined, + mutants: args.mutants ? resolve(cwd, args.mutants) : undefined, } } diff --git a/scripts/oracle-gaps/daemon.mjs b/scripts/oracle-gaps/daemon.mjs index dbebacf791..e4c850fd36 100644 --- a/scripts/oracle-gaps/daemon.mjs +++ b/scripts/oracle-gaps/daemon.mjs @@ -53,7 +53,7 @@ function send(pkg, message) { /** Sends a check to the package's daemon, starting the daemon if needed. */ export async function requestDaemon(pkg, argv) { try { - return await send(pkg, { argv }) + return await send(pkg, { argv, cwd: process.cwd() }) } catch (error) { if (![`ENOENT`, `ECONNREFUSED`].includes(error.code)) throw error } @@ -66,7 +66,7 @@ export async function requestDaemon(pkg, argv) { for (let waited = 0; ; waited += 100) { await new Promise((r) => setTimeout(r, 100)) try { - return await send(pkg, { argv }) + return await send(pkg, { argv, cwd: process.cwd() }) } catch (error) { if (![`ENOENT`, `ECONNREFUSED`].includes(error.code) || waited > 60_000) throw error @@ -108,7 +108,8 @@ async function serve(pkg) { let stdout = `` let exitCode = 0 try { - const options = parseArgs(message.argv) + // Paths are the caller's, so they resolve against its directory. + const options = parseArgs(message.argv, message.cwd) if (options.pkg !== pkg) throw new Error(`this daemon serves ${pkg}, not ${options.pkg}`) stdout = `${await session.check(options, store, (line) => stderr.push(`${line}\n`))}\n` diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index cea91cfe7f..72d1c8d0a4 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -161,7 +161,7 @@ import { rmSync, writeFileSync, } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import fc from 'fast-check' import { declareOracle } from '../../packages/db/tests/oracle-declaration.ts' @@ -228,11 +228,15 @@ function runCli(dir, files, { seed = 1, daemon = false } = {}) { writeFixture(dir, files) const out = join(dir, daemon ? `out-daemon` : `out`) rmSync(out, { recursive: true, force: true }) + // The CLI runs from the repository root. A daemon check names its paths + // relative to there, as an author types them, and the daemon must read + // them as the CLI's caller meant them. + const path = (absolute) => (daemon ? relative(repo, absolute) : absolute) const args = [ join(here, `index.mjs`), ...(daemon ? [`--daemon`] : []), `--package`, - dir, + path(dir), `--source`, `${SOURCE},${LIB}`, `--seed`, @@ -242,9 +246,10 @@ function runCli(dir, files, { seed = 1, daemon = false } = {}) { `--contexts`, `4`, `--out`, - out, + path(out), ] - if (files[AUTHORED] !== undefined) args.push(`--mutants`, join(dir, `mutants`)) + if (files[AUTHORED] !== undefined) + args.push(`--mutants`, path(join(dir, `mutants`))) const env = cleanEnv() const child = spawnSync(process.execPath, args, { cwd: repo, From c7a4de4275304e5d60fd05c7f4c6119be731a74b Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 16:18:18 -0700 Subject: [PATCH 040/168] fix(oracle-gaps): restart a daemon whose code changed, and keep identity location-independent A daemon keeps the CLI's modules it loaded, so after an edit to the CLI it served checks with the old code. It now compares its code's hash at each request; when the code changed it declines, closes its store and contexts, and exits, and the client starts a new daemon for the check. The run identity hashed its files in the order of their absolute paths, so the same CLI in another directory had another identity. It now orders them by their package- or CLI-relative names. Found running the CLI on offline-transactions after a fix: the old daemon was still serving. The CLI oracle now runs each incremental history on its own copy of the CLI, and a new edit, `cli`, changes the copy's code. RED: the copy's identity differed at the first check; after the ordering fix, law 9 failed after the `cli` edit ("the daemon's report differs from the cold run's"). GREEN: 26 of 26. Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 23 +++++--- scripts/oracle-gaps/daemon.mjs | 20 +++++-- .../oracle-gaps/gap-report-model-oracle.mjs | 9 ++- .../gap-report-oracle.property.test.mjs | 56 +++++++++++++++---- 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 6b4c45f967..e241a1f58f 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -70,6 +70,14 @@ const ownFiles = [ `../../packages/db/tests/oracle-property.ts`, ].map((file) => join(here, file)) +/** A hash of this CLI's own code and the framework it reads. */ +export function codeIdentity() { + const hash = createHash(`sha1`) + for (const file of ownFiles) + hash.update(`${file}\0${existsSync(file) ? readFileSync(file) : ``}\0`) + return hash.digest(`hex`) +} + /** Hashes of files by content, memoized by size and modification time. */ function fileHashes() { const memo = new Map() @@ -645,17 +653,18 @@ export default mergeConfig(base, { */ function identityOf(options, authored, testFiles, oracleCount) { const hash = createHash(`sha1`) - const files = [ - ...options.sources.map((file) => join(options.pkg, file)), - ...testFiles, - ...ownFiles, - ].sort() - // Files are named relative to the package or to this CLI, so the same - // inputs in another checkout have the same identity. + // Files are named relative to the package or to this CLI, and taken in + // the order of those names, so the same inputs in another checkout, or + // the same CLI in another directory, have the same identity. const name = (file) => file.startsWith(`${options.pkg}/`) ? file.slice(options.pkg.length + 1) : `cli:${relativePath(here, file)}` + const files = [ + ...options.sources.map((file) => join(options.pkg, file)), + ...testFiles, + ...ownFiles, + ].sort((a, b) => (name(a) < name(b) ? -1 : name(a) > name(b) ? 1 : 0)) for (const file of files) if (existsSync(file)) hash.update(`${name(file)}\0${readFileSync(file, `utf8`)}\0`) diff --git a/scripts/oracle-gaps/daemon.mjs b/scripts/oracle-gaps/daemon.mjs index e4c850fd36..ffe1b99c6b 100644 --- a/scripts/oracle-gaps/daemon.mjs +++ b/scripts/oracle-gaps/daemon.mjs @@ -50,10 +50,15 @@ function send(pkg, message) { }) } -/** Sends a check to the package's daemon, starting the daemon if needed. */ +/** + * Sends a check to the package's daemon, starting the daemon if needed. A + * daemon whose own code has changed since it started declines the check and + * exits, and a new one, which runs the current code, takes it. + */ export async function requestDaemon(pkg, argv) { try { - return await send(pkg, { argv, cwd: process.cwd() }) + const reply = await send(pkg, { argv, cwd: process.cwd() }) + if (!reply.restart) return reply } catch (error) { if (![`ENOENT`, `ECONNREFUSED`].includes(error.code)) throw error } @@ -84,7 +89,10 @@ export async function stopDaemon(pkg) { } async function serve(pkg) { - const { openSession } = await import(`./check.mjs`) + const { codeIdentity, openSession } = await import(`./check.mjs`) + // The CLI's code as this process loaded it. A process cannot reload its + // modules, so a daemon whose code changed must not run another check. + const code = codeIdentity() const { diskStore } = await import(`./evidence.mjs`) const { parseArgs } = await import(`./args.mjs`) const store = await diskStore(stateDirOf(pkg)) @@ -104,6 +112,8 @@ async function serve(pkg) { // The reply waits until the daemon has closed, so a daemon started // next finds the evidence on disk and no warm context in its way. if (message.stop) await shutdown(connection) + if (codeIdentity() !== code) + await shutdown(connection, { restart: true }) const stderr = [] let stdout = `` let exitCode = 0 @@ -126,14 +136,14 @@ async function serve(pkg) { }) }) }) - async function shutdown(connection) { + async function shutdown(connection, reply = { exitCode: 0 }) { server.close() rmSync(socket, { force: true }) await session.close() await store.close() if (connection) await new Promise((r) => - connection.end(`${JSON.stringify({ exitCode: 0 })}\n`, r), + connection.end(`${JSON.stringify(reply)}\n`, r), ) process.exit(0) } diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index c4c380990c..a3ab3f24ba 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -128,8 +128,10 @@ const testCase = fc.record({ // Law 9's edits. Each content edit changes what some evidence depends on: // a test file, a source statement, the source's line numbers, an owns list, // the authored mutant, a helper module that only file b imports, the seed -// (file a judges only under seed 1), or the set of oracle files. `restart` -// stops the daemon, and `revert` returns to the state before the last step. +// (file a judges only under seed 1), the set of oracle files, or the CLI's +// own code (`cli` changes the summary's wording in the copy of the CLI that +// the history runs). `restart` stops the daemon, and `revert` returns to the +// state before the last step. const edits = [ `test`, `source`, @@ -139,6 +141,7 @@ const edits = [ `helper`, `seed`, `newOracle`, + `cli`, `restart`, `revert`, ] @@ -1367,6 +1370,7 @@ const initialState = (plan) => ({ strict: true, seed: 1, newOracle: false, + cliEdited: false, }) /** The fixture package's files in one state. */ @@ -1487,6 +1491,7 @@ function applyEdit(state, edit) { if (edit === `helper`) return { ...state, strict: !state.strict } if (edit === `seed`) return { ...state, seed: state.seed === 1 ? 2 : 1 } if (edit === `newOracle`) return { ...state, newOracle: !state.newOracle } + if (edit === `cli`) return { ...state, cliEdited: !state.cliEdited } return state } diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 72d1c8d0a4..ec91ad0338 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -155,6 +155,7 @@ */ import { spawnSync } from 'node:child_process' import { + cpSync, mkdirSync, mkdtempSync, readFileSync, @@ -224,7 +225,7 @@ const cleanEnv = () => * daemon (law 9). A file of an earlier state that this state lacks is * removed. */ -function runCli(dir, files, { seed = 1, daemon = false } = {}) { +function runCli(dir, files, { seed = 1, daemon = false, cli = here } = {}) { writeFixture(dir, files) const out = join(dir, daemon ? `out-daemon` : `out`) rmSync(out, { recursive: true, force: true }) @@ -233,7 +234,7 @@ function runCli(dir, files, { seed = 1, daemon = false } = {}) { // them as the CLI's caller meant them. const path = (absolute) => (daemon ? relative(repo, absolute) : absolute) const args = [ - join(here, `index.mjs`), + join(cli, `index.mjs`), ...(daemon ? [`--daemon`] : []), `--package`, path(dir), @@ -317,26 +318,50 @@ function runCli(dir, files, { seed = 1, daemon = false } = {}) { * `restart` or `revert`) keeps its first cold result. */ function incremental(dir, plan, cli, observe, states) { + // The history runs its own copy of the CLI, beside this one so that its + // relative imports resolve, and the `cli` edit changes the copy's wording. + const copy = mkdtempSync(join(here, `..`, `oracle-gaps-mutant-cli-`)) + cpSync(here, copy, { + recursive: true, + filter: (path) => !/\.scratch-|node_modules/.test(path), + }) + const reportFile = join(copy, `report.mjs`) + const reportText = readFileSync(reportFile, `utf8`) + const RUN_LINE = '`Run ${run.id}. Inputs: ${run.inputs}.`' + if (!reportText.includes(RUN_LINE)) + throw new Error(`the cli edit no longer finds the summary's run line`) const cold = new Map() - const fingerprint = (files, seed) => JSON.stringify([files, seed]) - cold.set(fingerprint(fixtureFiles(plan), 1), cli) - // A cold result without what the report names as its run, so that two - // states compare by what they report. + const fingerprint = (files, state) => + JSON.stringify([files, state.seed, state.cliEdited]) + cold.set(fingerprint(fixtureFiles(plan), initialState(plan)), cli) + // A cold result without the run identity, which every edit changes, so + // that two states compare by what they report. const result = (run) => JSON.stringify([ run.exit, run.refusal, - run.report && { ...run.report, run: undefined, summary: undefined }, + run.report && { + ...run.report, + run: undefined, + summary: run.report.summary.replace(/^Run \w+/m, `Run`), + }, ]) const trail = [initialState(plan)] let previous = cli const checkAt = (step) => { const state = trail.at(-1) + writeFileSync( + reportFile, + state.cliEdited + ? reportText.replace(RUN_LINE, '`Run ${run.id} (edited). Inputs: ${run.inputs}.`') + : reportText, + ) const files = fixtureFiles(resolveHistory(state.history), state) - const key = fingerprint(files, state.seed) - if (!cold.has(key)) cold.set(key, runCli(dir, files, { seed: state.seed })) + const key = fingerprint(files, state) + if (!cold.has(key)) + cold.set(key, runCli(dir, files, { seed: state.seed, cli: copy })) const coldRun = cold.get(key) - const daemon = runCli(dir, files, { seed: state.seed, daemon: true }) + const daemon = runCli(dir, files, { seed: state.seed, daemon: true, cli: copy }) observe(`incremental`, { step, cold: { @@ -348,7 +373,12 @@ function incremental(dir, plan, cli, observe, states) { exit: daemon.exit, refusal: daemon.refusal, report: daemon.report, - stderr: daemon.stderr, + // The first line of a failure, for the message, without the paths + // of this history's scratch package and CLI copy. + stderr: daemon.stderr + ?.split(`\n`)[0] + .replaceAll(copy, ``) + .replaceAll(dir, ``), }, }) const reused = daemon.evidence?.reused ?? 0 @@ -367,7 +397,7 @@ function incremental(dir, plan, cli, observe, states) { const stop = () => spawnSync( process.execPath, - [join(here, `index.mjs`), `--stop-daemon`, `--package`, dir], + [join(copy, `index.mjs`), `--stop-daemon`, `--package`, dir], { cwd: repo, encoding: `utf8`, timeout: 60_000, env: cleanEnv() }, ) try { @@ -381,6 +411,7 @@ function incremental(dir, plan, cli, observe, states) { } } finally { stop() + rmSync(copy, { recursive: true, force: true }) } } @@ -985,6 +1016,7 @@ oracleProperty({ `owns`, `authored`, `newOracle`, + `cli`, `restart`, ], }), From c32b90eb663c25c88b00f0df4e499edaf9c528a8 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Nov 2026 16:42:17 -0700 Subject: [PATCH 041/168] fix(oracle-gaps): leave getters unread, and say why a failing owner fails The capture setup recorded each asserted value by reading its properties, which ran getters. An oracle that counts reads (db-ivm's topk-batch contract checks that unrelated payloads are never read) failed under the CLI with no mutant active, so the run refused. An accessor is now recorded as one and never called. A refusal for a failing owner now gives its first failing test and error. Law: the capture must not change what a test observes; a refusal names its cause and, for a failing owner, the failure. RED: a witness that counts getter reads made the CLI refuse ("oracle files fail with no mutant active ... tests/a.test.ts"), with no reason given. GREEN: 26 of 26. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 10 +++++++- scripts/oracle-gaps/check.mjs | 23 +++++++++++++++---- .../oracle-gaps/gap-report-model-oracle.mjs | 16 ++++++++++++- .../gap-report-oracle.property.test.mjs | 15 +++++++++++- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 8002029eea..f3eff0b073 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -255,7 +255,15 @@ function render(value, seen = new Set(), depth = 0) { } const name = prototype === null ? `null-proto` : `Object` const keys = Object.keys(value).sort() - return `${name}{${keys.map((k) => `${k}:${render(value[k], seen, depth + 1)}`).join(`,`)}}` + // An accessor is recorded as one, never called: a getter can count its + // reads, and the capture must not change what the test observes. + const field = (k) => { + const descriptor = Object.getOwnPropertyDescriptor(value, k) + return descriptor && !(`value` in descriptor) + ? `accessor` + : render(value[k], seen, depth + 1) + } + return `${name}{${keys.map((k) => `${k}:${field(k)}`).join(`,`)}}` } finally { seen.delete(value) } diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index e241a1f58f..17d8b69e0a 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -70,6 +70,16 @@ const ownFiles = [ `../../packages/db/tests/oracle-property.ts`, ].map((file) => join(here, file)) +/** A unit's first failure: a failing test and its error, or why it did not run. */ +function failureOf(unit) { + if (unit.timedOut) return `its run timed out` + const test = unit.tests.find((t) => t.state === `failed`) + const text = test + ? `${test.fullName}: ${test.message.split(`\n`)[0]}` + : (unit.setupErrors[0] ?? unit.unhandled[0] ?? `it failed`) + return text.slice(0, 300) +} + /** A hash of this CLI's own code and the framework it reads. */ export function codeIdentity() { const hash = createHash(`sha1`) @@ -423,10 +433,11 @@ export default mergeConfig(base, { const rows = new Map(units.flatMap((u) => u.rows ?? [])) for (const u of units) fileSeconds.set(u.file, Math.max(fileSeconds.get(u.file) ?? 0, u.duration ?? 0)) - const failed = new Set( + // Each failing file, with its first failure, so a refusal says why. + const failed = new Map( units .filter((u) => outcomeOf([u]).outcome !== `survived`) - .map((u) => u.file), + .map((u) => [u.file, failureOf(u)]), ) return { rows, failed, seconds: Math.max(0, ...units.map((u) => u.seconds)) } } @@ -434,12 +445,14 @@ export default mergeConfig(base, { const first = await runAcross(files, 1) // An oracle file that fails with no mutant active cannot judge the code // it owns, so the run refuses rather than report that code as unowned. - const failingOwners = [...first.failed].filter((file) => oracleFiles.has(file)) + const failingOwners = [...first.failed.keys()].filter((file) => + oracleFiles.has(file), + ) if (failingOwners.length > 0) throw new Error( - `oracle files fail with no mutant active, so their owned code cannot be judged: ${failingOwners.map(relative).join(`, `)}`, + `oracle files fail with no mutant active, so their owned code cannot be judged: ${failingOwners.map((file) => `${relative(file)} (${first.failed.get(file)})`).join(`, `)}`, ) - const excluded = [...first.failed] + const excluded = [...first.failed.keys()] if (excluded.length > 0) { log(`left out, failing with no mutant active: ${excluded.join(`, `)}`) files = files.filter((file) => !first.failed.has(file)) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index a3ab3f24ba..9659155491 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -292,6 +292,10 @@ const witness = [ // A soft assertion fails in the random campaign, and the test goes on to // register a fixed seed: the verdict keeps the random route. { id: `w_soft`, owners: [`a`], body: [kill], tests: [judging(`a`, `random seed`, 7, { judgeBy: `soft`, then: { route: `fixed seed`, input: 1 } })] }, + // A test that asserts on a value with a getter and checks that nothing + // read it, as an oracle that counts reads does. The CLI's capture must + // not read it either: the harness may not change what a test observes. + { id: `w_getter`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed seed`, 1, { getter: true })] }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -323,6 +327,7 @@ const captureWitness = new Set([ `w_assert`, `w_campaigns`, `w_soft`, + `w_getter`, ]) // ## Rendering and layout @@ -1109,7 +1114,9 @@ function refusal(plan) { if (plan.fault === `badEntry`) return { cause: `${SOURCE}#nope` } if (plan.fault === `missingSource`) return { cause: `src/missing.ts` } if (plan.fault === `nonLiteral`) return { cause: `b.test.ts` } - if (plan.fault === `brokenOwner`) return { cause: `b.test.ts` } + // A failing owner is named with its test's error, so the author can fix it. + if (plan.fault === `brokenOwner`) + return { cause: `b.test.ts`, detail: `this owner fails with no mutant active` } return undefined } @@ -1279,6 +1286,13 @@ function oracleTestFile(plan, file) { ...(t.flaky ? [` g.__oracleObserve?.('clock', performance.now())`] : []), + ...(t.getter + ? [ + ` let reads${i} = 0`, + ` expect({ get payload() { reads${i} += 1; return ${out} } }).toBeDefined()`, + ` if (reads${i} !== 0) throw new Error('the payload was read')`, + ] + : []), ...(!t.judges ? [] : t.judgeBy !== `verdict` diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index ec91ad0338..69785edf90 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -44,7 +44,11 @@ * in a function with no owner is `unowned` and is listed as a warning, not * a gap. An owns entry that names nothing, an owns list that is not a * literal, and an owner whose tests fail with no mutant active each make - * the run fail and name the cause, because each would otherwise hide gaps. + * the run fail and name the cause, because each would otherwise hide gaps; + * a failing owner is named with a failing test and its error. The CLI's + * capture must not change what a test observes: an owner that passes + * alone passes under the CLI, even when it counts the reads of a getter + * on a value it asserts on. * 4. **Classification across owners.** Each mutant is judged only by the * tests of its function's owners. Each test's route is the one the * framework registered for it (`__oracleRoute`), never its name; a test @@ -485,6 +489,8 @@ async function driver(plan, observe) { m.laws?.includes(`(unnamed)`) ) states.add(`killed under an unnamed law`) + if (m.function === `w_getter` && m.outcome === `killed`) + states.add(`a getter the capture left unread`) if (m.function === `w_campaigns` && m.outcome === `random-only-kill`) states.add(`second campaign's route attributed`) if (m.function === `w_soft` && m.outcome === `random-only-kill`) @@ -598,6 +604,12 @@ function check(plan, observations, expected) { `the refusal does not name ${expected.refused.cause}: ${cli.stderr}`, `ownership`, ) + if (expected.refused.detail && !`${cli.stderr}`.includes(expected.refused.detail)) + return violation( + `cli`, + `the refusal does not give the failing test's error, ${expected.refused.detail}: ${cli.stderr}`, + `ownership`, + ) return incrementalCheck(observations) ?? pass } if (cli.exit !== 0 || !cli.reported) @@ -907,6 +919,7 @@ const required = [ `killed under an unnamed law`, `second campaign's route attributed`, `a soft verdict keeps its campaign's route`, + `a getter the capture left unread`, ...incrementalStates, ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `brokenOwner`] From 1d0c7cb6e9c1f59c9761cf6444b057c31a6d7ae9 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 09:09:58 -0700 Subject: [PATCH 042/168] perf(oracle-gaps): stop at the first kill, run only reaching tests, cache timeouts A cold check took 14.5 minutes on offline-transactions' FIFO executor: every mutant ran every test of every owner file, about 15 s each, and each of 27 hangs waited out a long limit. - A mutant run stops at its first kill: once a behavioral law (any but reach agreement) rejects it on a reproducible route, the file's remaining tests are skipped and no further owner file runs (maintainer decision). A kill names the laws that fired. A kill now outranks a timed-out run in another owner file, so the outcome does not depend on file order. - A mutant runs only the tests that reached its site, when running just those tests with no mutant active reproduces what they recorded in the full run; otherwise the whole file runs, because a test can depend on state an earlier test set. - A run that timed out is stored under its file's unmutated imports, so an unchanged recheck no longer reruns hangs. The hang limit is 5 s plus three times the file's normal time. Laws: scripts.gap-report-classify (a kill outranks a timeout; a kill names a non-empty part of its behavioral laws); scripts.gap-report (a test that depends on an earlier test keeps its class). RED: the classify oracle owed `killed` where the CLI gave `timeout`. GREEN: 26 of 26. Mutant: selection without its guard turns w_shared's if-true into a comparison gap, owed a generator gap. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 22 ++- scripts/oracle-gaps/check.mjs | 129 ++++++++++++------ scripts/oracle-gaps/classify.mjs | 38 +++--- ...p-report-classify-oracle.property.test.mjs | 94 +++++++++---- .../oracle-gaps/gap-report-model-oracle.mjs | 23 +++- .../gap-report-oracle.property.test.mjs | 3 + 6 files changed, 218 insertions(+), 91 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index f3eff0b073..bf7f0dd150 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -331,6 +331,24 @@ function recordAssertion() { checkpoint: `assertion`, route, }) + noteKill(`(unnamed)`, route) +} + +// A mutant run may stop at its first kill (maintainer decision, +// 2026-11-06): once a behavioral law, any but reach agreement, rejects the +// mutant on a reproducible route, the file's remaining tests are skipped. +// The runner also runs no further file (check.mjs). +const reproducibleRoutes = new Set([ + `fixed seed`, + `fixed history`, + `constructed history`, + `case`, + `enumerated`, +]) +let stopped = false +function noteKill(law, at) { + if (state.stopOnKill && law !== `reach agreement` && reproducibleRoutes.has(at)) + stopped = true } let currentTask @@ -344,6 +362,7 @@ globalThis.__oracleVerdict = ({ property, law, checkpoint }) => { // several campaigns is attributed per campaign. const key = `${property}\u0000${law}\u0000${checkpoint}\u0000${route}` verdicts.set(key, { property, law, checkpoint, route }) + noteKill(law, route) } // The route the framework registered for the current test (index.mjs routeOf). let route @@ -361,7 +380,8 @@ beforeEach((context) => { currentTask = context.task observed = createHash(`sha1`) observations = 0 - if (selected && !selected.has(testKey(context.task))) context.skip() + if (stopped || (selected && !selected.has(testKey(context.task)))) + context.skip() installRandom(testKey(context.task)) digest = createHash(`sha1`) count = 0 diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 17d8b69e0a..a8de953e49 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -240,9 +240,14 @@ export default mergeConfig(base, { } /** One Vitest run of one test file, as plain data. */ - async function runUnit(context, out, file, { mutant = null, seed = null, limitMs } = {}) { + async function runUnit( + context, + out, + file, + { mutant = null, seed = null, limitMs, select = null, stopOnKill = false } = {}, + ) { const capture = join(out, `run-${process.pid}-${++runCounter}.jsonl`) - const state = { mutant, select: null, capture, seed } + const state = { mutant, select, capture, seed, stopOnKill } // Vitest 3 and 4 both expose the root project's `provide`; the state file // remains for setups that cannot `inject`. context.getRootProject?.().provide(`oracleGaps`, state) @@ -384,11 +389,15 @@ export default mergeConfig(base, { ]), ) const evidence = { reused: 0, computed: 0 } + // Each file's imports in its unmutated run. A run that timed out reports + // none, so it is stored under these: a mutant changes no import. + const fileInputs = new Map() async function unit(context, file, spec, runOptions = {}) { - const id = sha1(JSON.stringify([globalKey, file, spec])) + const id = sha1(JSON.stringify([globalKey, file, spec, runOptions.select ?? null])) const row = store.get(id) if (row && row.inputs.every(([path, hash]) => servedHash(path) === hash)) { evidence.reused += 1 + if (!row.unit.timedOut) fileInputs.set(file, row.unit.inputs) return row.unit } const result = await runUnit(context, options.out, file, { @@ -396,8 +405,11 @@ export default mergeConfig(base, { ...runOptions, }) evidence.computed += 1 - if (!result.timedOut) { - const inputs = result.inputs.map((path) => [path, servedHash(path)]) + const paths = result.timedOut ? fileInputs.get(file) : result.inputs + if (!result.timedOut && spec.startsWith(`unmutated:`)) + fileInputs.set(file, result.inputs) + if (paths) { + const inputs = paths.map((path) => [path, servedHash(path)]) for (const [path, hash] of inputs) lastServed.set(path, hash) store.put({ id, inputs, unit: result }) } @@ -416,7 +428,7 @@ export default mergeConfig(base, { // detect anything, and it may read source text that instrumentation // changes. Each file's unmutated duration sets a mutant run's time limit. const fileSeconds = new Map() - const limitFor = (file) => 10_000 + 3000 * (fileSeconds.get(file) ?? 10) + const limitFor = (file) => 5_000 + 3000 * (fileSeconds.get(file) ?? 10) const runAcross = async (files, pass) => { const parts = contexts.map(() => []) files.forEach((file, index) => parts[index % contexts.length].push(file)) @@ -496,23 +508,64 @@ export default mergeConfig(base, { // Results are appended as they finish, so a stopped survey keeps them. const partial = join(options.out, `results.jsonl`) rmSync(partial, { force: true }) - // The units of one mutant against some files, one file at a time. A - // unit that timed out makes the mutant's outcome `timeout`, and its - // cancelled context must be replaced before it runs anything else, so - // the rest are not run. - const unitsFor = async (context, targets, mutant) => { + // Whether running only `keys` of a file, with no mutant active, + // reproduces what those tests recorded when the whole file ran. A test + // can depend on state that an earlier test set, so a selection is used + // only when it reproduces the full run. + const selectionChecks = new Map() + const selectionFor = async (slot, file, keys) => { + const all = [...baseline.firstRows.keys()].filter( + (k) => fileOf(k) === file && !baseline.firstRows.get(k).background, + ) + const selection = [...new Set(keys)].filter((k) => !k.endsWith(` > (background)`)).sort() + if (selection.length === 0 || selection.length >= all.length) return null + const id = `${file}\0${selection.join(`\0`)}` + if (!selectionChecks.has(id)) { + const run = await unit(slot.context, file, `selected`, { + select: selection, + limitMs: limitFor(file), + }) + if (run.timedOut) await replace(slot) + const rows = new Map(run.rows ?? []) + selectionChecks.set( + id, + !run.timedOut && + outcomeOf([run]).outcome === `survived` && + selection.every( + (k) => + rows.get(k)?.hash === baseline.firstRows.get(k)?.hash && + rows.get(k)?.observed === baseline.firstRows.get(k)?.observed, + ), + ) + } + return selectionChecks.get(id) ? selection : null + } + // A cancelled run can leave work behind, so its context is replaced. + const replace = async (slot) => { + await Promise.race([slot.context.close().catch(() => undefined), sleep(10_000)]) + slot.context = await warmContext(settings) + await globSpecs(slot.context) + contexts[slot.index] = slot.context + } + // The units of one mutant against some files, one file at a time and + // only the tests that reached it. The runner stops at the first kill: a + // kill decides the outcome whatever the other files would do. + const unitsFor = async (slot, targets, keys, mutant) => { const done = [] for (const file of targets) { - const result = await unit(context, file, `mutant:${mutant}`, { + const result = await unit(slot.context, file, `mutant:${mutant}`, { mutant, limitMs: limitFor(file), + select: await selectionFor(slot, file, keys), + stopOnKill: true, }) done.push(result) - if (result.timedOut) break + if (result.timedOut) await replace(slot) + if (outcomeOf(done, baseline.nondeterministic).outcome === `killed`) break } return done } - async function evaluate(mutant, context) { + async function evaluate(mutant, slot) { mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) const owners = ownersOf(mutant) const targets = targetsOf(mutant, baseline, owners, oracleFiles) @@ -521,40 +574,29 @@ export default mergeConfig(base, { oracleFiles: targets.oracleTargets, ordinaryFiles: targets.testTargets, } - let replace = false const units = owners.size > 0 && targets.oracleTargets.length > 0 - ? await unitsFor(context, targets.oracleTargets, mutant.number) + ? await unitsFor(slot, targets.oracleTargets, targets.oracleKeys, mutant.number) : [] - replace ||= units.some((u) => u.timedOut) Object.assign( result, judgeMutant(mutant, { owners, targets, units, baseline, mutants }), ) - if (needsOrdinary(result, targets)) { - const ordinary = await unitsFor(context, targets.testTargets, mutant.number) - replace ||= ordinary.some((u) => u.timedOut) - result.testOutcome = judgeOrdinary(ordinary) - } + if (needsOrdinary(result, targets)) + result.testOutcome = judgeOrdinary( + await unitsFor(slot, targets.testTargets, targets.testKeys, mutant.number), + ) results.push(result) appendFileSync(partial, `${JSON.stringify(result)}\n`) log( `${mutant.id} L${mutant.line} ${mutant.operator} ${result.outcome}${result.class ? ` (${result.class})` : ``}${result.testOutcome ? ` [ordinary: ${result.testOutcome}]` : ``}`, ) - return replace } await Promise.all( - contexts.map(async (initial, slot) => { - let context = initial - for (let mutant = queue.shift(); mutant; mutant = queue.shift()) { - if (!(await evaluate(mutant, context))) continue - // A cancelled run can leave work behind, so its context is - // replaced. - await Promise.race([context.close().catch(() => undefined), sleep(10_000)]) - context = await warmContext(settings) - await globSpecs(context) - contexts[slot] = context - } + contexts.map(async (context, index) => { + const slot = { context, index } + for (let mutant = queue.shift(); mutant; mutant = queue.shift()) + await evaluate(mutant, slot) }), ) results.sort((a, b) => a.number - b.number || a.source.localeCompare(b.source)) @@ -593,26 +635,27 @@ export default mergeConfig(base, { result.outcome = `unowned` continue } - let context = contexts[0] + const slot = { context: contexts[0], index: 0 } globalThis.__oracleGapsServe = new Map([[path, patched.text]]) invalidateChanged([path]) + // An authored copy has no reach record, so whole files run; the runner + // still stops at the first kill. const units = [] for (const file of targets) { units.push( - await unit(context, file, id, { mutant: -1, limitMs: limitFor(file) }), + await unit(slot.context, file, id, { + mutant: -1, + limitMs: limitFor(file), + stopOnKill: true, + }), ) - if (units.at(-1).timedOut) break + if (units.at(-1).timedOut) await replace(slot) + if (outcomeOf(units, baseline.nondeterministic).outcome === `killed`) break } globalThis.__oracleGapsServe = undefined invalidateChanged([path]) Object.assign(result, judgeAuthored(units, baseline)) log(`${id} ${result.outcome}${result.class ? ` (${result.class})` : ``}`) - if (units.some((u) => u.timedOut)) { - await Promise.race([context.close().catch(() => undefined), sleep(10_000)]) - context = await warmContext(settings) - await globSpecs(context) - contexts[0] = context - } } // Keys go into every output, so an agent reads them from the JSON // results. diff --git a/scripts/oracle-gaps/classify.mjs b/scripts/oracle-gaps/classify.mjs index 6c6fbeea32..296cc90d28 100644 --- a/scripts/oracle-gaps/classify.mjs +++ b/scripts/oracle-gaps/classify.mjs @@ -38,10 +38,14 @@ function routeOf(registered) { * nothing, so a mutant that only such tests fail is not killed. */ export function outcomeOf(units, nondeterministic = new Set(), assertions = false) { - if (units.some((unit) => unit.timedOut)) - return { outcome: `timeout`, detail: `run time limit` } + // A run that timed out recorded nothing. A kill in another run still + // decides; failing that, the mutant is `timeout`. + const timeout = units.some((unit) => unit.timedOut) + ? { outcome: `timeout`, detail: `run time limit` } + : undefined const failures = [] for (const unit of units) { + if (unit.timedOut) continue const rows = new Map(unit.rows) for (const message of unit.setupErrors) failures.push({ setup: true, message }) @@ -72,7 +76,7 @@ export function outcomeOf(units, nondeterministic = new Set(), assertions = fals } for (const message of unit.unhandled) failures.push({ message }) } - if (failures.length === 0) return { outcome: `survived` } + if (failures.length === 0) return timeout ?? { outcome: `survived` } const judged = (route) => failures .filter( @@ -90,25 +94,27 @@ export function outcomeOf(units, nondeterministic = new Set(), assertions = fals const reproducible = judged(`reproducible`) const random = judged(`random`) const unregistered = judged(`unknown`) - // Reach agreement alone shows that production took another path, not that - // any behavioral law saw a different result. - const laws = lawsOf(reproducible) - if ( - reproducible.length > 0 && - laws.length > 0 && - laws.every((law) => law === `reach agreement`) + // A behavioral verdict on a reproducible route kills: any law but reach + // agreement, or an ordinary test's assertion. Reach agreement alone shows + // that production took another path, not that any behavioral law saw a + // different result. + const behavioral = (law) => law !== `reach agreement` + const killing = reproducible.filter( + (f) => f.verdicts.some((v) => behavioral(v.law)) || f.verdicts.length === 0, ) + if (killing.length > 0) return { - outcome: `agreement-only-kill`, - by: reproducible.map((f) => f.test), - laws, + outcome: `killed`, + by: killing.map((f) => f.test), + laws: lawsOf(killing).filter(behavioral), + randomAlso: random.length > 0, } + if (timeout) return timeout if (reproducible.length > 0) return { - outcome: `killed`, + outcome: `agreement-only-kill`, by: reproducible.map((f) => f.test), - laws, - randomAlso: random.length > 0, + laws: lawsOf(reproducible), } if (random.length > 0) return { diff --git a/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs index 7405de584f..4b78441e75 100644 --- a/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs @@ -20,13 +20,17 @@ * 1. No owner: `unowned`. No owner test reached it: `unreached`. * 2. Otherwise the records of the owner files whose tests reached it judge * it, except tests whose values differed between the two unmutated runs - * (nondeterministic), which judge nothing. A run that timed out makes it - * `timeout`. + * (nondeterministic), which judge nothing. * 3. A law verdict takes the route active when it fired, or the test's route - * when none was active. On a reproducible route (a fixed seed, a fixed, - * constructed, or enumerated history, a case) it kills: `killed`, unless - * reach agreement is its only law, which makes it an - * `agreement-only-kill`. Failing that, a verdict on the random campaign + * when none was active. A verdict on a reproducible route (a fixed seed, + * a fixed, constructed, or enumerated history, a case) under a behavioral + * law (any law but reach agreement) kills: `killed`, even when another + * owner file's run timed out, and the kill names at least one such law. + * The runner may stop at the first one (maintainer decision, 2026-11-06), + * so a kill names a non-empty part of them. Failing that, a run that + * timed out makes it `timeout`. Failing that, reproducible verdicts under + * reach agreement alone make it an `agreement-only-kill`, which names + * them all. Failing that, a verdict on the random campaign * makes it a `random-only-kill`, and one on an unknown or unregistered * route an `unregistered-kill`. * 4. Failing that, if every failing test timed out, it is `timeout`; a @@ -199,10 +203,13 @@ function modelOutcome(h, mi) { if (targets.length === 0) return withOrdinary({ outcome: `unreached` }) const tests = targets.flatMap((fi) => h.files[fi].tests.map((t, ti) => ({ t, fi, ti }))) const under = ({ t }) => t.under[mi].kind - if (tests.some((x) => under(x) === `runTimeout`)) return { outcome: `timeout` } + const timedOut = tests.some((x) => under(x) === `runTimeout`) const setup = tests.some((x) => under(x) === `setupError`) - // A file that failed to load ran no tests. - const ran = tests.filter((x) => !h.files[x.fi].tests.some((t) => t.under[mi].kind === `setupError`)) + // A file that failed to load, or whose run timed out, ran no tests. + const ran = tests.filter( + (x) => + !h.files[x.fi].tests.some((t) => [`setupError`, `runTimeout`].includes(t.under[mi].kind)), + ) const failing = ran.filter( (x) => !x.t.flaky && [`verdict`, `assertion`, `crash`, `timeoutMessage`].includes(under(x)), ) @@ -211,11 +218,11 @@ function modelOutcome(h, mi) { .filter((x) => under(x) === `verdict` && kindOf(x.t.under[mi].firedAt ?? x.t.route) === kind) .map((x) => x.t.under[mi].law) const kills = verdictsOn(`reproducible`) - if (kills.length > 0) - return { - outcome: kills.every((law) => law === `reach agreement`) ? `agreement-only-kill` : `killed`, - laws: [...new Set(kills)].sort(), - } + const behavioral = kills.filter((law) => law !== `reach agreement`) + if (behavioral.length > 0) + return { outcome: `killed`, laws: [...new Set(behavioral)].sort(), lawsMayBePart: true } + if (timedOut) return { outcome: `timeout` } + if (kills.length > 0) return { outcome: `agreement-only-kill`, laws: [`reach agreement`] } if (verdictsOn(`random`).length > 0) return withOrdinary({ outcome: `random-only-kill` }) if (verdictsOn(`unknown`).length > 0) return { outcome: `unregistered-kill` } if (failing.length > 0 && failing.every((x) => under(x) === `timeoutMessage`)) @@ -264,8 +271,11 @@ function targetsSeen(h, oi, owners) { function ordinaryOutcome(h, mi, files) { const tests = files.flatMap((fi) => h.files[fi].tests.map((t) => ({ t, fi }))) const under = ({ t }) => t.under[mi].kind - if (tests.some((x) => under(x) === `runTimeout`)) return `timeout` - const ran = tests.filter((x) => !h.files[x.fi].tests.some((t) => t.under[mi].kind === `setupError`)) + const timedOut = tests.some((x) => under(x) === `runTimeout`) + const ran = tests.filter( + (x) => + !h.files[x.fi].tests.some((t) => [`setupError`, `runTimeout`].includes(t.under[mi].kind)), + ) const failing = ran.filter((x) => [`verdict`, `assertion`, `crash`, `timeoutMessage`].includes(under(x)), ) @@ -276,12 +286,12 @@ function ordinaryOutcome(h, mi, files) { (under(x) === `assertion` && kindOf(x.t.route) === kind && kind !== `framework`), ) const kills = judgedOn(`reproducible`) - if (kills.length > 0) { - const laws = kills.filter((x) => under(x) === `verdict`).map((x) => x.t.under[mi].law) - return laws.length > 0 && laws.every((law) => law === `reach agreement`) - ? `agreement-only-kill` - : `killed` - } + const behavioral = kills.filter( + (x) => under(x) === `assertion` || x.t.under[mi].law !== `reach agreement`, + ) + if (behavioral.length > 0) return `killed` + if (timedOut) return `timeout` + if (kills.length > 0) return `agreement-only-kill` if (judgedOn(`random`).length > 0) return `random-only-kill` if (judgedOn(`unknown`).length > 0) return `unregistered-kill` if (failing.length > 0 && failing.every((x) => under(x) === `timeoutMessage`)) return `timeout` @@ -418,6 +428,7 @@ async function driver(h, observe) { ...(m.probe ? { probeGuard: true } : {}), })) const fileIndex = (file) => Number(file.match(/f(\d+)\.test/)[1]) + const states = new Set() const results = mutants.map((mutant, mi) => { const owners = new Set( h.mutants[mi].owners @@ -431,6 +442,8 @@ async function driver(h, observe) { ...mutant, ...judgeMutant(mutant, { owners, targets, units, baseline, mutants }), } + if (result.outcome === `killed` && units.some((u) => u.timedOut)) + states.add(`killed beside a timeout`) if (needsOrdinary(result, targets)) result.testOutcome = judgeOrdinary( targets.testTargets.map((file) => unitOf(h, fileIndex(file), mi)), @@ -449,7 +462,6 @@ async function driver(h, observe) { renderReport({ results, run: { id: `r`, inputs: `records` }, excluded: [] }), ), }) - const states = new Set() for (const r of results) { states.add(r.class ?? r.outcome) if (r.testOutcome === `killed`) states.add(`ordinary killed`) @@ -462,14 +474,18 @@ async function driver(h, observe) { function check(h, observations, expected) { const got = observations.find((o) => o.checkpoint === `classified`)?.value if (!got) return violation(`classified`, `nothing was classified`, `completion`) - // A kill names its laws; the model owes laws only for kills. - const view = (o, laws) => - JSON.stringify([o.outcome, o.class, laws ? (o.laws ?? []) : null, o.testOutcome ?? null]) + // A kill names a non-empty part of its behavioral laws (the runner may + // stop at the first); an agreement-only kill names reach agreement. + const view = (o) => JSON.stringify([o.outcome, o.class, o.testOutcome ?? null]) for (const [mi, owed] of expected.outcomes.entries()) { const actual = got.outcomes[mi] - const laws = owed.laws !== undefined - if (view(actual, laws) !== view(owed, laws)) - return violation(`classified`, `mutant m${mi}: ${view(actual, laws)}, owed ${view(owed, laws)}`, `classification`, { actual, expected: owed }) + const lawsFit = + owed.laws === undefined || + (owed.lawsMayBePart + ? (actual.laws ?? []).length > 0 && actual.laws.every((law) => owed.laws.includes(law)) + : JSON.stringify(actual.laws ?? []) === JSON.stringify(owed.laws)) + if (view(actual) !== view(owed) || !lawsFit) + return violation(`classified`, `mutant m${mi}: ${view(actual)} laws ${JSON.stringify(actual.laws ?? [])}, owed ${view(owed)} laws ${JSON.stringify(owed.laws ?? [])}`, `classification`, { actual, expected: owed }) } if (JSON.stringify(got.summary) !== JSON.stringify(expected.summary)) return violation(`classified`, `the summary does not list exactly the owed gaps, unowned warnings, and counts`, `report completeness`, { actual: got.summary, expected: expected.summary }) @@ -520,6 +536,7 @@ const required = [ `unreached`, `timeout`, `killed`, + `killed beside a timeout`, `agreement-only-kill`, `random-only-kill`, `unregistered-kill`, @@ -603,6 +620,25 @@ const constructed = [ effect: { kind: `verdict` }, }), single(`a crash`, [`crashed`], { effect: { kind: `crash` } }), + { + // One owner file kills; another's run times out. The kill decides. + name: `a kill beside a timed-out run`, + enters: [`killed beside a timeout`], + build: () => { + const base = single(`x`, [], { effect: { kind: `verdict` } }).build() + return { + ...base, + files: [ + base.files[0], + { + ...base.files[0], + tests: [{ ...base.files[0].tests[0], under: four({ kind: `runTimeout`, law: `L`, firedAt: undefined }) }], + }, + ], + mutants: [{ ...base.mutants[0], owners: [0, 1] }], + } + }, + }, single(`a failing calibration`, [`framework-error`], { test: { route: `calibration` }, effect: { kind: `crash` }, diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 9659155491..a3d50f2050 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -296,6 +296,19 @@ const witness = [ // read it, as an oracle that counts reads does. The CLI's capture must // not read it either: the harness may not change what a test observes. { id: `w_getter`, owners: [`a`], body: [push], tests: [judging(`a`, `fixed seed`, 1, { getter: true })] }, + // Two tests that share state: the first, which calls another function, + // sets it; the second calls this function only when it is set. A runner + // that runs only the tests that reached a site must not change what the + // second sees. + { + id: `w_shared`, + owners: [`a`], + body: [kill], + tests: [ + { ...judging(`a`, `fixed seed`, 1, { judges: false, setsShared: true }), fn: `w_kill` }, + judging(`a`, `fixed seed`, 5, { judges: false, readsShared: true }), + ], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -328,6 +341,7 @@ const captureWitness = new Set([ `w_campaigns`, `w_soft`, `w_getter`, + `w_shared`, ]) // ## Rendering and layout @@ -653,7 +667,7 @@ function resolveHistory(history) { fn: generatedIds[t.fn % generatedIds.length], })), ...functions.flatMap((fn) => - fn.generated ? [] : fn.tests.map((t) => ({ ...t, fn: fn.id })), + fn.generated ? [] : fn.tests.map((t) => ({ ...t, fn: t.fn ?? fn.id })), ), ] const pushes = functions.filter((fn) => fn.file === SOURCE).flatMap((fn) => @@ -1254,6 +1268,8 @@ function oracleTestFile(plan, file) { `import * as sut from '../src/sut'`, ...(file === `b` ? [`import { strict } from './strict'`] : []), `function declareOracle(_parts: { owns: Array }): void {}`, + // State that tests of this file share (the `w_shared` witness). + `const shared = { value: 0 }`, ...owns, `const g = globalThis as { __oracleRoute?: (route: string) => void; __oracleObserve?: (c: string, v: unknown) => void; __oracleVerdict?: (v: { property: string; law: string; checkpoint: string }) => void }`, ] @@ -1279,7 +1295,10 @@ function oracleTestFile(plan, file) { return [ ...(t.registered ? [` g.__oracleRoute?.('${part.route}')`] : []), ` const ${out}: Array = []`, - ` ${callFor(t.fn)}(${part.input}, ${out})`, + ...(t.setsShared ? [` shared.value = 5`] : []), + t.readsShared + ? ` if (shared.value === 5) ${callFor(t.fn)}(${part.input}, ${out})` + : ` ${callFor(t.fn)}(${part.input}, ${out})`, t.channel === `expect` ? ` expect(${out}).toBeDefined()` : ` g.__oracleObserve?.('trace', ${out})`, diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 69785edf90..96df194bd3 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -491,6 +491,8 @@ async function driver(plan, observe) { states.add(`killed under an unnamed law`) if (m.function === `w_getter` && m.outcome === `killed`) states.add(`a getter the capture left unread`) + if (m.function === `w_shared` && m.class === `generator-gap`) + states.add(`a test that depends on an earlier test`) if (m.function === `w_campaigns` && m.outcome === `random-only-kill`) states.add(`second campaign's route attributed`) if (m.function === `w_soft` && m.outcome === `random-only-kill`) @@ -920,6 +922,7 @@ const required = [ `second campaign's route attributed`, `a soft verdict keeps its campaign's route`, `a getter the capture left unread`, + `a test that depends on an earlier test`, ...incrementalStates, ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `brokenOwner`] From 2dfbe47159c3f6b32dce8c249e206cef238fd377 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 09:54:18 -0700 Subject: [PATCH 043/168] fix(oracle-gaps): stop only after a failing test, and keep tests that can see the mutant Two speed changes were unsound, found by comparing a cold run with the slower full run on offline-transactions. - The runner stopped a file at any verdict, also one in a test that caught its failure and passed, so a later killing test never ran. It now stops only after a failed test with a behavioral verdict on a reproducible route, the same rule the classification uses. - Running only the tests that reached a site dropped later tests that see the mutant through state a reaching test changed. A mutant now runs every test from the first reaching one, leaving out only the tests before it, under the same reproduction guard. - A selection check runs once even when several contexts ask for it. Temporary: ORACLE_GAPS_PROFILE=1 writes phase and run spans to /profile.jsonl, for the cold-run analysis. Laws: scripts.gap-report, with witnesses w_caught/w_late (a caught verdict between a reaching test and the judging one) and w_prop (a test that judges state an earlier test stored). RED: w_prop delete-call: comparison-gap, owed killed (on 1d0c7cb6e). GREEN: 26 of 26. Mutant: stopping on any verdict gives w_late delete-call: comparison-gap, owed killed. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 23 ++-- scripts/oracle-gaps/check.mjs | 123 ++++++++++++++---- .../oracle-gaps/gap-report-model-oracle.mjs | 43 +++++- .../gap-report-oracle.property.test.mjs | 10 +- 4 files changed, 165 insertions(+), 34 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index bf7f0dd150..ca888e2b52 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -331,13 +331,13 @@ function recordAssertion() { checkpoint: `assertion`, route, }) - noteKill(`(unnamed)`, route) } // A mutant run may stop at its first kill (maintainer decision, -// 2026-11-06): once a behavioral law, any but reach agreement, rejects the -// mutant on a reproducible route, the file's remaining tests are skipped. -// The runner also runs no further file (check.mjs). +// 2026-11-06): once a test fails with a verdict under a behavioral law, any +// but reach agreement, on a reproducible route, the file's remaining tests +// are skipped. The runner also runs no further file (check.mjs). A verdict +// in a test that passes kills nothing, as in the classification. const reproducibleRoutes = new Set([ `fixed seed`, `fixed history`, @@ -346,10 +346,6 @@ const reproducibleRoutes = new Set([ `enumerated`, ]) let stopped = false -function noteKill(law, at) { - if (state.stopOnKill && law !== `reach agreement` && reproducibleRoutes.has(at)) - stopped = true -} let currentTask // Each law violation the oracle framework raises, so the runner classifies @@ -362,7 +358,6 @@ globalThis.__oracleVerdict = ({ property, law, checkpoint }) => { // several campaigns is attributed per campaign. const key = `${property}\u0000${law}\u0000${checkpoint}\u0000${route}` verdicts.set(key, { property, law, checkpoint, route }) - noteKill(law, route) } // The route the framework registered for the current test (index.mjs routeOf). let route @@ -396,6 +391,16 @@ beforeEach((context) => { afterEach((context) => { inTest = false + if ( + state.stopOnKill && + (context.task.result?.state === `fail` || + (context.task.result?.errors?.length ?? 0) > 0) && + [...verdicts.values()].some( + (v) => + v.law !== `reach agreement` && reproducibleRoutes.has(v.route ?? route), + ) + ) + stopped = true if (!state.capture) return appendFileSync( state.capture, diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index a8de953e49..59433cc4b4 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -70,6 +70,17 @@ const ownFiles = [ `../../packages/db/tests/oracle-property.ts`, ].map((file) => join(here, file)) +// Temporary profiling: with ORACLE_GAPS_PROFILE set, each phase and each +// run appends one JSON line to /profile.jsonl. +let profileFile +const span = (kind, started, fields = {}) => { + if (!profileFile) return + appendFileSync( + profileFile, + `${JSON.stringify({ kind, ms: Date.now() - started, at: started, ...fields })}\n`, + ) +} + /** A unit's first failure: a failing test and its error, or why it did not run. */ function failureOf(unit) { if (unit.timedOut) return `its run timed out` @@ -269,17 +280,42 @@ export default mergeConfig(base, { ]) clearTimeout(timer) const seconds = (Date.now() - started) / 1000 + const profiled = { + file: file.split(`/`).at(-1), + mutant, + select: select?.length ?? null, + limitMs, + } if (result === undefined) { + const cancelled = Date.now() await Promise.race([ context.cancelCurrentRun(`keyboard-input`).catch(() => undefined), sleep(5000), ]) + span(`cancel`, cancelled, profiled) + span(`run`, started, { ...profiled, timedOut: true }) rmSync(capture, { force: true }) return { file, timedOut: true, seconds } } // The result also lists test modules from earlier runs, with their old // results. Only this run's file counts. const modules = result.testModules.filter((m) => m.moduleId === file) + if (profileFile) { + const d = modules[0]?.diagnostic?.() ?? {} + const states = modules.flatMap((m) => + [...m.children.allTests()].map((t) => t.result().state), + ) + span(`run`, started, { + ...profiled, + tests: states.length, + ran: states.filter((x) => x !== `skipped`).length, + environment: Math.round(d.environmentSetupDuration ?? 0), + prepare: Math.round(d.prepareDuration ?? 0), + collect: Math.round(d.collectDuration ?? 0), + setup: Math.round(d.setupDuration ?? 0), + testsMs: Math.round(d.duration ?? 0), + }) + } const rows = [] if (existsSync(capture)) { for (const line of readFileSync(capture, `utf8`).split(`\n`).filter(Boolean)) { @@ -337,6 +373,16 @@ export default mergeConfig(base, { async function check(options, store, log = console.error) { const authored = options.mutants ? loadAuthored(options.mutants) : [] mkdirSync(options.out, { recursive: true }) + profileFile = process.env.ORACLE_GAPS_PROFILE + ? join(options.out, `profile.jsonl`) + : undefined + if (profileFile) rmSync(profileFile, { force: true }) + const checkStarted = Date.now() + let phase = Date.now() + const endPhase = (name, fields) => { + span(`phase`, phase, { name, ...fields }) + phase = Date.now() + } // The state file is the fallback for setups that cannot `inject`. process.env.ORACLE_GAPS_STATE = join(options.out, `state.json`) const settings = { @@ -359,7 +405,9 @@ export default mergeConfig(base, { } while (contexts.length < options.contexts) contexts.push(await warmContext(settings)) + endPhase(`warm contexts`, { contexts: contexts.length }) for (const context of contexts) await globSpecs(context) + endPhase(`find test files`) // 1. The static half (analysis.mjs): instrumented sources, mutants, // declarations, and ownership. A broken declaration refuses the run. @@ -372,6 +420,7 @@ export default mergeConfig(base, { const previous = [...globalThis.__oracleGapsSchemata.keys()] globalThis.__oracleGapsSchemata = instrumented invalidateChanged(new Set([...previous, ...instrumented.keys(), ...lastServed.keys()])) + endPhase(`static analysis`, { mutants: mutants.length }) // The global inputs of every unit. const lockfile = lockfileOf(pkg) @@ -395,7 +444,10 @@ export default mergeConfig(base, { async function unit(context, file, spec, runOptions = {}) { const id = sha1(JSON.stringify([globalKey, file, spec, runOptions.select ?? null])) const row = store.get(id) - if (row && row.inputs.every(([path, hash]) => servedHash(path) === hash)) { + const looked = Date.now() + const valid = row && row.inputs.every(([path, hash]) => servedHash(path) === hash) + span(`lookup`, looked, { file: file.split(`/`).at(-1), spec, hit: Boolean(valid) }) + if (valid) { evidence.reused += 1 if (!row.unit.timedOut) fileInputs.set(file, row.unit.inputs) return row.unit @@ -455,6 +507,7 @@ export default mergeConfig(base, { } let files = [...fileSpecs.keys()] const first = await runAcross(files, 1) + endPhase(`unmutated pass 1`, { files: files.length }) // An oracle file that fails with no mutant active cannot judge the code // it owns, so the run refuses rather than report that code as unowned. const failingOwners = [...first.failed.keys()].filter((file) => @@ -476,6 +529,7 @@ export default mergeConfig(base, { } } const second = await runAcross(files, 2) + endPhase(`unmutated pass 2`, { files: files.length }) const baseline = baselineOf(first.rows, second.rows, oracleFiles) const { nondeterministic } = baseline log( @@ -508,44 +562,56 @@ export default mergeConfig(base, { // Results are appended as they finish, so a stopped survey keeps them. const partial = join(options.out, `results.jsonl`) rmSync(partial, { force: true }) - // Whether running only `keys` of a file, with no mutant active, - // reproduces what those tests recorded when the whole file ran. A test - // can depend on state that an earlier test set, so a selection is used - // only when it reproduces the full run. + // The tests of a file that a mutant must run: every test from the first + // one that reached its site. A later test can see the mutant through + // state that a reaching test changed, so only the tests before the + // first reaching one are left out, and only when running the rest with + // no mutant active reproduces what they recorded when the whole file + // ran: a later test can depend on state that a left-out test set. const selectionChecks = new Map() const selectionFor = async (slot, file, keys) => { const all = [...baseline.firstRows.keys()].filter( (k) => fileOf(k) === file && !baseline.firstRows.get(k).background, ) - const selection = [...new Set(keys)].filter((k) => !k.endsWith(` > (background)`)).sort() - if (selection.length === 0 || selection.length >= all.length) return null + const reaching = new Set(keys) + if (keys.some((k) => k === `${file} > (background)`)) return null + const first = all.findIndex((k) => reaching.has(k)) + if (first <= 0) return null + const selection = all.slice(first) const id = `${file}\0${selection.join(`\0`)}` - if (!selectionChecks.has(id)) { - const run = await unit(slot.context, file, `selected`, { - select: selection, - limitMs: limitFor(file), - }) - if (run.timedOut) await replace(slot) - const rows = new Map(run.rows ?? []) + // The check runs once per selection, even when several contexts ask + // for it at the same time. + if (!selectionChecks.has(id)) selectionChecks.set( id, - !run.timedOut && - outcomeOf([run]).outcome === `survived` && - selection.every( - (k) => - rows.get(k)?.hash === baseline.firstRows.get(k)?.hash && - rows.get(k)?.observed === baseline.firstRows.get(k)?.observed, - ), + (async () => { + const run = await unit(slot.context, file, `selected`, { + select: selection, + limitMs: limitFor(file), + }) + if (run.timedOut) await replace(slot) + const rows = new Map(run.rows ?? []) + return ( + !run.timedOut && + outcomeOf([run]).outcome === `survived` && + selection.every( + (k) => + rows.get(k)?.hash === baseline.firstRows.get(k)?.hash && + rows.get(k)?.observed === baseline.firstRows.get(k)?.observed, + ) + ) + })(), ) - } - return selectionChecks.get(id) ? selection : null + return (await selectionChecks.get(id)) ? selection : null } // A cancelled run can leave work behind, so its context is replaced. const replace = async (slot) => { + const started = Date.now() await Promise.race([slot.context.close().catch(() => undefined), sleep(10_000)]) slot.context = await warmContext(settings) await globSpecs(slot.context) contexts[slot.index] = slot.context + span(`replace context`, started, { slot: slot.index }) } // The units of one mutant against some files, one file at a time and // only the tests that reached it. The runner stops at the first kill: a @@ -566,6 +632,7 @@ export default mergeConfig(base, { return done } async function evaluate(mutant, slot) { + const evaluated = Date.now() mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) const owners = ownersOf(mutant) const targets = targetsOf(mutant, baseline, owners, oracleFiles) @@ -587,6 +654,13 @@ export default mergeConfig(base, { await unitsFor(slot, targets.testTargets, targets.testKeys, mutant.number), ) results.push(result) + span(`mutant`, evaluated, { + id: mutant.id, + slot: slot.index, + outcome: result.class ?? result.outcome, + files: units.length, + targets: targets.oracleTargets.length, + }) appendFileSync(partial, `${JSON.stringify(result)}\n`) log( `${mutant.id} L${mutant.line} ${mutant.operator} ${result.outcome}${result.class ? ` (${result.class})` : ``}${result.testOutcome ? ` [ordinary: ${result.testOutcome}]` : ``}`, @@ -600,6 +674,7 @@ export default mergeConfig(base, { }), ) results.sort((a, b) => a.number - b.number || a.source.localeCompare(b.source)) + endPhase(`operator mutants`, { mutants: results.length }) // 6. Authored mutants, one at a time in the first context. Each one // serves a patched copy of its source, which every context in this @@ -657,6 +732,7 @@ export default mergeConfig(base, { Object.assign(result, judgeAuthored(units, baseline)) log(`${id} ${result.outcome}${result.class ? ` (${result.class})` : ``}`) } + endPhase(`authored mutants`, { mutants: authored.length }) // Keys go into every output, so an agent reads them from the JSON // results. assignKeys(results) @@ -681,6 +757,7 @@ export default mergeConfig(base, { 1, ), ) + span(`check`, checkStarted, { evidence }) const summary = renderReport({ results, run: runIdentity, diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index a3d50f2050..d66a90392a 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -309,6 +309,32 @@ const witness = [ judging(`a`, `fixed seed`, 5, { judges: false, readsShared: true }), ], }, + // A test that fires a verdict and catches its failure passes, so it kills + // nothing, and the tests after it must still run. It sits between a test + // that reaches `w_late` and the one that judges it, so a runner that + // leaves out the tests before the first reaching one still runs it. + { id: `w_caught`, owners: [`a`], body: [push], tests: [] }, + { + id: `w_late`, + owners: [`a`], + body: [push], + tests: [ + judging(`a`, `fixed seed`, 1, { judges: false }), + { ...judging(`a`, `fixed seed`, 1, { judges: false, caughtVerdict: true }), fn: `w_caught` }, + judging(`a`, `fixed seed`, 1), + ], + }, + // A test that never calls this function judges what an earlier test + // stored from it, so it sees the mutant through shared state. + { + id: `w_prop`, + owners: [`a`], + body: [push], + tests: [ + judging(`a`, `fixed seed`, 7, { judges: false, storesShared: true }), + judging(`a`, `fixed seed`, 7, { judgesShared: true }), + ], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -342,6 +368,9 @@ const captureWitness = new Set([ `w_soft`, `w_getter`, `w_shared`, + `w_caught`, + `w_late`, + `w_prop`, ]) // ## Rendering and layout @@ -1270,6 +1299,7 @@ function oracleTestFile(plan, file) { `function declareOracle(_parts: { owns: Array }): void {}`, // State that tests of this file share (the `w_shared` witness). `const shared = { value: 0 }`, + `const stored: { value: Array } = { value: [] }`, ...owns, `const g = globalThis as { __oracleRoute?: (route: string) => void; __oracleObserve?: (c: string, v: unknown) => void; __oracleVerdict?: (v: { property: string; law: string; checkpoint: string }) => void }`, ] @@ -1298,7 +1328,18 @@ function oracleTestFile(plan, file) { ...(t.setsShared ? [` shared.value = 5`] : []), t.readsShared ? ` if (shared.value === 5) ${callFor(t.fn)}(${part.input}, ${out})` - : ` ${callFor(t.fn)}(${part.input}, ${out})`, + : t.judgesShared + ? ` ${out}.push(...stored.value)` + : ` ${callFor(t.fn)}(${part.input}, ${out})`, + ...(t.storesShared ? [` stored.value = [...${out}]`] : []), + ...(t.caughtVerdict + ? [ + ` try {`, + ` g.__oracleVerdict?.({ property: 'fixture-${file}', law: 'L', checkpoint: 'caught' })`, + ` throw new Error('caught')`, + ` } catch {}`, + ] + : []), t.channel === `expect` ? ` expect(${out}).toBeDefined()` : ` g.__oracleObserve?.('trace', ${out})`, diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 96df194bd3..e0af18419f 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -424,7 +424,9 @@ async function driver(plan, observe) { try { const files = fixtureFiles(plan) const cli = runCli(dir, files) - observe(`cli`, cli) + // How many units were computed or reused says how the result was + // produced, not what it is, so it is not observed. + observe(`cli`, { ...cli, evidence: undefined }) // Reach comes from what the CLI reported, mapped onto the plan. const states = new Set() // Each kind of declaration fault is its own state, so each refusal has @@ -493,6 +495,10 @@ async function driver(plan, observe) { states.add(`a getter the capture left unread`) if (m.function === `w_shared` && m.class === `generator-gap`) states.add(`a test that depends on an earlier test`) + if (m.function === `w_late` && m.outcome === `killed`) + states.add(`a kill after a caught verdict`) + if (m.function === `w_prop` && m.outcome === `killed`) + states.add(`a kill through state an earlier test stored`) if (m.function === `w_campaigns` && m.outcome === `random-only-kill`) states.add(`second campaign's route attributed`) if (m.function === `w_soft` && m.outcome === `random-only-kill`) @@ -923,6 +929,8 @@ const required = [ `a soft verdict keeps its campaign's route`, `a getter the capture left unread`, `a test that depends on an earlier test`, + `a kill after a caught verdict`, + `a kill through state an earlier test stored`, ...incrementalStates, ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `brokenOwner`] From f1bdf92279ccbc516951bf3ef5a839368888f2cd Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 10:07:23 -0700 Subject: [PATCH 044/168] feat(oracle-gaps): run ordinary test files only with --ordinary Ordinary (non-oracle) tests never close a gap; they only report whether they would have caught a survivor. They took 13% of a cold run on offline-transactions. They now run only when asked (maintainer decision). Law: scripts.gap-report law 4: ordinary tests run only with --ordinary, and then a survivor or unreached mutant they reached reports their outcome. Reach: "ordinary tests run when asked" and "ordinary tests left out by default", each entered by a construction in every campaign. RED: the unchanged CLI had no flag ("the authored mutant is undefined": the parser took the next argument as its value). GREEN: 26 of 26. Mutant: ordinary files that always run give f0 delete-call "reported, owed not reported". Co-authored-by: Isaac --- scripts/oracle-gaps/args.mjs | 5 +- scripts/oracle-gaps/check.mjs | 5 +- .../oracle-gaps/gap-report-model-oracle.mjs | 42 ++++++++++--- .../gap-report-oracle.property.test.mjs | 61 ++++++++++++++++--- scripts/oracle-gaps/index.mjs | 6 +- 5 files changed, 98 insertions(+), 21 deletions(-) diff --git a/scripts/oracle-gaps/args.mjs b/scripts/oracle-gaps/args.mjs index ff482d7d9a..d8482a4d1d 100644 --- a/scripts/oracle-gaps/args.mjs +++ b/scripts/oracle-gaps/args.mjs @@ -4,7 +4,7 @@ import { realpathSync } from 'node:fs' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' -const flags = new Set([`daemon`, `stop-daemon`]) +const flags = new Set([`daemon`, `stop-daemon`, `ordinary`]) /** * The options in `argv`. Paths are resolved against `cwd`, the caller's * working directory, which the daemon receives with each request. @@ -24,6 +24,9 @@ export function parseArgs(argv, cwd = process.cwd()) { return { pkg, daemon: args.daemon === true, + // Ordinary (non-oracle) test files run only when asked: they never close + // a gap, and only report whether they would have caught a survivor. + ordinary: args.ordinary === true, // Each warm context runs one mutant at a time with one worker. Measured on // main's LocalStorage owners (320 mutants): 2 contexts 297 s, 4 185 s, // 8 137 s, with identical oracle-tier classes. diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 59433cc4b4..88cd05f072 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -505,7 +505,10 @@ export default mergeConfig(base, { ) return { rows, failed, seconds: Math.max(0, ...units.map((u) => u.seconds)) } } - let files = [...fileSpecs.keys()] + // Without --ordinary, only oracle files run. + let files = [...fileSpecs.keys()].filter( + (file) => options.ordinary || oracleFiles.has(file), + ) const first = await runAcross(files, 1) endPhase(`unmutated pass 1`, { files: files.length }) // An oracle file that fails with no mutant active cannot judge the code diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index d66a90392a..33797581b4 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -197,6 +197,8 @@ const histories = fc.record({ `reformat`, ), // Most histories have no steps, which keeps the campaign's cost down. + // Whether the run asks for the ordinary tests' outcome (`--ordinary`). + ordinary: fc.boolean(), steps: fc.oneof( { weight: 5, arbitrary: fc.constant(null) }, { @@ -740,6 +742,7 @@ function resolveHistory(history) { fault: history.fault, stability: history.stability, steps: history.steps ?? [], + ordinary: history.ordinary === true, history, source, librarySource, @@ -1197,16 +1200,35 @@ function model(plan) { ? `generator-gap` : `no-observed-difference` } - const mutants = sites.map((site) => ({ - key: site.key, - source: site.file, - function: site.function, - line: site.line, - operator: site.operator, - ordinal: site.ordinal, - probe: site.probe, - kind: site.probe ? undefined : kindOf(site), - })) + // The ordinary tests' outcome is reported, for information, only when the + // run asks for it, and only for a survivor or an unreached mutant that an + // ordinary test reached. + const ordinaryReaches = (site) => + plan.tests + .filter((t) => t.file === `c`) + .some((t) => interpret(plan, t.fn, t.input, sites).reached.has(site.id)) + const survivorKinds = new Set([ + `comparison-gap`, + `generator-gap`, + `no-observed-difference`, + `indeterminate`, + `unreached`, + ]) + const mutants = sites.map((site) => { + const kind = site.probe ? undefined : kindOf(site) + return { + key: site.key, + source: site.file, + function: site.function, + line: site.line, + operator: site.operator, + ordinal: site.ordinal, + probe: site.probe, + kind, + ordinaryOutcome: + plan.ordinary && survivorKinds.has(kind) && ordinaryReaches(site), + } + }) const authored = plan.authored && (!plan.authored.applies diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index e0af18419f..5f28444ed0 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -74,7 +74,10 @@ * test's observation or asserted value changed, a `generator-gap` when an * `if` on its line never took one of its outcomes in an owner test, and * `no-observed-difference` otherwise. A test in a file that is not an - * oracle file never changes any of this. Only `killed` closes an owned + * oracle file never changes any of this, and runs only when the run asks + * for it (`--ordinary`, maintainer decision of 2026-11-06): then a + * survivor or an unreached mutant that an ordinary test reached reports + * the ordinary tests' outcome, for information. Only `killed` closes an owned * mutant; every other class is a gap. * 5. **Authored mutants.** An authored mutant whose text occurs exactly once * is judged by law 4 against the owners of the function it edits, without @@ -229,7 +232,11 @@ const cleanEnv = () => * daemon (law 9). A file of an earlier state that this state lacks is * removed. */ -function runCli(dir, files, { seed = 1, daemon = false, cli = here } = {}) { +function runCli( + dir, + files, + { seed = 1, daemon = false, cli = here, ordinary = false } = {}, +) { writeFixture(dir, files) const out = join(dir, daemon ? `out-daemon` : `out`) rmSync(out, { recursive: true, force: true }) @@ -253,6 +260,7 @@ function runCli(dir, files, { seed = 1, daemon = false, cli = here } = {}) { `--out`, path(out), ] + if (ordinary) args.push(`--ordinary`) if (files[AUTHORED] !== undefined) args.push(`--mutants`, path(join(dir, `mutants`))) const env = cleanEnv() @@ -311,6 +319,7 @@ function runCli(dir, files, { seed = 1, daemon = false, cli = here } = {}) { class: r.class, laws: r.laws, probe: r.probeGuard === true, + ordinaryOutcome: r.testOutcome !== undefined, })), ...readSummary(summary), } @@ -362,10 +371,10 @@ function incremental(dir, plan, cli, observe, states) { ) const files = fixtureFiles(resolveHistory(state.history), state) const key = fingerprint(files, state) - if (!cold.has(key)) - cold.set(key, runCli(dir, files, { seed: state.seed, cli: copy })) + const how = { seed: state.seed, cli: copy, ordinary: plan.ordinary } + if (!cold.has(key)) cold.set(key, runCli(dir, files, how)) const coldRun = cold.get(key) - const daemon = runCli(dir, files, { seed: state.seed, daemon: true, cli: copy }) + const daemon = runCli(dir, files, { ...how, daemon: true }) observe(`incremental`, { step, cold: { @@ -423,7 +432,7 @@ async function driver(plan, observe) { const dir = mkdtempSync(join(here, `.scratch-`)) try { const files = fixtureFiles(plan) - const cli = runCli(dir, files) + const cli = runCli(dir, files, { ordinary: plan.ordinary }) // How many units were computed or reused says how the result was // produced, not what it is, so it is not observed. observe(`cli`, { ...cli, evidence: undefined }) @@ -534,9 +543,13 @@ async function driver(plan, observe) { states.add(`no observed difference`) if (m.class === `indeterminate`) states.add(`indeterminate`) } + if (plan.ordinary && cli.mutants.some((m) => m.ordinaryOutcome)) + states.add(`ordinary tests run when asked`) + if (!plan.ordinary && plan.tests.some((t) => t.file === `c`)) + states.add(`ordinary tests left out by default`) if (plan.stability !== `none` && cli.exit === 0) { const moved = { ...files, [SOURCE]: edited(plan) } - observe(`stability`, runCli(dir, moved)) + observe(`stability`, runCli(dir, moved, { ordinary: plan.ordinary })) states.add(`stability rerun`) } if (plan.steps.length > 0) incremental(dir, plan, cli, observe, states) @@ -669,6 +682,14 @@ function check(plan, observations, expected) { { actual: got, expected: owed }, ) } + for (const [owed, got] of pairs) + if (!owed.probe && got.ordinaryOutcome !== owed.ordinaryOutcome) + return violation( + `cli`, + `${site(got)}: the ordinary tests' outcome is ${got.ordinaryOutcome ? `` : `not `}reported, owed ${owed.ordinaryOutcome ? `reported` : `not reported`}`, + `ordinary tier`, + { actual: got, expected: owed }, + ) const authored = cli.mutants.find((m) => m.authored) const authoredKind = authored && (authored.class ?? authored.outcome) if (authoredKind !== expected.authored) @@ -931,10 +952,13 @@ const required = [ `a test that depends on an earlier test`, `a kill after a caught verdict`, `a kill through state an earlier test stored`, + `ordinary tests run when asked`, + `ordinary tests left out by default`, ...incrementalStates, ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `brokenOwner`] const separate = [ + `ordinary tests left out by default`, `stale authored mutant`, `ambiguous authored mutant`, ...faults.map((fault) => `refused ${fault}`), @@ -970,6 +994,7 @@ oracleProperty({ authored: { stmt: 0, kind: `apply` }, stability: `insertGuard`, steps: null, + ordinary: true, }), }, { @@ -1045,6 +1070,28 @@ oracleProperty({ ], }), }, + { + // An ordinary test exists, and the run does not ask for it. + name: `ordinary tests left out by default`, + enters: [`ordinary tests left out by default`], + // One owned function with no oracle test, so it is unreached, and + // an ordinary test that calls it: asked, its outcome is reported. + build: (generated) => ({ + ...generated, + functions: [[{ kind: `push`, quote: `'` }]], + containers: [], + overloads: [], + owners: [`a`], + wholeFile: `none`, + witness: false, + tests: [{ ...judging(`c`, `case`, 1), fn: 0 }], + authored: null, + fault: `none`, + stability: `none`, + ordinary: false, + steps: null, + }), + }, ...faults.map((fault) => ({ name: `a declaration fault: ${fault}`, enters: [`refused ${fault}`], diff --git a/scripts/oracle-gaps/index.mjs b/scripts/oracle-gaps/index.mjs index b8638be762..1b8e81fb2b 100644 --- a/scripts/oracle-gaps/index.mjs +++ b/scripts/oracle-gaps/index.mjs @@ -27,8 +27,9 @@ // function no oracle owns is `unowned`, a warning. Survivors are sorted // into `comparison-gap`, `generator-gap`, `no-observed-difference`, and // `indeterminate`. -// 5. Runs a survivor against the ordinary tests that reach it, for -// information only: an ordinary test never closes a gap. +// 5. With --ordinary, runs a survivor against the ordinary tests that reach +// it, for information only: an ordinary test never closes a gap. Without +// it, ordinary test files do not run at all. // // The pipeline lives in check.mjs, as a session that keeps Vitest warm. A // cold run opens one session for one check. With --daemon, the CLI sends the @@ -47,6 +48,7 @@ // Usage: // node scripts/oracle-gaps/index.mjs --package --source // [--daemon] (check through the package's daemon) +// [--ordinary] (also run ordinary test files, for information) // [--seed ] [--mutants ] [--out ] [--only ] // [--diff-base ] // [--test-timeout ] (default 15000; also bounds hooks and teardown. From 2783782bd28891c6b1f25ec2b2ec877258eb1224 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 10:07:25 -0700 Subject: [PATCH 045/168] test(offline-transactions): settle interruption histories with setImmediate Each settle step yielded ten setTimeout(0) turns, and the timer clamp made this the slowest owner in the gap check (about 10 s per run, 60% of a cold run's work). setImmediate yields the same ten macrotask turns without the clamp. Production's only timer, the retry timer, waits its own delay either way. Evidence: the fixed-seed reach is identical (dispose 16/25/4, leadership 26/28/3, offline 24/30), all 13 tests pass, and the file's tests take 6.8 s instead of 12.0 s. Co-authored-by: Isaac --- .../tests/interruption-settlement-oracle.property.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts index 803e828c2b..29fe4b52b3 100644 --- a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts @@ -188,10 +188,15 @@ async function driveInterruption( // A permanent provider failure logs once by design; this law ignores logs. const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) const schedule = timing ? scheduleFrom(timing.choices) : undefined + // Settling yields ten macrotask turns. `setImmediate` yields a turn + // without the timer clamp of `setTimeout(0)`, which made this oracle the + // slowest owner in the gap check: the same reach at the fixed seed, about + // half the time. Production's only timer, the retry timer, waits its own + // delay either way. const settle = async () => { for (let index = 0; index < 10; index++) { await schedule?.waitIdle() - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setImmediate(resolve)) } } let phase: Phase = `before` From 1a694f1451b6c07711ed0de40988b592cd3c6c12 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 10:16:43 -0700 Subject: [PATCH 046/168] perf(oracle-gaps): judge authored mutants in every context at once Authored mutants ran one at a time in the first context, because their patched source was served through one map that every context read: 134 s of a 315 s cold run on offline-transactions. Each context now has its own wrapper config and serves only its own patch, with the served hashes and invalidation tracked per context, so authored mutants spread across all contexts like operator mutants. Law: scripts.gap-report law 5, each authored mutant judged against the original source with only its own edit; a second authored mutant per history (`secondAuthored`) and the construction "two authored mutants" (one killed, one a comparison gap, in the same source file). GREEN: 26 of 26 (the serial CLI was also green on the new construction). Mutant: parallel authored mutants through one shared map judge a1 `survived`, owed `comparison-gap`. Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 137 ++++++++++++------ .../oracle-gaps/gap-report-model-oracle.mjs | 123 +++++++++------- .../gap-report-oracle.property.test.mjs | 71 +++++++-- 3 files changed, 220 insertions(+), 111 deletions(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 88cd05f072..509fdf184f 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -140,12 +140,18 @@ export async function openSession(pkg) { ) // Each process uses its own harness files, so a cold run beside the daemon // cannot remove the daemon's. - const wrapper = join(pkg, `.oracle-gaps-${process.pid}.vitest.config.ts`) const harness = join(pkg, `.oracle-gaps-${process.pid}.capture-setup.mjs`) copyFileSync(join(here, `capture-setup.mjs`), harness) - writeFileSync( - wrapper, - `import { realpathSync } from 'node:fs' + // One wrapper config per context. Each serves its own context's patched + // sources, so authored mutants run in every context at once without one + // seeing another's patch. + const wrappers = new Map() + const wrapperFor = (index) => { + if (!wrappers.has(index)) { + const wrapper = join(pkg, `.oracle-gaps-${process.pid}-${index}.vitest.config.ts`) + writeFileSync( + wrapper, + `import { realpathSync } from 'node:fs' import { mergeConfig } from 'vitest/config' import base from './vite.config.ts' export default mergeConfig(base, { @@ -154,18 +160,23 @@ export default mergeConfig(base, { enforce: 'pre', load(id) { const path = id.split('?')[0] - // An authored mutant's patched source, or else the instrumented source - // of the current check, both set by the runner in this process. + // This context's authored patch, or else the instrumented source of the + // current check, both set by the runner in this process. try { const real = realpathSync(path) - return globalThis.__oracleGapsServe?.get(real) ?? globalThis.__oracleGapsSchemata?.get(real) + return globalThis.__oracleGapsServe?.[${index}]?.get(real) ?? globalThis.__oracleGapsSchemata?.get(real) } catch { return undefined } }, }], test: { setupFiles: ['./${harness.split(`/`).at(-1)}'] }, }) `, - ) + ) + wrappers.set(index, wrapper) + } + return wrappers.get(index) + } + globalThis.__oracleGapsServe = [] // Vitest resolves test-file filters and parts of its project setup against // the working directory, so the runner works from the package directory. process.chdir(pkg) @@ -174,23 +185,29 @@ export default mergeConfig(base, { // context last served, so a changed file is invalidated in the warm // contexts before it is read again. globalThis.__oracleGapsSchemata = new Map() - const servedHash = (path) => { + const servedHash = (path, index) => { const text = - globalThis.__oracleGapsServe?.get(path) ?? + globalThis.__oracleGapsServe[index]?.get(path) ?? globalThis.__oracleGapsSchemata.get(path) return text === undefined ? hashOf(path) : sha1(text) } + // Per context: the hash of what it last served for each path. const lastServed = new Map() + const servedBy = (index) => { + if (!lastServed.has(index)) lastServed.set(index, new Map()) + return lastServed.get(index) + } + const indexOf = new Map() let contexts = [] let contextsKey let contextsConfig const contextSpecs = new Map() let runCounter = 0 - async function warmContext(settings) { + async function warmContext(settings, index) { const context = await createVitest(`test`, { root: pkg, - config: wrapper, + config: wrapperFor(index), watch: false, maxWorkers: 1, // Every test in a file runs, so a kill is attributed to each route @@ -209,6 +226,8 @@ export default mergeConfig(base, { }) // Vitest 3 calls this step `init`; Vitest 4 renamed it `standalone`. await (context.standalone ? context.standalone() : context.init()) + indexOf.set(context, index) + lastServed.delete(index) return context } async function globSpecs(context) { @@ -233,7 +252,7 @@ export default mergeConfig(base, { // This process's harness files are generated from this CLI's own files, // which every key covers, and their names carry the process id, so they // are left out of every key and input. - const ownHarness = (path) => path === wrapper || path === harness + const ownHarness = (path) => path.startsWith(`${pkg}/.oracle-gaps-${process.pid}`) // The files the Vite config loaded, for the global key. const configFiles = () => (contexts[0]?.vite?.config?.configFileDependencies ?? []).filter( @@ -241,12 +260,16 @@ export default mergeConfig(base, { ) /** Invalidates every path whose served text changed since a context read it. */ - function invalidateChanged(paths) { - for (const path of paths) { - const hash = servedHash(path) - if (lastServed.get(path) === hash) continue - for (const context of contexts) context.invalidateFile(path) - lastServed.set(path, hash) + function invalidateChanged(paths, only) { + for (const context of only ? [only] : contexts) { + const index = indexOf.get(context) + const served = servedBy(index) + for (const path of paths) { + const hash = servedHash(path, index) + if (served.get(path) === hash) continue + context.invalidateFile(path) + served.set(path, hash) + } } } @@ -399,12 +422,12 @@ export default mergeConfig(base, { ) { await closeContexts() for (let i = 0; i < options.contexts; i += 1) - contexts.push(await warmContext(settings)) + contexts.push(await warmContext(settings, i)) contextsKey = JSON.stringify(settings) contextsConfig = configHash() } while (contexts.length < options.contexts) - contexts.push(await warmContext(settings)) + contexts.push(await warmContext(settings, contexts.length)) endPhase(`warm contexts`, { contexts: contexts.length }) for (const context of contexts) await globSpecs(context) endPhase(`find test files`) @@ -419,7 +442,13 @@ export default mergeConfig(base, { ) const previous = [...globalThis.__oracleGapsSchemata.keys()] globalThis.__oracleGapsSchemata = instrumented - invalidateChanged(new Set([...previous, ...instrumented.keys(), ...lastServed.keys()])) + invalidateChanged( + new Set([ + ...previous, + ...instrumented.keys(), + ...[...lastServed.values()].flatMap((served) => [...served.keys()]), + ]), + ) endPhase(`static analysis`, { mutants: mutants.length }) // The global inputs of every unit. @@ -442,10 +471,12 @@ export default mergeConfig(base, { // none, so it is stored under these: a mutant changes no import. const fileInputs = new Map() async function unit(context, file, spec, runOptions = {}) { + const index = indexOf.get(context) const id = sha1(JSON.stringify([globalKey, file, spec, runOptions.select ?? null])) const row = store.get(id) const looked = Date.now() - const valid = row && row.inputs.every(([path, hash]) => servedHash(path) === hash) + const valid = + row && row.inputs.every(([path, hash]) => servedHash(path, index) === hash) span(`lookup`, looked, { file: file.split(`/`).at(-1), spec, hit: Boolean(valid) }) if (valid) { evidence.reused += 1 @@ -461,8 +492,8 @@ export default mergeConfig(base, { if (!result.timedOut && spec.startsWith(`unmutated:`)) fileInputs.set(file, result.inputs) if (paths) { - const inputs = paths.map((path) => [path, servedHash(path)]) - for (const [path, hash] of inputs) lastServed.set(path, hash) + const inputs = paths.map((path) => [path, servedHash(path, index)]) + for (const [path, hash] of inputs) servedBy(index).set(path, hash) store.put({ id, inputs, unit: result }) } return result @@ -611,7 +642,7 @@ export default mergeConfig(base, { const replace = async (slot) => { const started = Date.now() await Promise.race([slot.context.close().catch(() => undefined), sleep(10_000)]) - slot.context = await warmContext(settings) + slot.context = await warmContext(settings, slot.index) await globSpecs(slot.context) contexts[slot.index] = slot.context span(`replace context`, started, { slot: slot.index }) @@ -679,10 +710,12 @@ export default mergeConfig(base, { results.sort((a, b) => a.number - b.number || a.source.localeCompare(b.source)) endPhase(`operator mutants`, { mutants: results.length }) - // 6. Authored mutants, one at a time in the first context. Each one - // serves a patched copy of its source, which every context in this - // process would see. The copy is not instrumented, so an authored mutant - // runs against every owner of the function its edit lands in. + // 6. Authored mutants, spread across the contexts. Each serves a patched + // copy of its source only to the context that runs it. The copy is not + // instrumented, so an authored mutant runs against every owner of the + // function its edit lands in, whole files, still stopping at its first + // kill. + const authoredJobs = [] for (const mutant of authored) { const id = `authored:${mutant.id}` if (options.only && !options.only.has(id)) continue @@ -708,33 +741,49 @@ export default mergeConfig(base, { } result.function = owned.functionAt(mutant.source, patched.line) const owners = owned.of(mutant.source, result.function) - const targets = [...owners].filter((file) => fileSpecs.has(file)) if (owners.size === 0) { result.outcome = `unowned` continue } - const slot = { context: contexts[0], index: 0 } - globalThis.__oracleGapsServe = new Map([[path, patched.text]]) - invalidateChanged([path]) - // An authored copy has no reach record, so whole files run; the runner - // still stops at the first kill. + authoredJobs.push({ + id, + path, + patched, + result, + targets: [...owners].filter((file) => fileSpecs.has(file)), + }) + } + const judgeAuthoredJob = async (slot, job) => { + const serve = globalThis.__oracleGapsServe + serve[slot.index] = new Map([[job.path, job.patched.text]]) + invalidateChanged([job.path], slot.context) const units = [] - for (const file of targets) { + for (const file of job.targets) { units.push( - await unit(slot.context, file, id, { + await unit(slot.context, file, job.id, { mutant: -1, limitMs: limitFor(file), stopOnKill: true, }), ) - if (units.at(-1).timedOut) await replace(slot) + if (units.at(-1).timedOut) { + await replace(slot) + invalidateChanged([job.path], slot.context) + } if (outcomeOf(units, baseline.nondeterministic).outcome === `killed`) break } - globalThis.__oracleGapsServe = undefined - invalidateChanged([path]) - Object.assign(result, judgeAuthored(units, baseline)) - log(`${id} ${result.outcome}${result.class ? ` (${result.class})` : ``}`) + serve[slot.index] = undefined + invalidateChanged([job.path], slot.context) + Object.assign(job.result, judgeAuthored(units, baseline)) + log(`${job.id} ${job.result.outcome}${job.result.class ? ` (${job.result.class})` : ``}`) } + await Promise.all( + contexts.map(async (context, index) => { + const slot = { context, index } + for (let job = authoredJobs.shift(); job; job = authoredJobs.shift()) + await judgeAuthoredJob(slot, job) + }), + ) endPhase(`authored mutants`, { mutants: authored.length }) // Keys go into every output, so an agent reads them from the JSON // results. @@ -772,7 +821,7 @@ export default mergeConfig(base, { async function close() { await closeContexts() - rmSync(wrapper, { force: true }) + for (const wrapper of wrappers.values()) rmSync(wrapper, { force: true }) rmSync(harness, { force: true }) } diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 33797581b4..f23fec0005 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -199,6 +199,9 @@ const histories = fc.record({ // Most histories have no steps, which keeps the campaign's cost down. // Whether the run asks for the ordinary tests' outcome (`--ordinary`). ordinary: fc.boolean(), + // A second authored mutant, on the next statement, so that two patched + // sources are judged in the same run and neither may see the other's. + secondAuthored: fc.boolean(), steps: fc.oneof( { weight: 5, arbitrary: fc.constant(null) }, { @@ -706,37 +709,48 @@ function resolveHistory(history) { .filter((stmt) => [`push`, `if`, `mixed`, `elseIf`].includes(stmt.kind)) .map((stmt) => ({ fn: fn.id, stmt })), ) + const authoredFrom = (spec, id) => { + const target = pushes[spec.stmt % pushes.length] + const find = + spec.kind === `stale` + ? `out.push('nope');` + : spec.kind === `ambiguous` + ? `out.push(` + : pushText(target.stmt) + return { + id, + fn: target.fn, + label: target.stmt.label, + find, + replace: + spec.kind === `apply` + ? pushText(target.stmt).replace( + `s${target.stmt.label}`, + `s${target.stmt.label}!`, + ) + : `out.push('x');`, + applies: source.split(find).length - 1 === 1, + line: source.slice(0, source.indexOf(find)).split(`\n`).length, + kind: spec.kind, + } + } const authored = history.authored && pushes.length > 0 - ? (() => { - const target = pushes[history.authored.stmt % pushes.length] - const find = - history.authored.kind === `stale` - ? `out.push('nope');` - : history.authored.kind === `ambiguous` - ? `out.push(` - : pushText(target.stmt) - return { - fn: target.fn, - label: target.stmt.label, - find, - replace: - history.authored.kind === `apply` - ? pushText(target.stmt).replace( - `s${target.stmt.label}`, - `s${target.stmt.label}!`, - ) - : `out.push('x');`, - applies: source.split(find).length - 1 === 1, - line: source.slice(0, source.indexOf(find)).split(`\n`).length, - kind: history.authored.kind, - } - })() + ? authoredFrom(history.authored, `a1`) : undefined + const authoredList = authored + ? [ + authored, + ...(history.secondAuthored + ? [authoredFrom({ stmt: history.authored.stmt + 1, kind: `apply` }, `a2`)] + : []), + ] + : [] return { functions, tests, authored, + authoredList, wholeFile: history.wholeFile, template: history.template, fault: history.fault, @@ -1229,15 +1243,19 @@ function model(plan) { plan.ordinary && survivorKinds.has(kind) && ordinaryReaches(site), } }) - const authored = - plan.authored && - (!plan.authored.applies + // Each authored mutant is judged alone, against the original source with + // only its own edit. + const authoredAll = plan.authoredList.map((a) => ({ + id: a.id, + key: `${SOURCE}#authored:${a.id}`, + kind: !a.applies ? `does-not-apply` - : owners(plan.authored.fn).size === 0 + : owners(a.fn).size === 0 ? `unowned` - : judge(plan, ownerTests(plan.authored.fn), sites, { - authored: plan.authored.label, - })) + : judge(plan, ownerTests(a.fn), sites, { authored: a.label }), + line: a.applies ? a.line : undefined, + })) + const authored = authoredAll[0]?.kind const isGap = (kind) => kind !== undefined && ![`killed`, `unowned`].includes(kind) const count = (kind) => mutants.filter((m) => m.kind === kind).length @@ -1246,31 +1264,27 @@ function model(plan) { ...mutants .filter((m) => isGap(m.kind)) .map((m) => ({ key: m.key, kind: m.kind, line: String(m.line) })), - ...(isGap(authored) - ? [ - { - key: `${SOURCE}#authored:a1`, - kind: authored, - line: String(plan.authored.applies ? plan.authored.line : undefined), - }, - ] - : []), + ...authoredAll + .filter((a) => isGap(a.kind)) + .map((a) => ({ key: a.key, kind: a.kind, line: String(a.line) })), ].sort((a, b) => (a.key < b.key ? -1 : 1)) const gaps = entries.map((e) => e.key) return { mutants, authored, - authoredLine: plan.authored?.applies ? plan.authored.line : undefined, + authoredAll, + authoredLine: authoredAll[0]?.line, gaps, entries, unowned: [ ...mutants.filter((m) => m.kind === `unowned`).map((m) => m.key), - ...(authored === `unowned` ? [`${SOURCE}#authored:a1`] : []), + ...authoredAll.filter((a) => a.kind === `unowned`).map((a) => a.key), ].sort(), counts: { - mutants: mutants.length + (plan.authored ? 1 : 0), - authored: plan.authored ? 1 : 0, - killed: count(`killed`) + (authored === `killed` ? 1 : 0), + mutants: mutants.length + authoredAll.length, + authored: authoredAll.length, + killed: + count(`killed`) + authoredAll.filter((a) => a.kind === `killed`).length, gaps: gaps.length, probe: mutants.filter((m) => m.probe).length, }, @@ -1500,15 +1514,16 @@ function fixtureFiles(plan, state = initialState(plan)) { `d`, ) } - if (plan.authored) - files[AUTHORED] = JSON.stringify({ - id: `a1`, - source: SOURCE, - find: plan.authored.find, - replace: plan.authored.replace, - intent: `pushes a different label`, - author: `gap-report oracle`, - }) + for (const a of plan.authoredList) + files[a.id === `a1` ? AUTHORED : `mutants/authored-mutant-${a.id}.json`] = + JSON.stringify({ + id: a.id, + source: SOURCE, + find: a.find, + replace: a.replace, + intent: `pushes a different label`, + author: `gap-report oracle`, + }) return files } diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 5f28444ed0..e1af64b10e 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -449,6 +449,16 @@ async function driver(plan, observe) { ) for (const m of cli.mutants) { if (m.authored) { + const others = cli.mutants.filter((x) => x.authored && x.key !== m.key) + if ( + others.some( + (x) => + x.outcome !== `does-not-apply` && + m.outcome !== `does-not-apply` && + (x.class ?? x.outcome) !== (m.class ?? m.outcome), + ) + ) + states.add(`two authored mutants judged apart`) states.add( m.outcome !== `does-not-apply` ? `authored mutant` @@ -690,15 +700,31 @@ function check(plan, observations, expected) { `ordinary tier`, { actual: got, expected: owed }, ) - const authored = cli.mutants.find((m) => m.authored) - const authoredKind = authored && (authored.class ?? authored.outcome) - if (authoredKind !== expected.authored) + // Each authored mutant, by its key: its id, whatever its file is called. + const authoredGot = cli.mutants.filter((m) => m.authored) + if (authoredGot.length !== expected.authoredAll.length) return violation( `cli`, - `the authored mutant is ${authoredKind}, owed ${expected.authored}`, + `${authoredGot.length} authored mutants are reported, owed ${expected.authoredAll.length}`, `authored mutants`, - { actual: authoredKind, expected: expected.authored }, ) + for (const owed of expected.authoredAll) { + const got = authoredGot.find((m) => m.key === owed.key) + const kind = got && (got.class ?? got.outcome) + if (kind !== owed.kind) + return violation( + `cli`, + `the authored mutant ${owed.id} is ${kind}, owed ${owed.kind}`, + `authored mutants`, + { actual: kind, expected: owed.kind }, + ) + if (owed.line !== undefined && got.line !== owed.line) + return violation( + `cli`, + `the authored mutant ${owed.id} is reported at line ${got.line}, owed ${owed.line}`, + `authored mutants`, + ) + } for (const [owed, got] of pairs) if (got.key !== owed.key) return violation( @@ -707,14 +733,6 @@ function check(plan, observations, expected) { `keys`, { actual: got.key, expected: owed.key }, ) - if (authored && authored.key !== `${SOURCE}#authored:a1`) - return violation(`cli`, `the authored key is ${authored.key}`, `keys`) - if (expected.authoredLine !== undefined && authored.line !== expected.authoredLine) - return violation( - `cli`, - `the authored mutant is reported at line ${authored.line}, owed ${expected.authoredLine}`, - `authored mutants`, - ) if ( cli.gaps.join(`\n`) !== expected.gaps.join(`\n`) || JSON.stringify(cli.entries) !== JSON.stringify(expected.entries) || @@ -954,11 +972,13 @@ const required = [ `a kill through state an earlier test stored`, `ordinary tests run when asked`, `ordinary tests left out by default`, + `two authored mutants judged apart`, ...incrementalStates, ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `brokenOwner`] const separate = [ `ordinary tests left out by default`, + `two authored mutants judged apart`, `stale authored mutant`, `ambiguous authored mutant`, ...faults.map((fault) => `refused ${fault}`), @@ -1070,6 +1090,31 @@ oracleProperty({ ], }), }, + { + // Two authored mutants in one source file, one killed and one a + // comparison gap: a run that let one see the other's patch would + // judge them alike. + name: `two authored mutants`, + enters: [`two authored mutants judged apart`], + build: (generated) => ({ + ...generated, + functions: [[{ kind: `push`, quote: `'` }], [{ kind: `push`, quote: `'` }]], + containers: [], + overloads: [], + owners: [`a`, `a`], + wholeFile: `none`, + witness: false, + tests: [ + { ...judging(`a`, `fixed seed`, 1), fn: 0 }, + { ...judging(`a`, `fixed seed`, 1, { judges: false }), fn: 1 }, + ], + authored: { stmt: 0, kind: `apply` }, + secondAuthored: true, + fault: `none`, + stability: `none`, + steps: null, + }), + }, { // An ordinary test exists, and the run does not ask for it. name: `ordinary tests left out by default`, From 7b01c05b4246da7bb37e208ebf43d80a4d15c2ad Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 10:22:00 -0700 Subject: [PATCH 047/168] fix(oracle-gaps): keep the 10 s base of the hang limit A 5 s base cut off a mutant run of transaction-settlement-oracle that slows down but still ends and kills (resetRetryDelays delete-call), so a kill became a timeout. With the 10 s base, a cold run on offline-transactions classifies every mutant as the full reference run does, except that a kill now outranks a timeout (26 mutants). Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 509fdf184f..cc68fecf92 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -511,7 +511,11 @@ export default mergeConfig(base, { // detect anything, and it may read source text that instrumentation // changes. Each file's unmutated duration sets a mutant run's time limit. const fileSeconds = new Map() - const limitFor = (file) => 5_000 + 3000 * (fileSeconds.get(file) ?? 10) + // A mutant can slow a run that still ends, for example by lengthening a + // retry backoff. A 5 s base cut off such a run in offline-transactions + // and turned a kill into a timeout, so the base stays at 10 s; hangs are + // cached, so it costs only cold runs. + const limitFor = (file) => 10_000 + 3000 * (fileSeconds.get(file) ?? 10) const runAcross = async (files, pass) => { const parts = contexts.map(() => []) files.forEach((file, index) => parts[index % contexts.length].push(file)) From c85b45b0e4843d17fcbd511752fa167739be9d93 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 10:36:53 -0700 Subject: [PATCH 048/168] perf(oracle-gaps): default to up to 16 contexts, and profile history reach The default context count is now the machine's parallelism minus 2, up to 16. On offline-transactions' FIFO executor (18 cores) a cold run takes 91 s with 16 contexts and 124 s with 8, with identical classes. Temporary profiling (ORACLE_GAPS_PROFILE): the capture setup counts each test's fast-check histories and, per site, the histories that hit it. Measured on the same run: the histories that reach a survivor's site are 86% of its reaching tests' histories (median 76%), so replaying only the reaching histories would save little there. Co-authored-by: Isaac --- scripts/oracle-gaps/args.mjs | 6 ++-- scripts/oracle-gaps/capture-setup.mjs | 52 ++++++++++++++++++++++++++- scripts/oracle-gaps/check.mjs | 8 +++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/scripts/oracle-gaps/args.mjs b/scripts/oracle-gaps/args.mjs index d8482a4d1d..38ec0239ee 100644 --- a/scripts/oracle-gaps/args.mjs +++ b/scripts/oracle-gaps/args.mjs @@ -28,10 +28,10 @@ export function parseArgs(argv, cwd = process.cwd()) { // a gap, and only report whether they would have caught a survivor. ordinary: args.ordinary === true, // Each warm context runs one mutant at a time with one worker. Measured on - // main's LocalStorage owners (320 mutants): 2 contexts 297 s, 4 185 s, - // 8 137 s, with identical oracle-tier classes. + // offline-transactions' FIFO executor (163 mutants, 18 cores): 8 contexts + // 124 s, 16 contexts 91 s, with identical classes. contexts: Number( - args.contexts ?? Math.min(8, Math.max(2, availableParallelism() - 2)), + args.contexts ?? Math.min(16, Math.max(2, availableParallelism() - 2)), ), sources: args.source.split(`,`), seed: args.seed, diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index ca888e2b52..52f4cf3424 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -113,7 +113,52 @@ let inTest = false let currentFile const background = new Set() const active = state.mutant -const hit = (id) => (inTest ? hits : background).add(id) +const hit = (id) => { + ;(inTest ? hits : background).add(id) + historyHits?.add(id) +} + +// Temporary profiling (ORACLE_GAPS_PROFILE): how many fast-check histories +// each test ran, and in how many of them each site was hit, to measure what +// replaying only the reaching histories would save. +let histories = 0 +let siteHistories = new Map() +let historyHits = null +if (fc && process.env.ORACLE_GAPS_PROFILE) { + const samples = [ + () => fc.property(fc.constant(0), () => true), + () => fc.asyncProperty(fc.constant(0), async () => true), + ] + for (const sample of samples) { + const prototype = Object.getPrototypeOf(sample()) + const run = prototype.run + if (typeof run !== `function` || run.counted) continue + const counted = function (...args) { + const outer = historyHits + historyHits = new Set() + const done = () => { + if (inTest) { + histories += 1 + for (const id of historyHits) + siteHistories.set(id, (siteHistories.get(id) ?? 0) + 1) + } + historyHits = outer + } + let result + try { + result = run.apply(this, args) + } catch (error) { + done() + throw error + } + if (typeof result?.then === `function`) return result.finally(done) + done() + return result + } + counted.counted = true + prototype.run = counted + } +} const swapped = { '===': `!==`, '!==': `===`, @@ -385,6 +430,8 @@ beforeEach((context) => { hits = new Set() outcomes = new Map() verdicts = new Map() + histories = 0 + siteHistories = new Map() route = undefined inTest = true }) @@ -413,6 +460,9 @@ afterEach((context) => { outcomes: [...outcomes], verdicts: [...verdicts.values()], route, + ...(process.env.ORACLE_GAPS_PROFILE + ? { histories, siteHistories: [...siteHistories] } + : {}), })}\n`, ) hits = new Set() diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index cc68fecf92..46772d75ed 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -546,6 +546,14 @@ export default mergeConfig(base, { ) const first = await runAcross(files, 1) endPhase(`unmutated pass 1`, { files: files.length }) + if (profileFile) + for (const [key, row] of first.rows) + if (row.histories) + span(`baseline histories`, Date.now(), { + key, + histories: row.histories, + sites: row.siteHistories, + }) // An oracle file that fails with no mutant active cannot judge the code // it owns, so the run refuses rather than report that code as unowned. const failingOwners = [...first.failed.keys()].filter((file) => From e9095717cb340ac756c4176b622442e518959745 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 10:58:03 -0700 Subject: [PATCH 049/168] perf(oracle-gaps): keep several versions of each stored run The evidence store kept one version of each run, so undoing an edit to a test or source file made the earlier evidence stale, and a recheck reran it (111 runs of fifo-retry-oracle in one measured case). Each run now keeps up to four versions, newest first, each under the inputs it read, as a build system keeps several verifying traces. Law: scripts.gap-report law 9; new reach state "a revert of a file edit reran nothing", with a revert after the test edit in the edit sequence. RED: the construction did not enter that state on the one-version store. GREEN: 26 of 26. Mutant: a one-version store is the unchanged code, which fails the reach. Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 16 +++++++---- scripts/oracle-gaps/evidence.mjs | 28 +++++++++++++------ .../oracle-gaps/gap-report-model-oracle.mjs | 2 +- .../gap-report-oracle.property.test.mjs | 22 ++++++++++++++- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 46772d75ed..9ec80e1a2b 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -473,15 +473,19 @@ export default mergeConfig(base, { async function unit(context, file, spec, runOptions = {}) { const index = indexOf.get(context) const id = sha1(JSON.stringify([globalKey, file, spec, runOptions.select ?? null])) - const row = store.get(id) + // A run is reused when every input of one of its stored versions is + // unchanged; undoing an edit finds the version from before it. const looked = Date.now() - const valid = - row && row.inputs.every(([path, hash]) => servedHash(path, index) === hash) + const valid = store + .get(id) + ?.variants.find((variant) => + variant.inputs.every(([path, hash]) => servedHash(path, index) === hash), + ) span(`lookup`, looked, { file: file.split(`/`).at(-1), spec, hit: Boolean(valid) }) if (valid) { evidence.reused += 1 - if (!row.unit.timedOut) fileInputs.set(file, row.unit.inputs) - return row.unit + if (!valid.unit.timedOut) fileInputs.set(file, valid.unit.inputs) + return valid.unit } const result = await runUnit(context, options.out, file, { seed: options.seed ?? null, @@ -494,7 +498,7 @@ export default mergeConfig(base, { if (paths) { const inputs = paths.map((path) => [path, servedHash(path, index)]) for (const [path, hash] of inputs) servedBy(index).set(path, hash) - store.put({ id, inputs, unit: result }) + store.put(id, { inputs, unit: result }) } return result } diff --git a/scripts/oracle-gaps/evidence.mjs b/scripts/oracle-gaps/evidence.mjs index 8a08959fcb..ed6746229e 100644 --- a/scripts/oracle-gaps/evidence.mjs +++ b/scripts/oracle-gaps/evidence.mjs @@ -12,12 +12,24 @@ import { fileURLToPath, pathToFileURL } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) +// A run keeps a few versions, newest first, each under the inputs it read, +// as a build system keeps several verifying traces: an edit and its undo +// both find their evidence. +const VERSIONS = 4 +const withVersion = (row, variant) => [ + variant, + ...(row?.variants ?? []).filter( + (old) => JSON.stringify(old.inputs) !== JSON.stringify(variant.inputs), + ), +].slice(0, VERSIONS) + /** A store that lives for one check. */ export function memoryStore() { const rows = new Map() return { get: (id) => rows.get(id), - put: (row) => rows.set(row.id, row), + put: (id, variant) => + rows.set(id, { id, variants: withVersion(rows.get(id), variant) }), } } @@ -45,20 +57,20 @@ export async function diskStore(dir) { id: `evidence-units`, getKey: (row) => row.id, persistence: createNodeSQLitePersistence({ database }), - schemaVersion: 1, + schemaVersion: 2, }), ) await units.preload() let pending = [] return { get: (id) => units.get(id), - put(row) { - const transaction = units.has(row.id) - ? units.update(row.id, (draft) => { - draft.inputs = row.inputs - draft.unit = row.unit + put(id, variant) { + const variants = withVersion(units.get(id), variant) + const transaction = units.has(id) + ? units.update(id, (draft) => { + draft.variants = variants }) - : units.insert(row) + : units.insert({ id, variants }) pending.push(transaction.when(`settled`)) }, async flush() { diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index f23fec0005..3e571037b3 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -208,7 +208,7 @@ const histories = fc.record({ weight: 1, arbitrary: fc.array(fc.constantFrom(...edits), { minLength: 1, - maxLength: edits.length, + maxLength: edits.length + 2, }), }, ), diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index e1af64b10e..a01472b41d 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -360,6 +360,10 @@ function incremental(dir, plan, cli, observe, states) { }, ]) const trail = [initialState(plan)] + // The steps that made each state after the first, so a revert knows what + // it undid. + const made = [] + let reverted let previous = cli const checkAt = (step) => { const state = trail.at(-1) @@ -402,6 +406,15 @@ function incremental(dir, plan, cli, observe, states) { states.add(`incremental check reused some evidence and recomputed some`) if (step === `restart` && reused > 0) states.add(`evidence reused after a daemon restart`) + // Undoing an edit to a file returns to a state the daemon has seen, so + // it reruns nothing. + if ( + step === `revert` && + [`test`, `source`, `prepend`, `owns`, `authored`, `helper`, `newOracle`].includes(reverted) && + reused > 0 && + computed === 0 + ) + states.add(`a revert of a file edit reran nothing`) // An edit that changes the cold result is one a stale cache could miss. if (step !== `start` && result(previous) !== result(coldRun)) states.add(`edit ${step} changed the cold result`) @@ -419,7 +432,11 @@ function incremental(dir, plan, cli, observe, states) { if (step === `restart`) stop() else if (step === `revert`) { if (trail.length > 1) trail.pop() - } else trail.push(applyEdit(trail.at(-1), step)) + reverted = made.pop() + } else { + trail.push(applyEdit(trail.at(-1), step)) + made.push(step) + } checkAt(step) } } finally { @@ -911,6 +928,7 @@ const incrementalStates = [ `incremental check reused evidence`, `incremental check reused some evidence and recomputed some`, `evidence reused after a daemon restart`, + `a revert of a file edit reran nothing`, ...edits .filter((edit) => edit !== `restart`) .map((edit) => `edit ${edit} changed the cold result`), @@ -1080,6 +1098,8 @@ oracleProperty({ `seed`, `revert`, `test`, + `revert`, + `test`, `source`, `prepend`, `owns`, From d95ca1f7d59ad46054f6e62d18dbd7b414227ab9 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 11:16:12 -0700 Subject: [PATCH 050/168] perf(oracles): wait one event-loop turn with setImmediate The schedule's settle step and `withinTurns` waited a turn with `setTimeout(0)`, which Node clamps to at least 1 ms. A schedule-heavy history was then mostly idle: a CPU profile of interruption-settlement-oracle showed the worker idle 72% of the time, in the timer list. `setImmediate` yields the same turn without the clamp; a browser, which has no `setImmediate`, keeps `setTimeout(0)`. Evidence, each run with the old and the new framework: - fixed-seed reach identical for every user: the self-oracle, the scheduler oracle, optimistic-transaction, optimistic-composition, optimistic-history-publication, collection-sync-reentrancy, local-storage write-order and value-fidelity, persisted optimistic-history, and interruption-settlement; - every test passes (2,085 in db, 12 in persisted, 13 in offline); - test time: interruption-settlement 6.66 s -> 0.25 s, the db group 3.21 s -> 1.73 s. Co-authored-by: Isaac --- packages/db/tests/oracle-property.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 1b744b8c6e..1a9d9c9630 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -234,6 +234,18 @@ export function forwardCut( * wait for work that production schedules on a delayed timer; a driver for * such work controls that timer. */ +/** + * One event-loop turn. `setTimeout(0)` waits at least a millisecond in Node, + * which made schedule-heavy histories mostly idle; `setImmediate` yields the + * same turn without that clamp. A browser has no `setImmediate`. + */ +const nextTurn = () => + new Promise((resolve) => + typeof setImmediate === `function` + ? setImmediate(resolve) + : setTimeout(resolve, 0), + ) + export function withinTurns( promise: Promise, turns: number, @@ -246,7 +258,7 @@ export function withinTurns( ) const expiry = (async () => { for (let turn = 0; turn < turns && !state.settled; turn += 1) - await new Promise((resolve) => setTimeout(resolve, 0)) + await nextTurn() return false })() return Promise.race([done, expiry]) @@ -288,7 +300,7 @@ export function scheduleFrom(choices: ReadonlyArray): DataScheduler { let step = 0 // Continuations of a released task run on microtasks; one macrotask turn // lets them finish, and lets them schedule further tasks. - const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + const settle = nextTurn const waitOne = async () => { if (pending.length === 0) return const queues = [...new Set(pending.map((task) => task.queue))] From 13196bcd8dcd572e4249074097e11ee5d5790dbb Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 11:21:01 -0700 Subject: [PATCH 051/168] test(offline-transactions): yield harness turns with setImmediate `turn()` yielded a macrotask with `setTimeout(0)`, which Node clamps to at least 1 ms, so the leadership-replay and transaction-settlement oracles were mostly idle. `setImmediate` yields the same macrotask without the clamp. Evidence: all 185 tests of its four users pass, with their reach assertions; their test time drops from 2.96 s to 0.50 s. Co-authored-by: Isaac --- packages/offline-transactions/tests/harness.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/offline-transactions/tests/harness.ts b/packages/offline-transactions/tests/harness.ts index d6e9b9a552..ba5737a628 100644 --- a/packages/offline-transactions/tests/harness.ts +++ b/packages/offline-transactions/tests/harness.ts @@ -290,5 +290,9 @@ export function gate() { return { promise, resolve } } -/** Yields one macrotask, after the microtasks that the step queued. */ -export const turn = () => new Promise((resolve) => setTimeout(resolve, 0)) +/** + * Yields one macrotask, after the microtasks that the step queued. + * `setImmediate` yields it without the 1 ms clamp of `setTimeout(0)`, which + * left schedule-heavy oracles mostly idle. + */ +export const turn = () => new Promise((resolve) => setImmediate(resolve)) From 8344c07656bc0b64f596fa76dfbe7ff0d4fa6ec8 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 12:08:41 -0700 Subject: [PATCH 052/168] feat(oracle-gaps): code run at module load reaches every test of the file A mutant in code that runs only when a test file loads the module was `indeterminate` in the CLI, a class the law keeps for mutants that only nondeterministic tests could judge. Code that runs at load can affect any test of a file that loads it, so every deterministic owner test of that file now counts as reaching it (maintainer decision, 2026-11-06). The CLI oracle's grammar now has module state, ahead of a runner that keeps modules loaded between mutants: `w_memo` caches its label in a module-level map, and `w_load` runs at module load into `loaded`, which `w_loaded` reads. Reach: "a mutant of code run at module load killed" and "a module-level cache judged afresh". The model's tokenizer reads `...` as one token, as TypeScript's scanner does. Law: scripts.gap-report law 4 and scripts.gap-report-classify rule 5. RED: the classify oracle owed generator-gap where the CLI said indeterminate; the end-to-end oracle found delete-call indeterminate where the old model said unreached. GREEN: 26 of 26. Co-authored-by: Isaac --- scripts/oracle-gaps/classify.mjs | 9 +- ...p-report-classify-oracle.property.test.mjs | 11 +- .../oracle-gaps/gap-report-model-oracle.mjs | 101 ++++++++++++++++-- .../gap-report-oracle.property.test.mjs | 12 ++- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/scripts/oracle-gaps/classify.mjs b/scripts/oracle-gaps/classify.mjs index 296cc90d28..ff0493aa5c 100644 --- a/scripts/oracle-gaps/classify.mjs +++ b/scripts/oracle-gaps/classify.mjs @@ -242,11 +242,16 @@ export function judgeMutant(mutant, { owners, targets, units, baseline, mutants } if (result.outcome !== `survived`) return result const rows = new Map(units.flatMap((u) => u.rows ?? [])) - // Only owner tests that reach the site can judge it. + // Only owner tests that reach the site can judge it. Code that ran when a + // file loaded the module reaches every test of that file (maintainer + // decision, 2026-11-06). const reaching = new Set(targets.oracleKeys) + const loadedIn = new Set( + targets.oracleKeys.filter((k) => k.endsWith(` > (background)`)).map(fileOf), + ) const compared = [...rows.keys()].filter( (k) => - reaching.has(k) && + (reaching.has(k) || loadedIn.has(fileOf(k))) && !rows.get(k).background && !baseline.nondeterministic.has(k), ) diff --git a/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs index 4b78441e75..b878866414 100644 --- a/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-classify-oracle.property.test.mjs @@ -39,7 +39,9 @@ * determinism test) makes it a `framework-error`; and errors outside any * test make it a `setup-error`. * 5. With no failure it survived: a `comparison-gap` when a reaching, - * deterministic owner test's asserted or observed value changed; + * deterministic owner test's asserted or observed value changed (code + * that ran when a file loaded the module reaches every test of that file, + * maintainer decision of 2026-11-06); * `indeterminate` when no such test judged it; a `generator-gap` when an * `if` on its line, reached by an owner test, never took one of its * outcomes in the owners' tests; and `no-observed-difference` otherwise. @@ -232,8 +234,11 @@ function modelOutcome(h, mi) { if (failing.length > 0) return { outcome: `framework-error` } const unhandled = ran.some((x) => under(x) === `unhandled`) if (setup || unhandled) return { outcome: `setup-error` } - // A survivor. Only reaching, deterministic owner tests judge it. - const compared = ran.filter((x) => x.t.reaches[mi] && !x.t.flaky) + // A survivor. Only reaching, deterministic owner tests judge it; code that + // ran when a file loaded the module reaches every test of that file. + const compared = ran.filter( + (x) => (x.t.reaches[mi] || h.files[x.fi].background[mi]) && !x.t.flaky, + ) if (compared.some((x) => [`changed`, `observedChanged`].includes(under(x)))) return withOrdinary({ outcome: `survived`, class: `comparison-gap` }) if (compared.length === 0) return withOrdinary({ outcome: `survived`, class: `indeterminate` }) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 3e571037b3..134b1cde44 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -314,6 +314,24 @@ const witness = [ judging(`a`, `fixed seed`, 5, { judges: false, readsShared: true }), ], }, + // Module state. `w_memo` caches its label in a module-level map, so a + // runner that keeps modules loaded between mutants would answer a later + // mutant from an earlier run's cache. `w_load` runs when the module loads, + // and `w_loaded` reads what it wrote, so its mutants take effect only in a + // freshly loaded module. + { + id: `w_memo`, + owners: [`a`], + body: [{ kind: `memoSet` }, { kind: `memoGet` }], + tests: [judging(`a`, `fixed seed`, 1)], + }, + { id: `w_load`, owners: [`a`], body: [push], tests: [] }, + { + id: `w_loaded`, + owners: [`a`], + body: [{ kind: `pushLoaded` }], + tests: [judging(`a`, `fixed seed`, 1)], + }, // A test that fires a verdict and catches its failure passes, so it kills // nothing, and the tests after it must still run. It sits between a test // that reaches `w_late` and the one that judges it, so a runner that @@ -376,6 +394,9 @@ const captureWitness = new Set([ `w_caught`, `w_late`, `w_prop`, + `w_memo`, + `w_load`, + `w_loaded`, ]) // ## Rendering and layout @@ -425,6 +446,10 @@ const preamble = [ `export function make(cls: new () => { run(x: number, out: Array): void }, x: number, out: Array): void {`, ` new cls().run(x, out);`, `}`, + // Module state: a cache that `w_memo` fills, and labels that the module + // writes when it loads (`w_load`, called at the end of the source). + `const memo = new Map()`, + `export const loaded: Array = []`, ] const preambleStatements = { devBuild: `return process.env.NODE_ENV !== 'production'`, @@ -495,6 +520,12 @@ function renderStatement(stmt, callText) { return [`this.tag = 'b';`] case `pushTag`: return [`out.push(this.tag);`] + case `memoSet`: + return [`if (!memo.has(x)) memo.set(x, 's${stmt.label}');`] + case `memoGet`: + return [`out.push(String(memo.get(x)));`] + case `pushLoaded`: + return [`out.push(...loaded);`] case `call`: case `libCall`: case `valueCall`: @@ -684,6 +715,13 @@ function resolveHistory(history) { for (const fn of functions) if (fn.container === cls) emit(fn, ` `) lines.push(`}`) } + // Code that runs when the module loads: `w_load` writes its labels into + // `loaded`, which `w_loaded` reads. + let moduleLoadLine + if (functions.some((fn) => fn.id === `w_load`)) { + moduleLoadLine = lines.length + 1 + lines.push(`w_load(0, loaded);`) + } const source = `${lines.join(`\n`)}\n` // The library is laid out the same way, in its own file. { @@ -751,6 +789,7 @@ function resolveHistory(history) { tests, authored, authoredList, + moduleLoadLine, wholeFile: history.wholeFile, template: history.template, fault: history.fault, @@ -816,7 +855,7 @@ const routeOf = (t, part = t) => /** Law 6's normalization: the statement's tokens, joined by single spaces. */ function tokens(text) { const pattern = - /'[^']*'|`[^`]*`|[A-Za-z_$][\w$]*|\d+|\?\.|=>|!==|===|&&|\|\||<=|>=|[^\s\w]/g + /'[^']*'|`[^`]*`|[A-Za-z_$][\w$]*|\d+|\.\.\.|\?\.|=>|!==|===|&&|\|\||<=|>=|[^\s\w]/g return text.match(pattern).join(` `) } @@ -901,6 +940,14 @@ function sitesOf(plan) { } if (stmt.kind === `push` || stmt.kind === `pushTag`) at(`delete-call`, `push`, stmt.line, whole) + if (stmt.kind === `memoSet`) { + at(`if-true`, `true`, stmt.line, whole) + at(`if-false`, `false`, stmt.line, whole) + at(`drop-not`, `not`, stmt.line, whole) + at(`delete-call`, `memoSet`, stmt.line, `memo.set(x, 's${stmt.label}');`) + } + if (stmt.kind === `memoGet` || stmt.kind === `pushLoaded`) + at(`delete-call`, stmt.kind, stmt.line, whole) if (stmt.kind === `primeCall`) at(`delete-call`, `call`, stmt.line, whole) if (stmt.kind === `if`) { condition() @@ -955,6 +1002,10 @@ function sitesOf(plan) { } } } + if (plan.moduleLoadLine !== undefined) + add(``, plan.moduleLoadLine, `delete-call`, `w_load(0, loaded);`, { + role: `moduleLoad`, + }) // Order on a line, and the anchor's occurrence index within a function. const onLine = new Map() const anchors = new Map() @@ -1051,10 +1102,40 @@ function interpret(plan, fnId, x, sites, active) { active?.authored === stmt.label ? `s${stmt.label}!` : `s${stmt.label}`, ) } + // Module state as a freshly loaded module has it: an empty cache, and + // the labels `w_load` wrote at load, under the same mutant. + const memo = new Map() + const loaded = () => { + const call = sites.find((s) => s.role === `moduleLoad`) + if (call) reached.add(call.id) + if (isActive(call)) return [] + const load = interpret(plan, `w_load`, 0, sites, active) + for (const id of load.reached) reached.add(id) + return load.trace + } const run = (id, object = { tag: `a` }) => { const fn = plan.functions.find((f) => f.id === id) for (const stmt of fn.body) { if (stmt.kind === `push`) write(stmt) + if (stmt.kind === `memoSet`) { + for (const role of [`true`, `false`, `not`]) reached.add(siteOf(stmt, role).id) + let value = !memo.has(x) + if (isActive(siteOf(stmt, `not`))) value = !value + outcomes.push([stmt.line, value]) + if (isActive(siteOf(stmt, `true`))) value = true + if (isActive(siteOf(stmt, `false`))) value = false + if (value) { + const set = siteOf(stmt, `memoSet`) + reached.add(set.id) + if (!isActive(set)) memo.set(x, `s${stmt.label}`) + } + } + if (stmt.kind === `memoGet` || stmt.kind === `pushLoaded`) { + const site = siteOf(stmt, stmt.kind) + reached.add(site.id) + if (!isActive(site)) + trace.push(...(stmt.kind === `memoGet` ? [String(memo.get(x))] : loaded())) + } if (stmt.kind === `pushTag`) { const site = siteOf(stmt, `push`) reached.add(site.id) @@ -1201,12 +1282,18 @@ function model(plan) { for (const [line, value] of run.outcomes) taken.set(line, new Set([...(taken.get(line) ?? []), value])) } - if (!reached.has(site.id)) return `unreached` - const reaching = tests.filter((t) => - partsOf(t).some((part) => - interpret(plan, t.fn, part.input, sites).reached.has(site.id), - ), - ) + // Code that runs when the module loads (`w_load`, and the call at the + // end of the source) runs in every owner file that has a test, and + // reaches every test of that file (maintainer decision, 2026-11-06). + const atLoad = site.function === `w_load` || site.function === `` + if (!reached.has(site.id) && !(atLoad && tests.length > 0)) return `unreached` + const reaching = atLoad + ? tests + : tests.filter((t) => + partsOf(t).some((part) => + interpret(plan, t.fn, part.input, sites).reached.has(site.id), + ), + ) if (reaching.every((t) => t.flaky)) return `indeterminate` const kind = judge(plan, tests, sites, { site: site.id }) if (kind !== `survived`) return kind diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index a01472b41d..496a116f24 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -68,8 +68,10 @@ * that fails with no verdict, whatever its message, makes it `crashed`; * and a failing framework check alone (a calibration, a probe * calibration, or the determinism test) makes it a `framework-error`. A - * mutant that no owner test reaches is `unreached`; one that only - * nondeterministic owner tests could judge is `indeterminate`. A mutant + * mutant that no owner test reaches is `unreached`; code that runs when + * a file loads the module reaches every test of that file (maintainer + * decision of 2026-11-06); a mutant that only nondeterministic owner tests + * could judge is `indeterminate`. A mutant * that no owner test fails is a survivor: a `comparison-gap` when an owner * test's observation or asserted value changed, a `generator-gap` when an * `if` on its line never took one of its outcomes in an owner test, and @@ -535,6 +537,10 @@ async function driver(plan, observe) { states.add(`a kill after a caught verdict`) if (m.function === `w_prop` && m.outcome === `killed`) states.add(`a kill through state an earlier test stored`) + if (m.function === `w_load` && m.outcome === `killed`) + states.add(`a mutant of code run at module load killed`) + if (m.function === `w_memo` && m.operator === `if-false` && m.outcome === `killed`) + states.add(`a module-level cache judged afresh`) if (m.function === `w_campaigns` && m.outcome === `random-only-kill`) states.add(`second campaign's route attributed`) if (m.function === `w_soft` && m.outcome === `random-only-kill`) @@ -988,6 +994,8 @@ const required = [ `a test that depends on an earlier test`, `a kill after a caught verdict`, `a kill through state an earlier test stored`, + `a mutant of code run at module load killed`, + `a module-level cache judged afresh`, `ordinary tests run when asked`, `ordinary tests left out by default`, `two authored mutants judged apart`, From 9f5db6df0e78010479601839fc882087f0fe8b4f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 12:09:43 -0700 Subject: [PATCH 053/168] test(oracle-gaps): tokenize keys in the model with TypeScript's scanner The model computed the statement text of each owed key with a regular expression, which had to list every token the grammar can write and missed `...`. It now asks TypeScript's scanner, the authority on what a token is, and stays independent of the CLI where it matters: which statement holds a site and how a key is put together. GREEN: scripts.gap-report-static and scripts.gap-report-classify, 14 of 14. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 134b1cde44..f0cc09446f 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -5,9 +5,16 @@ * end-to-end oracle runs the real CLI on them. */ import { mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import fc from 'fast-check' +// TypeScript is the authority on what a token is (law 6), not the CLI. +const ts = createRequire( + join(dirname(fileURLToPath(import.meta.url)), `../../package.json`), +)(`typescript`) + const SOURCE = `src/sut.ts` // A second source in the same package, which the first imports from. const LIB = `src/lib.ts` @@ -852,11 +859,24 @@ const routeOf = (t, part = t) => ? `random` : `framework` -/** Law 6's normalization: the statement's tokens, joined by single spaces. */ +/** + * Law 6's normalization: the statement's tokens, as TypeScript's scanner + * reads them, joined by single spaces. The scanner reads `>` alone unless + * asked to rescan it, which joins `>=`. + */ function tokens(text) { - const pattern = - /'[^']*'|`[^`]*`|[A-Za-z_$][\w$]*|\d+|\.\.\.|\?\.|=>|!==|===|&&|\|\||<=|>=|[^\s\w]/g - return text.match(pattern).join(` `) + const scanner = ts.createScanner(ts.ScriptTarget.Latest, true) + scanner.setText(text) + const found = [] + for ( + let kind = scanner.scan(); + kind !== ts.SyntaxKind.EndOfFileToken; + kind = scanner.scan() + ) { + if (kind === ts.SyntaxKind.GreaterThanToken) scanner.reScanGreaterToken() + found.push(scanner.getTokenText()) + } + return found.join(` `) } /** The functions whose every statement is probe-only (law 2), by fixpoint. */ From da234dc462055f2fe7c3c1963bc862e480ec3715 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 12:47:34 -0700 Subject: [PATCH 054/168] perf(oracle-gaps): run many mutants in one Vitest run of file copies Vitest 4 ends a run's workers when the run ends, so every unit of one test file under one mutant paid a fresh worker, a fresh jsdom environment, and a fresh import of the file: about 0.5 s a run, half of a cold check's work on offline-transactions. The runner now writes one copy of the test file per item, next to the original, and runs a batch of copies in one non-isolated run with an order-keeping sequencer. The capture setup takes each copy's mutant, selection, and stop rule from the worker's current file, and resets the module registry before the copy loads, so the copy loads the package afresh under its mutant with no module state from an earlier copy. The chai and fast-check wrappers are installed once per worker and call the copy now running. A watchdog cuts off a copy that passes its file's time limit; the copies before it keep their results and the rest rerun. Mutants run in rounds of small groups per context; authored mutants run one owner file per context at a time. Code run at module load also reaches ordinary tests (the model's ordinary-tier reach follows the maintainer's decision). Law: scripts.gap-report, the module-state witnesses w_memo and w_load. GREEN: 26 of 26; a cold run on offline-transactions classifies every mutant as the slow reference does (except the intended timeout -> killed). Mutant: batching without the module reset gives w_memo if-false `generator-gap`, owed `killed`. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 169 ++++--- scripts/oracle-gaps/check.mjs | 453 ++++++++++++++---- .../oracle-gaps/gap-report-model-oracle.mjs | 14 +- 3 files changed, 490 insertions(+), 146 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 52f4cf3424..9cf23eae70 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -25,13 +25,21 @@ // of shrinking it: a kill needs one failing history, not the smallest one. import { createHash } from 'node:crypto' import { appendFileSync, readFileSync } from 'node:fs' -import { afterAll, afterEach, beforeEach, chai, expect, inject } from 'vitest' +import { afterAll, afterEach, beforeEach, chai, expect, inject, vi } from 'vitest' -const state = +const run = inject(`oracleGaps`) ?? (process.env.ORACLE_GAPS_STATE ? JSON.parse(readFileSync(process.env.ORACLE_GAPS_STATE, `utf8`)) : { mutant: null, select: null, capture: null }) +// A batch runs many copies of test files in one worker, each with its own +// mutant (check.mjs). This file runs before each copy loads, so it takes +// that copy's settings, and resets the module registry: the copy's imports +// then load the package's modules afresh, under its mutant, with no state +// left by an earlier copy. +const unit = run.units?.[globalThis.__vitest_worker__?.filepath] +if (run.units) vi.resetModules() +const state = unit ? { ...run, ...unit } : run // The seed travels with each run's state, so a worker that a warm context // started under an earlier check's seed runs under this run's seed. if (`seed` in state) { @@ -124,6 +132,20 @@ const hit = (id) => { let histories = 0 let siteHistories = new Map() let historyHits = null +// fast-check also outlives a batch's copies, so its wrapper calls the +// copy now running. +globalThis.__oracleGapsHistory = { + get: () => historyHits, + set: (value) => { + historyHits = value + }, + done: () => { + if (!inTest || !historyHits) return + histories += 1 + for (const id of historyHits) + siteHistories.set(id, (siteHistories.get(id) ?? 0) + 1) + }, +} if (fc && process.env.ORACLE_GAPS_PROFILE) { const samples = [ () => fc.property(fc.constant(0), () => true), @@ -134,15 +156,12 @@ if (fc && process.env.ORACLE_GAPS_PROFILE) { const run = prototype.run if (typeof run !== `function` || run.counted) continue const counted = function (...args) { - const outer = historyHits - historyHits = new Set() + const history = globalThis.__oracleGapsHistory + const outer = history.get() + history.set(new Set()) const done = () => { - if (inTest) { - histories += 1 - for (const id of historyHits) - siteHistories.set(id, (siteHistories.get(id) ?? 0) + 1) - } - historyHits = outer + history.done() + history.set(outer) } let result try { @@ -315,55 +334,69 @@ function render(value, seen = new Set(), depth = 0) { } // Every matcher is a method on chai's Assertion prototype. Wrapping each one -// records its name and the value under test before the matcher runs. +// records its name and the value under test before the matcher runs. chai +// outlives the copies of a batch (check.mjs), so the wrappers are installed +// once per worker and call the hooks of the copy now running. +globalThis.__oracleGapsHooks = { + matcher(name, assertion) { + try { + const rendered = `${name}(${render(chai.util.flag(assertion, `object`))})\n` + digest.update(rendered) + if (debug) + appendFileSync(debug, `${expect.getState().currentTestName}\t${rendered}`) + count += 1 + } catch { + digest.update(`${name}(unrenderable)\n`) + } + }, + inTest: () => inTest, + errors: () => currentTask?.result?.errors ?? [], + recordAssertion: () => recordAssertion(), +} const proto = chai.Assertion.prototype // `withTest` is Vitest's binding of an assertion to its test, and `assert` is // the check every matcher calls; neither is a matcher. Whether `withTest` // runs varies between runs, and wrapping `assert` counts each matcher twice. const notMatchers = new Set([`constructor`, `withTest`, `assert`]) -for (const name of Object.getOwnPropertyNames(proto)) { - if (notMatchers.has(name)) continue - const descriptor = Object.getOwnPropertyDescriptor(proto, name) - if (!descriptor || typeof descriptor.value !== `function`) continue - const matcher = descriptor.value - Object.defineProperty(proto, name, { - ...descriptor, - value: function (...args) { - try { - const rendered = `${name}(${render(chai.util.flag(this, `object`))})\n` - digest.update(rendered) - if (debug) - appendFileSync( - debug, - `${expect.getState().currentTestName}\t${rendered}`, +const WRAPPED = Symbol.for(`oracle-gaps.wrapped`) +if (!proto[WRAPPED]) { + proto[WRAPPED] = true + for (const name of Object.getOwnPropertyNames(proto)) { + if (notMatchers.has(name)) continue + const descriptor = Object.getOwnPropertyDescriptor(proto, name) + if (!descriptor || typeof descriptor.value !== `function`) continue + const matcher = descriptor.value + Object.defineProperty(proto, name, { + ...descriptor, + value: function (...args) { + const hooks = globalThis.__oracleGapsHooks + hooks.matcher(name, this) + // A soft assertion (`expect.soft`) does not throw: Vitest records its + // failure among the test's errors and the test goes on. A failure is + // seen as a new assertion error there. + const soft = hooks.inTest() && chai.util.flag(this, `soft`) + const errorsBefore = hooks.errors().length + try { + const returned = matcher.apply(this, args) + if ( + soft && + hooks + .errors() + .slice(errorsBefore) + .some((error) => error?.name === `AssertionError`) ) - count += 1 - } catch { - digest.update(`${name}(unrenderable)\n`) - } - // A soft assertion (`expect.soft`) does not throw: Vitest records its - // failure among the test's errors and the test goes on. A failure is - // seen as a new assertion error there. - const soft = inTest && chai.util.flag(this, `soft`) - const errorsBefore = currentTask?.result?.errors?.length ?? 0 - try { - const returned = matcher.apply(this, args) - if ( - soft && - (currentTask?.result?.errors ?? []) - .slice(errorsBefore) - .some((error) => error?.name === `AssertionError`) - ) - recordAssertion() - return returned - } catch (error) { - // A thrown assertion error is recognized by its type, never by its - // message. - if (inTest && error instanceof chai.AssertionError) recordAssertion() - throw error - } - }, - }) + hooks.recordAssertion() + return returned + } catch (error) { + // A thrown assertion error is recognized by its type, never by its + // message. + if (hooks.inTest() && error instanceof chai.AssertionError) + hooks.recordAssertion() + throw error + } + }, + }) + } } // An assertion that fails is a verdict under an unnamed law, as older @@ -392,6 +425,8 @@ const reproducibleRoutes = new Set([ ]) let stopped = false let currentTask +// When the current test started, so its row records how long it took. +let testStarted = 0 // Each law violation the oracle framework raises, so the runner classifies // a kill by the law that rejected the mutant rather than by error text. @@ -430,6 +465,7 @@ beforeEach((context) => { hits = new Set() outcomes = new Map() verdicts = new Map() + testStarted = performance.now() histories = 0 siteHistories = new Map() route = undefined @@ -460,6 +496,18 @@ afterEach((context) => { outcomes: [...outcomes], verdicts: [...verdicts.values()], route, + ms: Math.round(performance.now() - testStarted), + // The test's own result, so a batch cut short by a hang still has the + // results of the copies that finished. + state: context.task.result?.state, + message: (context.task.result?.errors ?? []) + .map((error) => + error?.cause?.message + ? `${error.cause.name ?? ``}: ${error.cause.message}` + : `${error?.name ?? ``}: ${error?.message ?? ``}`, + ) + .join(`\n`) + .slice(0, 2000), ...(process.env.ORACLE_GAPS_PROFILE ? { histories, siteHistories: [...siteHistories] } : {}), @@ -468,11 +516,14 @@ afterEach((context) => { hits = new Set() }) +const thisFile = globalThis.__vitest_worker__?.filepath afterAll(() => { - if (!state.capture || !currentFile) return - const file = currentFile - appendFileSync( - state.capture, - `${JSON.stringify({ key: `${file} > (background)`, background: true, hits: [...background] })}\n`, - ) + if (!state.capture) return + if (currentFile) + appendFileSync( + state.capture, + `${JSON.stringify({ key: `${currentFile} > (background)`, background: true, hits: [...background] })}\n`, + ) + // A batch's watchdog reads this row to know that the copy finished. + appendFileSync(state.capture, `${JSON.stringify({ file: thisFile, closed: true })}\n`) }) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 9ec80e1a2b..f810f3ecd2 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -33,7 +33,7 @@ import { writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join, relative as relativePath } from 'node:path' +import { basename, dirname, join, relative as relativePath } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { applyAuthored, loadAuthored } from './authored.mjs' import { assignKeys, renderReport } from './report.mjs' @@ -99,6 +99,17 @@ export function codeIdentity() { return hash.digest(`hex`) } + +/** A sequencer that runs files in the order given. */ +class KeepOrder { + async sort(files) { + return files + } + async shard(files) { + return files + } +} + /** Hashes of files by content, memoized by size and modification time. */ function fileHashes() { const memo = new Map() @@ -222,6 +233,11 @@ export default mergeConfig(base, { // Every module each test file imports, which a unit records as its // inputs. experimental: { importDurations: { limit: 1_000_000 } }, + // A batch runs copies of test files in one worker, sharing its + // environment (runBatch), in the order given, so a hang is attributed + // to the copy that was running. + isolate: false, + sequence: { sequencer: KeepOrder }, reporters: [{}], }) // Vitest 3 calls this step `init`; Vitest 4 renamed it `standalone`. @@ -343,7 +359,8 @@ export default mergeConfig(base, { if (existsSync(capture)) { for (const line of readFileSync(capture, `utf8`).split(`\n`).filter(Boolean)) { const row = JSON.parse(line) - rows.push([row.key, row]) + // A closing row marks the end of a file for a batch's watchdog. + if (!row.closed) rows.push([row.key, row]) } rmSync(capture) } @@ -393,6 +410,171 @@ export default mergeConfig(base, { } } + /** + * Many units in one Vitest run. Each item runs in its own copy of its test + * file, next to the original so that its imports resolve. The worker keeps + * its environment between copies, and the capture setup gives each copy + * its own mutant and a fresh module registry. A copy that passes its time + * limit is cut off: the copies before it keep their results, it is + * `timedOut`, and the caller reruns the rest. Returns one unit per + * finished item, in order, and the index of the item that timed out. + */ + let copyCounter = 0 + async function runBatch(context, out, items, seed) { + const capture = join(out, `run-${process.pid}-${++runCounter}.jsonl`) + const copies = items.map((item) => { + const copy = join( + dirname(item.file), + `.oracle-gaps-${process.pid}-${++copyCounter}-${basename(item.file)}`, + ) + writeFileSync(copy, readFileSync(item.file)) + return copy + }) + const keyFor = (copy, file) => (key) => + key.startsWith(`${copy} > `) ? `${file}${key.slice(copy.length)}` : key + const units = Object.fromEntries( + copies.map((copy, i) => [ + copy, + { + mutant: items[i].mutant ?? null, + select: + items[i].select?.map((key) => + key.startsWith(`${items[i].file} > `) + ? `${copy}${key.slice(items[i].file.length)}` + : key, + ) ?? null, + stopOnKill: items[i].stopOnKill ?? false, + }, + ]), + ) + const state = { mutant: null, select: null, capture, seed, units } + context.getRootProject?.().provide(`oracleGaps`, state) + writeFileSync(join(out, `state.json`), JSON.stringify(state)) + const project = context.getRootProject() + const started = Date.now() + const running = context.runTestSpecifications( + copies.map((copy) => project.createSpecification(copy)), + false, + ) + // The watchdog: each copy writes a closing row when it finishes, so the + // copy that is running is the first without one. A copy that passes its + // file's time limit is cut off. + const finished = () => { + if (!existsSync(capture)) return 0 + const done = new Set() + for (const line of readFileSync(capture, `utf8`).split(`\n`)) + if (line.includes(`"closed":true`)) done.add(JSON.parse(line).file) + return copies.findIndex((copy) => !done.has(copy)) + } + let current = 0 + let copyStarted = Date.now() + let timer + const watchdog = new Promise((resolve) => { + const poll = () => { + const at = finished() + if (at === -1) return + if (at !== current) { + current = at + copyStarted = Date.now() + } + if (Date.now() - copyStarted > items[current].limitMs) return resolve(undefined) + timer = setTimeout(poll, 50) + } + timer = setTimeout(poll, 50) + }) + const result = await Promise.race([running, watchdog]) + clearTimeout(timer) + const seconds = (Date.now() - started) / 1000 + const rowsOf = () => { + const rows = new Map(copies.map((copy) => [copy, []])) + if (existsSync(capture)) + for (const line of readFileSync(capture, `utf8`).split(`\n`).filter(Boolean)) { + const row = JSON.parse(line) + const copy = copies.find((c) => row.key?.startsWith(`${c} > `) || row.file === c) + if (copy && !row.closed) rows.get(copy).push(row) + } + return rows + } + try { + if (result === undefined) { + await Promise.race([ + context.cancelCurrentRun(`keyboard-input`).catch(() => undefined), + sleep(5000), + ]) + span(`run`, started, { file: `batch`, items: items.length, timedOut: current }) + // The copies before the one that hung finished: their units come + // from their capture rows. + const rows = rowsOf() + return { + units: items.slice(0, current).map((item, i) => { + const map = keyFor(copies[i], item.file) + const own = rows.get(copies[i]).map((row) => ({ ...row, key: map(row.key) })) + return { + file: item.file, + timedOut: false, + seconds: seconds / items.length, + setupErrors: [], + tests: own + .filter((row) => !row.background) + .map((row) => ({ + key: row.key, + fullName: row.key.split(` > `).slice(1).join(` > `), + state: row.state === `fail` ? `failed` : row.state === `skip` ? `skipped` : `passed`, + message: row.message ?? ``, + })), + unhandled: [], + rows: own.map((row) => [row.key, row]), + } + }), + timedOutAt: current, + } + } + // Errors outside any test cannot be attributed to one copy, so the + // caller runs those items alone. + if ((result.unhandledErrors ?? []).length > 0) return { units: [], unattributed: true } + const rows = rowsOf() + span(`run`, started, { file: `batch`, items: items.length }) + return { + units: items.map((item, i) => { + const copy = copies[i] + const map = keyFor(copy, item.file) + const modules = result.testModules.filter((m) => m.moduleId === copy) + return { + file: item.file, + timedOut: false, + seconds: Math.max(0, ...modules.map((m) => (m.diagnostic?.().duration ?? 0) / 1000)), + setupErrors: modules.flatMap((m) => + (m.errors?.() ?? []).map((error) => error.message), + ), + tests: modules.flatMap((module) => + [...module.children.allTests()].map((testCase) => { + const testResult = testCase.result() + return { + key: map(`${module.moduleId} > ${testCase.fullName}`), + fullName: testCase.fullName, + state: testResult.state, + message: (testResult.errors ?? []) + .map((error) => { + const cause = error.cause + return cause?.message + ? `${cause.name ?? ``}: ${cause.message}` + : `${error.name ?? ``}: ${error.message ?? ``}` + }) + .join(`\n`), + } + }), + ), + unhandled: [], + rows: rows.get(copy).map((row) => [map(row.key), { ...row, key: map(row.key) }]), + } + }), + } + } finally { + for (const copy of copies) rmSync(copy, { force: true }) + rmSync(capture, { force: true }) + } + } + async function check(options, store, log = console.error) { const authored = options.mutants ? loadAuthored(options.mutants) : [] mkdirSync(options.out, { recursive: true }) @@ -470,21 +652,34 @@ export default mergeConfig(base, { // Each file's imports in its unmutated run. A run that timed out reports // none, so it is stored under these: a mutant changes no import. const fileInputs = new Map() - async function unit(context, file, spec, runOptions = {}) { - const index = indexOf.get(context) - const id = sha1(JSON.stringify([globalKey, file, spec, runOptions.select ?? null])) - // A run is reused when every input of one of its stored versions is - // unchanged; undoing an edit finds the version from before it. - const looked = Date.now() - const valid = store + const idOf = (file, spec, select) => + sha1(JSON.stringify([globalKey, file, spec, select ?? null])) + // A run is reused when every input of one of its stored versions is + // unchanged; undoing an edit finds the version from before it. + const lookup = (id, index) => + store .get(id) ?.variants.find((variant) => variant.inputs.every(([path, hash]) => servedHash(path, index) === hash), ) + // A batch item reads the modules its file's unmutated run read: a mutant + // changes no import. + const remember = (id, file, result, index) => { + const paths = fileInputs.get(file) + if (!paths) return + const inputs = paths.map((path) => [path, servedHash(path, index)]) + store.put(id, { inputs, unit: result }) + } + async function unit(context, file, spec, runOptions = {}) { + const index = indexOf.get(context) + const id = idOf(file, spec, runOptions.select) + const looked = Date.now() + const valid = lookup(id, index) span(`lookup`, looked, { file: file.split(`/`).at(-1), spec, hit: Boolean(valid) }) if (valid) { evidence.reused += 1 - if (!valid.unit.timedOut) fileInputs.set(file, valid.unit.inputs) + if (spec.startsWith(`unmutated:`) && valid.unit.inputs) + fileInputs.set(file, valid.unit.inputs) return valid.unit } const result = await runUnit(context, options.out, file, { @@ -502,6 +697,50 @@ export default mergeConfig(base, { } return result } + // Units for many requests at once. Each is looked up first; the rest run + // as one batch (runBatch). A batch cut off by a hang records that item + // as timed out and reruns the rest in a fresh context; a batch with + // errors outside any test runs its items alone. + async function unitsBatch(slot, requests) { + const index = indexOf.get(slot.context) + const got = new Array(requests.length) + let rest = [] + requests.forEach((request, i) => { + const id = idOf(request.file, request.spec, request.select) + const valid = lookup(id, index) + if (valid) { + evidence.reused += 1 + got[i] = valid.unit + } else rest.push({ ...request, i, id }) + }) + while (rest.length > 0) { + const batch = await runBatch(slot.context, options.out, rest, options.seed ?? null) + if (batch.unattributed) { + for (const r of rest) + got[r.i] = await unit(slot.context, r.file, r.spec, { + mutant: r.mutant, + select: r.select, + stopOnKill: r.stopOnKill, + limitMs: r.limitMs, + }) + break + } + batch.units.forEach((u, k) => { + got[rest[k].i] = u + remember(rest[k].id, rest[k].file, u, index) + evidence.computed += 1 + }) + if (batch.timedOutAt === undefined) break + const hung = rest[batch.timedOutAt] + const timedOut = { file: hung.file, timedOut: true, seconds: hung.limitMs / 1000 } + got[hung.i] = timedOut + remember(hung.id, hung.file, timedOut, index) + evidence.computed += 1 + await replace(slot) + rest = rest.slice(batch.timedOutAt + 1) + } + return got + } const vitest = contexts[0] const fileSpecs = new Map(contextSpecs.get(vitest)) @@ -663,64 +902,107 @@ export default mergeConfig(base, { contexts[slot.index] = slot.context span(`replace context`, started, { slot: slot.index }) } - // The units of one mutant against some files, one file at a time and - // only the tests that reached it. The runner stops at the first kill: a - // kill decides the outcome whatever the other files would do. - const unitsFor = async (slot, targets, keys, mutant) => { - const done = [] - for (const file of targets) { - const result = await unit(slot.context, file, `mutant:${mutant}`, { - mutant, - limitMs: limitFor(file), - select: await selectionFor(slot, file, keys), - stopOnKill: true, + // Mutants run in groups per context. In each round, every undecided + // mutant of the group runs its next owner file, all in one batch; a + // mutant is decided once an owner kills it or every owner has run. The + // ordinary tier, when asked for, runs the same way afterwards. + const GROUP = 4 + const rounds = async (slot, states, filesOf, keysOf) => { + for (;;) { + const active = states.filter((st) => !st.done) + if (active.length === 0) return + const requests = [] + for (const st of active) { + const file = filesOf(st)[st.next] + requests.push({ + st, + file, + spec: `mutant:${st.mutant.number}`, + mutant: st.mutant.number, + select: await selectionFor(slot, file, keysOf(st)), + limitMs: limitFor(file), + stopOnKill: true, + }) + } + const got = await unitsBatch(slot, requests) + got.forEach((u, i) => { + const st = requests[i].st + st.units.push(u) + st.next += 1 + if ( + st.next >= filesOf(st).length || + outcomeOf(st.units, baseline.nondeterministic).outcome === `killed` + ) + st.done = true }) - done.push(result) - if (result.timedOut) await replace(slot) - if (outcomeOf(done, baseline.nondeterministic).outcome === `killed`) break } - return done } - async function evaluate(mutant, slot) { + async function evaluateGroup(group, slot) { const evaluated = Date.now() - mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) - const owners = ownersOf(mutant) - const targets = targetsOf(mutant, baseline, owners, oracleFiles) - const result = { - ...mutant, - oracleFiles: targets.oracleTargets, - ordinaryFiles: targets.testTargets, + const states = group.map((mutant) => { + mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) + const owners = ownersOf(mutant) + const targets = targetsOf(mutant, baseline, owners, oracleFiles) + return { + mutant, + owners, + targets, + units: [], + next: 0, + done: owners.size === 0 || targets.oracleTargets.length === 0, + } + }) + await rounds( + slot, + states, + (st) => st.targets.oracleTargets, + (st) => st.targets.oracleKeys, + ) + const ordinary = [] + for (const st of states) { + st.result = { + ...st.mutant, + oracleFiles: st.targets.oracleTargets, + ordinaryFiles: st.targets.testTargets, + ...judgeMutant(st.mutant, { + owners: st.owners, + targets: st.targets, + units: st.units, + baseline, + mutants, + }), + } + if (needsOrdinary(st.result, st.targets)) + ordinary.push({ ...st, units: [], next: 0, done: false }) } - const units = - owners.size > 0 && targets.oracleTargets.length > 0 - ? await unitsFor(slot, targets.oracleTargets, targets.oracleKeys, mutant.number) - : [] - Object.assign( - result, - judgeMutant(mutant, { owners, targets, units, baseline, mutants }), + await rounds( + slot, + ordinary, + (st) => st.targets.testTargets, + (st) => st.targets.testKeys, ) - if (needsOrdinary(result, targets)) - result.testOutcome = judgeOrdinary( - await unitsFor(slot, targets.testTargets, targets.testKeys, mutant.number), + for (const st of ordinary) + states.find((x) => x.mutant === st.mutant).result.testOutcome = judgeOrdinary(st.units) + for (const st of states) { + results.push(st.result) + span(`mutant`, evaluated, { + id: st.mutant.id, + slot: slot.index, + outcome: st.result.class ?? st.result.outcome, + files: st.units.length, + targets: st.targets.oracleTargets.length, + }) + appendFileSync(partial, `${JSON.stringify(st.result)}\n`) + log( + `${st.mutant.id} L${st.mutant.line} ${st.mutant.operator} ${st.result.outcome}${st.result.class ? ` (${st.result.class})` : ``}${st.result.testOutcome ? ` [ordinary: ${st.result.testOutcome}]` : ``}`, ) - results.push(result) - span(`mutant`, evaluated, { - id: mutant.id, - slot: slot.index, - outcome: result.class ?? result.outcome, - files: units.length, - targets: targets.oracleTargets.length, - }) - appendFileSync(partial, `${JSON.stringify(result)}\n`) - log( - `${mutant.id} L${mutant.line} ${mutant.operator} ${result.outcome}${result.class ? ` (${result.class})` : ``}${result.testOutcome ? ` [ordinary: ${result.testOutcome}]` : ``}`, - ) + } } await Promise.all( contexts.map(async (context, index) => { const slot = { context, index } - for (let mutant = queue.shift(); mutant; mutant = queue.shift()) - await evaluate(mutant, slot) + for (let group = queue.splice(0, GROUP); group.length > 0; group = queue.splice(0, GROUP)) + await evaluateGroup(group, slot) }), ) results.sort((a, b) => a.number - b.number || a.source.localeCompare(b.source)) @@ -769,37 +1051,42 @@ export default mergeConfig(base, { targets: [...owners].filter((file) => fileSpecs.has(file)), }) } - const judgeAuthoredJob = async (slot, job) => { - const serve = globalThis.__oracleGapsServe - serve[slot.index] = new Map([[job.path, job.patched.text]]) - invalidateChanged([job.path], slot.context) - const units = [] - for (const file of job.targets) { - units.push( - await unit(slot.context, file, job.id, { - mutant: -1, - limitMs: limitFor(file), - stopOnKill: true, - }), - ) - if (units.at(-1).timedOut) { - await replace(slot) - invalidateChanged([job.path], slot.context) - } - if (outcomeOf(units, baseline.nondeterministic).outcome === `killed`) break - } - serve[slot.index] = undefined - invalidateChanged([job.path], slot.context) - Object.assign(job.result, judgeAuthored(units, baseline)) - log(`${job.id} ${job.result.outcome}${job.result.class ? ` (${job.result.class})` : ``}`) - } + // Each owner file of each authored mutant is its own item, taken by any + // free context, which serves the patch only to itself. A hang in one + // owner then waits beside the others, not before them; a kill in any + // owner decides the mutant (a kill outranks a timeout). + const authoredItems = authoredJobs.flatMap((job) => + job.targets.map((file) => ({ job, file })), + ) + for (const job of authoredJobs) job.units = [] await Promise.all( contexts.map(async (context, index) => { const slot = { context, index } - for (let job = authoredJobs.shift(); job; job = authoredJobs.shift()) - await judgeAuthoredJob(slot, job) + const serve = globalThis.__oracleGapsServe + for (let item = authoredItems.shift(); item; item = authoredItems.shift()) { + const { job, file } = item + serve[slot.index] = new Map([[job.path, job.patched.text]]) + invalidateChanged([job.path], slot.context) + const [got] = await unitsBatch(slot, [ + { + file, + spec: job.id, + mutant: -1, + select: null, + limitMs: limitFor(file), + stopOnKill: true, + }, + ]) + job.units.push(got) + serve[slot.index] = undefined + invalidateChanged([job.path], slot.context) + } }), ) + for (const job of authoredJobs) { + Object.assign(job.result, judgeAuthored(job.units, baseline)) + log(`${job.id} ${job.result.outcome}${job.result.class ? ` (${job.result.class})` : ``}`) + } endPhase(`authored mutants`, { mutants: authored.length }) // Keys go into every output, so an agent reads them from the JSON // results. diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index f0cc09446f..3d2dd2e8d8 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -1324,10 +1324,16 @@ function model(plan) { // The ordinary tests' outcome is reported, for information, only when the // run asks for it, and only for a survivor or an unreached mutant that an // ordinary test reached. - const ordinaryReaches = (site) => - plan.tests - .filter((t) => t.file === `c`) - .some((t) => interpret(plan, t.fn, t.input, sites).reached.has(site.id)) + // Code that runs at module load reaches every test of a file that loads + // the module, ordinary files included (maintainer decision, 2026-11-06). + const ordinaryReaches = (site) => { + const ordinary = plan.tests.filter((t) => t.file === `c`) + if (site.function === `w_load` || site.function === ``) + return ordinary.length > 0 + return ordinary.some((t) => + interpret(plan, t.fn, t.input, sites).reached.has(site.id), + ) + } const survivorKinds = new Set([ `comparison-gap`, `generator-gap`, From 64f721fab96f656b88a0b65ae4018585e3a9199b Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 13:13:41 -0700 Subject: [PATCH 055/168] test(oracle-gaps): witness hangs in the gap report oracle The gap report oracle generated no hangs, so nothing checked how the scheduler judges a run that never ends. Owner tests can now hang where they would judge (they await a promise that never settles). The model cuts the hung file's run off, and the cut-off run records nothing. A kill in another owner still decides, and failing one, the mutant is a timeout. Plumbing, no change at the defaults: `--hang-limit` sets the base of each run's time limit (default 10 s). The report's evidence stats count runs cut off at their limit and items rerun after a cut-off batch. The limit is part of the evidence key, because a run cut off under one limit may end under a longer one. - Law: law 4 gains the hang rule. A hang cuts its file's run off, a kill in another owner outranks a timeout, and otherwise the mutant is a timeout whatever any other owner reported (v19 decision, classify.mjs). - Reach: four new required states, each reached by the "every outcome class" construction in both runs of fixed seed 20261105: a hang alone timed out; a hang in one owner and a kill in another killed; a hang in one owner and a survivor in another timed out; a batch cut off by a hang reran the rest. A rare generated `hangs` dimension (1 in 8 tests) also exists. - RED: not applicable. The current scheduler is correct here; the witnesses guard the scheduler changes that come next. - GREEN: `npx vitest run --config scripts/oracle-gaps/vitest.config.mjs gap-report-oracle.property -t "fixed seed 20261105"` passed (57 s). The static and classify oracles also passed. - Mutant: a scheduler that stops a mutant at its first timeout failed with "src/sut.ts:w_hangkill:121:if-false#0: timeout, owed killed". - Limits: a hang is only a never-settling await under a 3 s base limit. These are not generated: busy loops, slow runs that would still end, and hangs in ordinary or nondeterministic tests. Co-authored-by: Isaac --- scripts/oracle-gaps/args.mjs | 3 + scripts/oracle-gaps/check.mjs | 18 +++-- .../oracle-gaps/gap-report-model-oracle.mjs | 72 ++++++++++++++++--- .../gap-report-oracle.property.test.mjs | 33 +++++++-- 4 files changed, 109 insertions(+), 17 deletions(-) diff --git a/scripts/oracle-gaps/args.mjs b/scripts/oracle-gaps/args.mjs index 38ec0239ee..03839b384a 100644 --- a/scripts/oracle-gaps/args.mjs +++ b/scripts/oracle-gaps/args.mjs @@ -44,6 +44,9 @@ export function parseArgs(argv, cwd = process.cwd()) { // A mutant can make a test hang. Oracles that run in milliseconds do not // need Vitest's default limits, which would make each hang cost seconds. testTimeout: Number(args[`test-timeout`] ?? 15000), + // The base of each mutant run's time limit, in milliseconds; the limit + // adds three times the file's unmutated duration (see limitFor). + hangLimit: Number(args[`hang-limit`] ?? 10000), // A directory of authored mutants (see loadAuthored). mutants: args.mutants ? resolve(cwd, args.mutants) : undefined, } diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index f810f3ecd2..12887bdad8 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -646,9 +646,13 @@ export default mergeConfig(base, { process.version, options.seed ?? null, settings, + // A run cut off under one limit may end under a longer one. + options.hangLimit, ]), ) - const evidence = { reused: 0, computed: 0 } + // How the evidence was produced: units reused and computed, runs cut off + // at their time limit, and items rerun after a cut-off batch. + const evidence = { reused: 0, computed: 0, cut: 0, rerun: 0 } // Each file's imports in its unmutated run. A run that timed out reports // none, so it is stored under these: a mutant changes no import. const fileInputs = new Map() @@ -687,6 +691,7 @@ export default mergeConfig(base, { ...runOptions, }) evidence.computed += 1 + if (result.timedOut) evidence.cut += 1 const paths = result.timedOut ? fileInputs.get(file) : result.inputs if (!result.timedOut && spec.startsWith(`unmutated:`)) fileInputs.set(file, result.inputs) @@ -736,8 +741,10 @@ export default mergeConfig(base, { got[hung.i] = timedOut remember(hung.id, hung.file, timedOut, index) evidence.computed += 1 + evidence.cut += 1 await replace(slot) rest = rest.slice(batch.timedOutAt + 1) + evidence.rerun += rest.length } return got } @@ -756,9 +763,9 @@ export default mergeConfig(base, { const fileSeconds = new Map() // A mutant can slow a run that still ends, for example by lengthening a // retry backoff. A 5 s base cut off such a run in offline-transactions - // and turned a kill into a timeout, so the base stays at 10 s; hangs are - // cached, so it costs only cold runs. - const limitFor = (file) => 10_000 + 3000 * (fileSeconds.get(file) ?? 10) + // and turned a kill into a timeout, so the base defaults to 10 s + // (`--hang-limit`); hangs are cached, so it costs only cold runs. + const limitFor = (file) => options.hangLimit + 3000 * (fileSeconds.get(file) ?? 10) const runAcross = async (files, pass) => { const parts = contexts.map(() => []) files.forEach((file, index) => parts[index % contexts.length].push(file)) @@ -1104,7 +1111,8 @@ export default mergeConfig(base, { nondeterministic: [...nondeterministic], mutantSeconds, // How the result was produced: units reused from the evidence - // store and units run in this check. + // store and units run in this check, runs cut off at their time + // limit, and items rerun after a cut-off batch. evidence, results, }, diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 3d2dd2e8d8..3fb223cadc 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -125,6 +125,10 @@ const testCase = fc.record({ // (`expect.soft`) records its failure and lets the test go on to its next // campaign. judgeBy: fc.constantFrom(`verdict`, `verdict`, `assertion`, `soft`), + // A test may hang where it would judge: when the output differs, it waits + // on a promise that never settles. Rare, since each hang costs a run its + // whole time limit. + hangs: fc.constantFrom(false, false, false, false, false, false, false, true), // A test may run a second campaign, on its own route and input, as // `runOracleCampaign` does. then: fc.option( @@ -236,6 +240,7 @@ const judging = (file, route, input, extra = {}) => ({ channel: `observe`, flaky: false, judgeBy: `verdict`, + hangs: false, then: null, ...extra, }) @@ -365,6 +370,33 @@ const witness = [ judging(`a`, `fixed seed`, 7, { judgesShared: true }), ], }, + // Hangs. A test that hangs where it would judge cuts its file's run off + // at the time limit. Alone, the mutant is a timeout; a kill in another + // owner still decides, and a survivor in another owner does not. + { + id: `w_hang`, + owners: [`a`], + body: [kill], + tests: [judging(`a`, `fixed seed`, 7, { judges: false, hangs: true })], + }, + { + id: `w_hangkill`, + owners: [`a`, `b`], + body: [kill], + tests: [ + judging(`a`, `fixed seed`, 7, { judges: false, hangs: true }), + judging(`b`, `fixed seed`, 7), + ], + }, + { + id: `w_hangsurvive`, + owners: [`a`, `b`], + body: [kill], + tests: [ + judging(`a`, `fixed seed`, 7, { judges: false, hangs: true }), + judging(`b`, `fixed seed`, 7, { judges: false }), + ], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -404,6 +436,9 @@ const captureWitness = new Set([ `w_memo`, `w_load`, `w_loaded`, + `w_hang`, + `w_hangkill`, + `w_hangsurvive`, ]) // ## Rendering and layout @@ -1237,6 +1272,8 @@ function judge(plan, tests, sites, active) { const route = routeOf(t, part) if (mutated.threw) return { t, route, crash: true, verdicts: found, changed: changedAny } + // A test that hangs where another would judge never ends. + if (changed && t.hangs) return { t, route, hang: true, verdicts: found, changed: true } if (changed && t.judges) { found.push({ route, @@ -1253,21 +1290,34 @@ function judge(plan, tests, sites, active) { changed: changedAny, } }) + // A file's tests run in order, and a kill (a behavioral verdict on a + // reproducible route) stops the file. A hang before that cuts the file's + // run off, and a run that was cut off records nothing, not even its + // earlier verdicts. A kill in another file still decides (a kill outranks + // a timeout); failing that, the mutant is a timeout. + const kills = (r) => + r.verdicts.some((v) => v.route === `reproducible` && v.law !== `reach agreement`) + const hung = new Set() + for (const file of new Set(runs.map((r) => r.t.file))) + for (const r of runs.filter((x) => x.t.file === file)) { + if (r.hang) hung.add(file) + if (r.hang || kills(r)) break + } + const live = runs.filter((r) => !hung.has(r.t.file)) const verdicts = (route) => - runs.flatMap((r) => + live.flatMap((r) => r.verdicts.filter((v) => v.route === route).map((v) => v.law), ) const reproducible = verdicts(`reproducible`) - if (reproducible.length > 0) - return reproducible.every((law) => law === `reach agreement`) - ? `agreement-only-kill` - : `killed` + if (reproducible.some((law) => law !== `reach agreement`)) return `killed` + if (hung.size > 0) return `timeout` + if (reproducible.length > 0) return `agreement-only-kill` if (verdicts(`random`).length > 0) return `random-only-kill` if (verdicts(`unknown`).length > 0) return `unregistered-kill` - const failing = runs.filter((r) => r.crash || r.verdicts.length > 0) + const failing = live.filter((r) => r.crash || r.verdicts.length > 0) if (failing.some((r) => r.route !== `framework`)) return `crashed` if (failing.length > 0) return `framework-error` - return runs.some((r) => r.changed) ? `comparison-gap` : `survived` + return live.some((r) => r.changed) ? `comparison-gap` : `survived` } /** Which declaration fault, if any, must make the run fail, and its cause. */ @@ -1502,6 +1552,12 @@ function oracleTestFile(plan, file) { ` if (reads${i} !== 0) throw new Error('the payload was read')`, ] : []), + // A flaky test never hangs: the model leaves flaky tests out. + ...(t.hangs && !t.flaky + ? [ + ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise(() => {})`, + ] + : []), ...(!t.judges ? [] : t.judgeBy !== `verdict` @@ -1518,7 +1574,7 @@ function oracleTestFile(plan, file) { } out.push( `describe('t${index}', () => {`, - ` test('${testName(t, index)}', () => {`, + ` test('${testName(t, index)}', ${t.hangs && !t.flaky ? `async ` : ``}() => {`, ...partsOf(t).flatMap(campaign), ` })`, `})`, diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 496a116f24..8a5fe25f45 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -71,7 +71,11 @@ * mutant that no owner test reaches is `unreached`; code that runs when * a file loads the module reaches every test of that file (maintainer * decision of 2026-11-06); a mutant that only nondeterministic owner tests - * could judge is `indeterminate`. A mutant + * could judge is `indeterminate`. An owner test that does not end cuts + * its file's run off at the run's time limit, and the cut-off run + * records nothing, not even its earlier verdicts; a kill in another + * owner still decides (a kill outranks a timeout, v19), and failing one, + * the mutant is a `timeout`, whatever any other owner reported. A mutant * that no owner test fails is a survivor: a `comparison-gap` when an owner * test's observation or asserted value changed, a `generator-gap` when an * `if` on its line never took one of its outcomes in an owner test, and @@ -136,7 +140,10 @@ * evidence, never the record (v19 freeze). * * Limits. Sources are one file of exported functions and classes over one - * number. Not generated: loops (so no timeouts or setup errors), multi-line + * number. A hang is generated only as an owner test that waits on a promise + * that never settles, under a 3 s base limit (`--hang-limit`); not + * generated: a busy loop, a run slower than its limit that would still end, + * a hang in an ordinary or nondeterministic test, setup errors, multi-line * conditions, arrow functions and functions passed as values, nested * functions, two functions on one line, getters and setters, * `await`, `new`, and `void` statements, `--only`, `--sample`, and @@ -259,6 +266,10 @@ function runCli( // the count. `--contexts`, `4`, + // A fixture's tests run in milliseconds, so a 3 s base limit cannot cut + // off a run that would end, and each hang costs about 3 s. + `--hang-limit`, + `3000`, `--out`, path(out), ] @@ -559,6 +570,12 @@ async function driver(plan, observe) { states.add(`method owned through this`) if (m.function === `w_union` && m.outcome === `killed`) states.add(`killed by a second owner`) + if (m.function === `w_hang` && m.outcome === `timeout`) + states.add(`a hang alone timed out`) + if (m.function === `w_hangkill` && m.outcome === `killed`) + states.add(`a hang in one owner and a kill in another killed`) + if (m.function === `w_hangsurvive` && m.outcome === `timeout`) + states.add(`a hang in one owner and a survivor in another timed out`) const named = { killed: `killed`, 'random-only-kill': `random-only kill`, @@ -568,6 +585,7 @@ async function driver(plan, observe) { 'framework-error': `framework-only failure`, unreached: `unreached`, unowned: `unowned`, + timeout: `timeout`, }[m.outcome] if (named) states.add(named) if (m.class === `comparison-gap`) states.add(`comparison gap`) @@ -576,6 +594,8 @@ async function driver(plan, observe) { states.add(`no observed difference`) if (m.class === `indeterminate`) states.add(`indeterminate`) } + // A batch that a hang cut off reran its other items in a new context. + if (cli.evidence?.rerun > 0) states.add(`a batch cut off by a hang reran the rest`) if (plan.ordinary && cli.mutants.some((m) => m.ordinaryOutcome)) states.add(`ordinary tests run when asked`) if (!plan.ordinary && plan.tests.some((t) => t.file === `c`)) @@ -999,6 +1019,11 @@ const required = [ `ordinary tests run when asked`, `ordinary tests left out by default`, `two authored mutants judged apart`, + `timeout`, + `a hang alone timed out`, + `a hang in one owner and a kill in another killed`, + `a hang in one owner and a survivor in another timed out`, + `a batch cut off by a hang reran the rest`, ...incrementalStates, ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `brokenOwner`] @@ -1015,9 +1040,9 @@ const witnessed = required.filter((s) => !separate.includes(s)) oracleProperty({ id: `scripts.gap-report`, law: { - statement: `The gap report enumerates the promised operator mutants, leaves out exactly the probe-only code, discovers every oracle that owns a function's call tree and refuses broken ownership, judges each mutant only by its owners' tests on their registered routes and counts only law verdicts on reproducible routes as kills, anchors each mutant by its function and normalized statement, lists every gap and unowned mutant once with consistent counts, keeps keys stable under unrelated edits, and reports through its daemon what a cold run reports at every state of a sequence of edits.`, + statement: `The gap report enumerates the promised operator mutants, leaves out exactly the probe-only code, discovers every oracle that owns a function's call tree and refuses broken ownership, judges each mutant only by its owners' tests on their registered routes and counts only law verdicts on reproducible routes as kills, which outrank a run that never ends, anchors each mutant by its function and normalized statement, lists every gap and unowned mutant once with consistent counts, keeps keys stable under unrelated edits, and reports through its daemon what a cold run reports at every state of a sequence of edits.`, authority: `The maintainer's decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), not yet in the repository's decision register, including that a kill by reach agreement alone is a gap and that an incremental run through the daemon equals a cold one (v19.3); AGENTS.md on quiet exclusions; oracle-rubric ORC-006 outcome kinds; the CLI's documented operator list in schemata.mjs.`, - limits: `No loops, multi-line conditions, arrow or nested functions, two functions on one line, getters and setters, or await, new, and void statements; two source files of one package, with no re-exports, namespace imports, or calls into another package; no --only, --sample, or --diff-base; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks.`, + limits: `Hangs only as an owner test that awaits a promise that never settles, under a 3 s base limit; no busy loops, slow runs that would end, hangs in ordinary or nondeterministic tests, setup errors, loops, multi-line conditions, arrow or nested functions, two functions on one line, getters and setters, or await, new, and void statements; two source files of one package, with no re-exports, namespace imports, or calls into another package; no --only, --sample, or --diff-base; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks.`, owner: `scripts/oracle-gaps`, }, histories: { From f2cdb8e706d046e46a3aa14ac2a1f1feeffc9abd Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 13:31:48 -0700 Subject: [PATCH 056/168] fix(oracle-gaps): a nondeterministic test's verdict no longer stops a run Stop-at-first-kill ran in the worker, which did not know the nondeterministic tests. A nondeterministic test's verdict stopped its file. The main process then discarded that verdict, so a later deterministic kill in the same file never ran, and the mutant was misclassified. Each run's state now carries the file's nondeterministic tests, and their verdicts never stop the run. The set is part of the evidence id, because it changes which tests run. - Law: law 4. A nondeterministic test judges nothing, and a kill by a deterministic owner test decides (maintainer decisions v11/v14/v19, stop at first kill 2026-11-06). - Reach: the new required state "a kill after a nondeterministic verdict in its file" (witness w_flakykill), reached in both runs of fixed seed 20261105. - RED (parent 64f721fab): `npx vitest run --config scripts/oracle-gaps/vitest.config.mjs gap-report-oracle.property -t "fixed seed 20261105"` failed with "src/sut.ts:w_flakykill:127:if-false#0: indeterminate, owed killed". - GREEN: the same command passed. All three CLI oracles passed in full (26 tests, 426 s). - Mutant: the original defect, which is the RED above. - Limits: a nondeterministic test that hangs is not generated. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 7 +++- scripts/oracle-gaps/check.mjs | 40 +++++++++++++------ .../oracle-gaps/gap-report-model-oracle.mjs | 12 ++++++ .../gap-report-oracle.property.test.mjs | 3 ++ 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 9cf23eae70..29cfa479da 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -415,7 +415,8 @@ function recordAssertion() { // 2026-11-06): once a test fails with a verdict under a behavioral law, any // but reach agreement, on a reproducible route, the file's remaining tests // are skipped. The runner also runs no further file (check.mjs). A verdict -// in a test that passes kills nothing, as in the classification. +// in a test that passes kills nothing, as in the classification, and so does +// a verdict in a nondeterministic test. const reproducibleRoutes = new Set([ `fixed seed`, `fixed history`, @@ -472,10 +473,14 @@ beforeEach((context) => { inTest = true }) +// Tests whose observations varied between the two unmutated runs: their +// verdicts judge nothing, so they never stop a run. +const ignored = new Set(state.ignore ?? []) afterEach((context) => { inTest = false if ( state.stopOnKill && + !ignored.has(testKey(context.task)) && (context.task.result?.state === `fail` || (context.task.result?.errors?.length ?? 0) > 0) && [...verdicts.values()].some( diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 12887bdad8..f64fa60616 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -294,10 +294,17 @@ export default mergeConfig(base, { context, out, file, - { mutant = null, seed = null, limitMs, select = null, stopOnKill = false } = {}, + { + mutant = null, + seed = null, + limitMs, + select = null, + stopOnKill = false, + ignore = [], + } = {}, ) { const capture = join(out, `run-${process.pid}-${++runCounter}.jsonl`) - const state = { mutant, select, capture, seed, stopOnKill } + const state = { mutant, select, capture, seed, stopOnKill, ignore } // Vitest 3 and 4 both expose the root project's `provide`; the state file // remains for setups that cannot `inject`. context.getRootProject?.().provide(`oracleGaps`, state) @@ -432,18 +439,19 @@ export default mergeConfig(base, { }) const keyFor = (copy, file) => (key) => key.startsWith(`${copy} > `) ? `${file}${key.slice(copy.length)}` : key + // A file's test keys, named as its copy names them. + const inCopy = (copy, file, keys) => + keys.map((key) => + key.startsWith(`${file} > `) ? `${copy}${key.slice(file.length)}` : key, + ) const units = Object.fromEntries( copies.map((copy, i) => [ copy, { mutant: items[i].mutant ?? null, - select: - items[i].select?.map((key) => - key.startsWith(`${items[i].file} > `) - ? `${copy}${key.slice(items[i].file.length)}` - : key, - ) ?? null, + select: items[i].select ? inCopy(copy, items[i].file, items[i].select) : null, stopOnKill: items[i].stopOnKill ?? false, + ignore: inCopy(copy, items[i].file, items[i].ignore ?? []), }, ]), ) @@ -656,8 +664,10 @@ export default mergeConfig(base, { // Each file's imports in its unmutated run. A run that timed out reports // none, so it is stored under these: a mutant changes no import. const fileInputs = new Map() - const idOf = (file, spec, select) => - sha1(JSON.stringify([globalKey, file, spec, select ?? null])) + // The tests whose verdicts cannot stop a run (see `ignoredIn`) are part + // of a unit's id: they change which tests run. + const idOf = (file, spec, select, ignore = []) => + sha1(JSON.stringify([globalKey, file, spec, select ?? null, ignore])) // A run is reused when every input of one of its stored versions is // unchanged; undoing an edit finds the version from before it. const lookup = (id, index) => @@ -676,7 +686,7 @@ export default mergeConfig(base, { } async function unit(context, file, spec, runOptions = {}) { const index = indexOf.get(context) - const id = idOf(file, spec, runOptions.select) + const id = idOf(file, spec, runOptions.select, runOptions.ignore) const looked = Date.now() const valid = lookup(id, index) span(`lookup`, looked, { file: file.split(`/`).at(-1), spec, hit: Boolean(valid) }) @@ -711,7 +721,7 @@ export default mergeConfig(base, { const got = new Array(requests.length) let rest = [] requests.forEach((request, i) => { - const id = idOf(request.file, request.spec, request.select) + const id = idOf(request.file, request.spec, request.select, request.ignore) const valid = lookup(id, index) if (valid) { evidence.reused += 1 @@ -726,6 +736,7 @@ export default mergeConfig(base, { mutant: r.mutant, select: r.select, stopOnKill: r.stopOnKill, + ignore: r.ignore, limitMs: r.limitMs, }) break @@ -828,6 +839,9 @@ export default mergeConfig(base, { endPhase(`unmutated pass 2`, { files: files.length }) const baseline = baselineOf(first.rows, second.rows, oracleFiles) const { nondeterministic } = baseline + // A nondeterministic test judges nothing, so its verdict must not stop + // a run at its first kill: a later test of the file may decide. + const ignoredIn = (file) => [...nondeterministic].filter((key) => fileOf(key) === file).sort() log( `unmutated: ${[...first.rows.values()].filter((r) => !r.background).length} tests in ${first.seconds.toFixed(1)}s and ${second.seconds.toFixed(1)}s; nondeterministic: ${nondeterministic.size}`, ) @@ -929,6 +943,7 @@ export default mergeConfig(base, { select: await selectionFor(slot, file, keysOf(st)), limitMs: limitFor(file), stopOnKill: true, + ignore: ignoredIn(file), }) } const got = await unitsBatch(slot, requests) @@ -1082,6 +1097,7 @@ export default mergeConfig(base, { select: null, limitMs: limitFor(file), stopOnKill: true, + ignore: ignoredIn(file), }, ]) job.units.push(got) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 3fb223cadc..a26efb2991 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -397,6 +397,17 @@ const witness = [ judging(`b`, `fixed seed`, 7, { judges: false }), ], }, + // A nondeterministic test judges nothing, even when its verdict fires, so + // it must not stop its file: the deterministic test after it decides. + { + id: `w_flakykill`, + owners: [`a`], + body: [kill], + tests: [ + judging(`a`, `fixed seed`, 7, { flaky: true }), + judging(`a`, `fixed seed`, 7), + ], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -439,6 +450,7 @@ const captureWitness = new Set([ `w_hang`, `w_hangkill`, `w_hangsurvive`, + `w_flakykill`, ]) // ## Rendering and layout diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 8a5fe25f45..40219883a8 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -570,6 +570,8 @@ async function driver(plan, observe) { states.add(`method owned through this`) if (m.function === `w_union` && m.outcome === `killed`) states.add(`killed by a second owner`) + if (m.function === `w_flakykill` && m.outcome === `killed`) + states.add(`a kill after a nondeterministic verdict in its file`) if (m.function === `w_hang` && m.outcome === `timeout`) states.add(`a hang alone timed out`) if (m.function === `w_hangkill` && m.outcome === `killed`) @@ -1020,6 +1022,7 @@ const required = [ `ordinary tests left out by default`, `two authored mutants judged apart`, `timeout`, + `a kill after a nondeterministic verdict in its file`, `a hang alone timed out`, `a hang in one owner and a kill in another killed`, `a hang in one owner and a survivor in another timed out`, From 53fa8dc4ce8325206e3da6831cbb2b603addf5dd Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 13:39:12 -0700 Subject: [PATCH 057/168] test(offline-transactions): judge an idle executor's timers in the FIFO oracle An executor with nothing left to run must hold no timer. One that keeps waking with nothing to do spins the CPU for as long as the app runs. Until now the oracle judged no timers at all, and its driver advanced the clock in 60 s steps. So an executor that re-armed a 0 ms retry forever (mutant M134, `if (nextRetryTime === null)` made `if (false)`) changed no observation and hung the driver. The driver now runs retry timers one at a time, at most 1,000 after the deadline, then observes how many timers the executor still holds. - Law: the idle law. Once every plan has played out, the executor holds no timer (maintainer decision, 2026-11-06: an idle spin is a bad bug). While work is pending, timer counts and polling strategy stay outside the judgment. - Reach: the law applies at the end of every history. The new measured state `queue drained` (the outbox ends empty) was reached 25 and 26 times in the two campaigns. - RED: with M134 applied by hand, the old driver did not finish; the 120 s test timeout failed it. The CLI reported M134 as `timeout`. - GREEN: `npx vitest run tests/fifo-retry-oracle.property.test.ts` in packages/offline-transactions passed 19 tests in 1.1 s, including the new calibration "an idle executor keeps a timer armed" (law idle). - Mutant: with M134 applied by hand, fixed seed 20260916 failed at checkpoint "idle" (law idle) in 185 ms. In the gap CLI, M134 is now `killed` under law idle. A cold FIFO run went from 31 s to 23 s with no hang cut-offs, and every other class is unchanged. - Limits: at most 1,000 retry timers after the deadline (raise TIMER_STEPS if a legal history needs more). The clock advances before the deadline are not bounded. An executor that waits on a promise that never settles is not bounded by the step budget. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/fifo-retry-oracle.property.test.ts | 70 +++++++++++++++---- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index edeb26df27..efb61dd544 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -595,7 +595,7 @@ that test identifiers must copy production's private data structures. | Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | | PowerSync | Fixed tests only | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are covered by fixed tests in `correctness.test.ts`; no oracle owns them (tracked in #2264). | | SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared-handle driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | -| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. Reach comes from production. Four states are state probes in `TransactionExecutor` (`offline-transactions#retry-before-peer`, `#permanent-failure-before-peer`, `#phase-write-fault`, `#fault-before-peer`); `predict` says from the plans alone which histories enter each, and reach agreement must hold in every history. The storage fake reports `deletion fault` when it refuses the removal, and the driver reports `retried head` and `retried tail` as measured states. Four constructed histories enter the gating states on every run, and each campaign reaches every gating state at least 13 times. Mutants that let a ready tail pass the delayed head, retry without backoff, drop a permanent failure's caller rejection, swallow a phase-write error, keep an outbox row after removal, or keep running after a storage fault fail the oracle, and misplaced or removed probes fail reach agreement. A mutant that keeps the scheduler's active ID after a failure stalls the driver, so it is killed only by the test timeout, not by a named law. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The check labels a call after a storage fault as FIFO order rather than the stop law. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per campaign for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight fixed histories enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. Reach comes from production. Four states are state probes in `TransactionExecutor` (`offline-transactions#retry-before-peer`, `#permanent-failure-before-peer`, `#phase-write-fault`, `#fault-before-peer`); `predict` says from the plans alone which histories enter each, and reach agreement must hold in every history. The storage fake reports `deletion fault` when it refuses the removal, and the driver reports `retried head` and `retried tail` as measured states. Four constructed histories enter the gating states on every run, and each campaign reaches every gating state at least 13 times. Mutants that let a ready tail pass the delayed head, retry without backoff, drop a permanent failure's caller rejection, swallow a phase-write error, keep an outbox row after removal, keep running after a storage fault, or keep arming a retry timer once nothing is left to run fail the oracle, and misplaced or removed probes fail reach agreement. The idle law (an executor with nothing left to run holds no timer) is the maintainer's decision of 2026-11-06: an idle spin is a bad bug. The driver runs at most 1,000 retry timers after the deadline, one at a time, so an executor that never goes quiet ends the history at once and the idle law judges it; while work is pending, timer counts and polling strategy stay outside the judgment. A mutant that keeps the scheduler's active ID after a failure stalls the driver, so it is killed only by the test timeout, not by a named law. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The check labels a call after a storage fault as FIFO order rather than the stop law. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per campaign for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight fixed histories enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | | Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | | Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. MultiSet consolidation (`ivm.multiset-consolidate`, [owner](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts)) keeps one record per identity under the keyed, primitive, or structural rule, with the first record and the summed multiplicity, and leaves its input unchanged. Six declared states are production probes (`db-ivm#...` in `packages/db-ivm/src/multiset.ts`): the three rules, `records merged`, `join tuples merged`, and `only pairs without a key`. The model predicts each one from the history alone, and reach counts a state only where the probe and the prediction agree (`reach agreement`). Each state has a constructed history, so every campaign enters it; the grammar modes are generator dimensions, not reach. Sixteen mutants are killed, each by both campaigns: wrong identity rules, merge sums, retained records, zero-sum output, -0 handling, input mutation, and wrong or missing probe placements. Limits: output order is not part of the law; structural identity uses a 32-bit hash, so no injectivity is claimed; the probes are development-only behind a temporary guard (`size:compare` measures zero shipped bytes), and they show where production enters a state while the check judges the output. The empty multiset takes rule 1 by the law's vacuous "every record" reading, which no output depends on. | | Mixed-kind ordered prefixes | Bounded | Eager BasicIndex and BTreeIndex paths compare ascending and descending direct and live-query windows with an unindexed scan across mixed value kinds and paired source changes. One retained on-demand BTree history checks settled windows after a number/bigint transition, a finite request for a missing row, and a cursor request after index eligibility changes. Other provider paging histories remain open. | diff --git a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts index 98d6560f8b..1c93531fbc 100644 --- a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts @@ -35,14 +35,19 @@ declareOracle({ * - Eligible work runs when it becomes eligible: at the head's deadline the * executor runs the head and every ready transaction behind it, in order, * until a retriable failure or a storage fault. This is a bounded liveness - * check on a controlled clock, not a wall-clock latency promise; timer count - * and polling strategy are outside the judgment. The `nextAttemptAt` + * check on a controlled clock, not a wall-clock latency promise; while work + * is pending, timer count and polling strategy are outside the judgment. + * The `nextAttemptAt` * eligibility boundary itself is the KeyScheduler oracle's * (`KeyScheduler-oracle.property.test.ts`). * - A retriable provider failure retries the same transaction after its * backoff; nothing behind it runs first. * - A `NonRetriableError` removes its row and rejects its caller with that * error; queued rows then continue in creation order. + * - An idle executor goes quiet: once every plan has played out, so that no + * transaction is due or waiting to retry, it holds no timer. An executor + * that keeps waking with nothing to do spins the CPU for as long as the app + * runs (maintainer decision, 2026-11-06: an idle spin is a bad bug). * - Success means the provider returned and the storage adapter acknowledged * the outbox deletion. If recording the `deletion-pending` phase or * removing the row fails, the caller rejects with the storage error and @@ -68,12 +73,15 @@ declareOracle({ * the scheduler, timers, or the outbox. At the deadline, attempts run until * the first retriable failure (whose backoff is at least a second) or a * storage fault; after every timer has run, every plan has played out up to - * the first storage fault. + * the first storage fault, and the executor holds no timer. * * Driver: a real `TransactionExecutor`, `KeyScheduler`, and `OutboxManager` * over fake storage that fails as planned, with a controlled virtual clock * and jitter off. It records provider calls, the outbox, and each caller's - * settlement at each checkpoint. + * settlement at each checkpoint, and after the last one the number of timers + * the executor still holds. It runs the retry timers one at a time, at most + * `TIMER_STEPS` of them, so an executor that never goes quiet cannot keep + * the driver busy forever; the idle law then judges the timers it left. * * Reach comes from what production did, never from the grammar's labels. The * four states that the model can predict from the plans are state probes @@ -87,7 +95,9 @@ declareOracle({ * row behind it). `deletion fault` is reported by the storage fake when it * rejects the removal that the executor issued: production reached the * removal and the storage refused it. `retried head` and `retried tail` are - * measured only: the driver reports each provider call that fails retriably. + * measured only: the driver reports each provider call that fails retriably, + * and `queue drained` when the outbox ends empty, the history where an idle + * executor has nothing left at all. * * Limits: an online executor with one mutation function; no leadership * changes, no `shouldRetry` or `beforeRetry` hooks, no storage fault on a @@ -192,6 +202,9 @@ function playOut({ plans }: History, untilRetry: boolean): State { return { calls, outbox: [...remaining], settled } } +/** Timers an executor with nothing left to run may hold: none (idle law). */ +const idleTimers = 0 + function fifoModel(history: History) { const queued: State = { calls: [], @@ -213,7 +226,7 @@ const permanentBeforePeer = `permanent failure before a peer` const phaseWriteFault = `phase write fault` const faultBeforePeer = `fault before a queued peer` const deletionFault = `deletion fault` -const measuredStates = [`retried head`, `retried tail`] as const +const measuredStates = [`retried head`, `retried tail`, `queue drained`] as const /** * Which probe-bound states a history enters, from its plans alone. The @@ -243,6 +256,12 @@ function predictedStates({ plans }: History): Record { // Production driver // --------------------------------------------------------------------------- +// Timers the driver runs after the deadline. A history's retries need a few +// dozen; an executor that keeps arming timers is cut off here (in a few +// milliseconds of virtual time) and judged by the idle law. Raise it if a +// legal history needs more. +const TIMER_STEPS = 1000 + async function driveFifo( { delay, plans }: History, observe: (checkpoint: string, value: unknown) => void, @@ -345,10 +364,13 @@ async function driveFifo( await record(`just before deadline`) await vi.advanceTimersByTimeAsync(1) await record(`at deadline`) - // Backoff doubles from a second to a minute; run every retry timer. - for (let round = 0; round < 20 && vi.getTimerCount() > 0; round++) - await vi.advanceTimersByTimeAsync(60_000) + // Backoff doubles from a second to a minute; run every retry timer, one + // at a time, up to the step budget. + for (let step = 0; step < TIMER_STEPS && vi.getTimerCount() > 0; step++) + await vi.advanceTimersToNextTimerAsync() await record(`after every retry`) + if ((await outbox.getAll()).length === 0) observe(`reach`, `queue drained`) + observe(`idle`, vi.getTimerCount()) } catch (error) { primary = error failed = true @@ -381,6 +403,7 @@ async function driveFifo( // equal the model's. Calls that are not a prefix of the full attempt order // break the FIFO law; a correct prefix of the wrong length at a cut breaks // the progress law (a late wakeup, or a queue that stalls); settlements and the outbox after a fault are the stop law. +// After every retry, a timer the executor still holds breaks the idle law. function checkFifo( _history: History, @@ -426,7 +449,15 @@ function checkFifo( ) if (wrong) return wrong } - return pass + return ( + differs( + `idle`, + `idle`, + trace.find((entry) => entry.checkpoint === `idle`)?.value, + idleTimers, + `the executor still holds timers with nothing left to run`, + ) ?? pass + ) } // --------------------------------------------------------------------------- @@ -438,9 +469,9 @@ const ok: Plan = { failures: 0, final: `success`, storageFault: `none` } oracleProperty({ id: `offline.fifo-retry`, law: { - statement: `The executor runs transactions one at a time in creation order: a ready tail waits for a delayed or retrying head, eligible work runs when its deadline passes, a permanent failure lets queued rows continue, and a failed phase write or deletion rejects its caller with the storage error and stops the queue.`, - authority: `packages/offline-transactions/README.md: FIFO Sequential Processing, Retry decisions, and offline-execution settlement; the eligibility boundary from KeyScheduler-oracle.property.test.ts`, - limits: `An online executor with one mutation function, no retry hooks, and jitter off; no leadership changes; no storage fault on a permanent failure's removal; backoff lengths, timer counts, and polling strategy are not judged; liveness on a controlled clock, not wall-clock latency.`, + statement: `The executor runs transactions one at a time in creation order: a ready tail waits for a delayed or retrying head, eligible work runs when its deadline passes, a permanent failure lets queued rows continue, a failed phase write or deletion rejects its caller with the storage error and stops the queue, and an executor with nothing left to run holds no timer.`, + authority: `packages/offline-transactions/README.md: FIFO Sequential Processing, Retry decisions, and offline-execution settlement; the eligibility boundary from KeyScheduler-oracle.property.test.ts; the idle law is the maintainer's decision of 2026-11-06 (an idle spin is a bad bug)`, + limits: `An online executor with one mutation function, no retry hooks, and jitter off; no leadership changes; no storage fault on a permanent failure's removal; backoff lengths are not judged, and timer counts and polling strategy only once nothing is left to run; at most 1000 retry timers after the deadline; liveness on a controlled clock, not wall-clock latency.`, owner: `Offline execution`, }, histories: { @@ -567,9 +598,18 @@ oracleProperty({ driver: driveFifo, check: checkFifo, // Wrong answers the check must reject: a ready tail that passes the - // delayed head, a scheduler that never wakes at eligibility, and a queue - // that keeps running after a storage fault. + // delayed head, a scheduler that never wakes at eligibility, a queue + // that keeps running after a storage fault, and an idle executor that + // keeps a timer armed. calibrations: [ + { + name: `an idle executor keeps a timer armed`, + law: `idle`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `idle` ? { ...entry, value: 1 } : entry, + ), + }, { name: `a ready tail passes the delayed head`, law: `fifo`, From 4920f9a1afeff3399f6f4edc4329565987a90290 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 13:39:26 -0700 Subject: [PATCH 058/168] test(oracle-gaps): witness a kill in one owner after a gap in another A random-only verdict in owner a must not end the mutant: owner b's kill still decides. This guards the coming one-batch-per-group scheduler, whose in-worker skip must follow kills only. - Law: law 4, a kill by any owner decides. - Reach: "a kill in one owner after a gap in another" (w_crosskill), reached in both runs of fixed seed 20261105. - GREEN on the current scheduler. The RED-capable mutant (skip a mutant's later copies after any failure) needs the new scheduler; it will be run there. Co-authored-by: Isaac --- scripts/oracle-gaps/gap-report-model-oracle.mjs | 9 +++++++++ scripts/oracle-gaps/gap-report-oracle.property.test.mjs | 3 +++ 2 files changed, 12 insertions(+) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index a26efb2991..293b8bda81 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -408,6 +408,14 @@ const witness = [ judging(`a`, `fixed seed`, 7), ], }, + // A gap in one owner does not stop the mutant: a later owner's kill + // still decides. + { + id: `w_crosskill`, + owners: [`a`, `b`], + body: [kill], + tests: [judging(`a`, `random seed`, 7), judging(`b`, `fixed seed`, 7)], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -451,6 +459,7 @@ const captureWitness = new Set([ `w_hangkill`, `w_hangsurvive`, `w_flakykill`, + `w_crosskill`, ]) // ## Rendering and layout diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 40219883a8..a61e02742b 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -570,6 +570,8 @@ async function driver(plan, observe) { states.add(`method owned through this`) if (m.function === `w_union` && m.outcome === `killed`) states.add(`killed by a second owner`) + if (m.function === `w_crosskill` && m.outcome === `killed`) + states.add(`a kill in one owner after a gap in another`) if (m.function === `w_flakykill` && m.outcome === `killed`) states.add(`a kill after a nondeterministic verdict in its file`) if (m.function === `w_hang` && m.outcome === `timeout`) @@ -1023,6 +1025,7 @@ const required = [ `two authored mutants judged apart`, `timeout`, `a kill after a nondeterministic verdict in its file`, + `a kill in one owner after a gap in another`, `a hang alone timed out`, `a hang in one owner and a kill in another killed`, `a hang in one owner and a survivor in another timed out`, From 210db70fe7d4270986dc52506a113def6e6740af Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 14:08:59 -0700 Subject: [PATCH 059/168] perf(oracle-gaps): run a group's owner files in one batch The scheduler ran each group of four mutants in rounds: one owner file per mutant per Vitest run. Each run starts a fresh worker (about 600 ms), so a cold FIFO run spent 126 runs, 51 of them on a single item. Now one batch holds every owner file of the group's mutants, file by file. A copy whose mutant an earlier copy in the worker killed skips its tests and its module reset. A file after a kill whose unit was reused is not run. A mutant's units are its files in order, up to and including the first that kills it, however they were found: run, reused, or rerun after a hang. So a result does not depend on how its evidence was produced (law 9). A file left without a unit and no earlier kill is an invariant violation, and the check fails. - Law: unchanged. Law 4 (a kill by any owner decides; stop at first kill) and law 9. - Reach: the CLI oracle's constructed witnesses, including "a kill in one owner after a gap in another" and the hang states, were reached in both runs of fixed seed 20261105. - RED: not applicable; this is a speed change. GREEN on the parent. - GREEN: `npx vitest run --config scripts/oracle-gaps/vitest.config.mjs gap-report-oracle.property -t "fixed seed 20261105"` passed. A cold FIFO run (offline-transactions, TransactionExecutor.ts, seed 1) went from 23 s to 19 s. Batches went from 126 to 37 and context time from 210 s to 123 s. The classes, killing tests, and laws of all 145 mutants are identical. - Mutant: a worker that marks a mutant killed after any failed test failed the oracle: the CLI stopped with "sut.ts:M82: tests/b.test.ts has no unit and no earlier kill". - Limits: authored mutants still run one file per batch. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 22 ++++-- scripts/oracle-gaps/check.mjs | 99 ++++++++++++++++++--------- 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 29cfa479da..1a4bce6a15 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -38,7 +38,13 @@ const run = // then load the package's modules afresh, under its mutant, with no state // left by an earlier copy. const unit = run.units?.[globalThis.__vitest_worker__?.filepath] -if (run.units) vi.resetModules() +// The mutants that a copy earlier in this batch killed. A later copy of the +// same mutant runs nothing: the mutant is decided (stop at first kill). +if (globalThis.__oracleGapsKilled?.capture !== run.capture) + globalThis.__oracleGapsKilled = { capture: run.capture, mutants: new Set() } +const killed = globalThis.__oracleGapsKilled.mutants +const skippedCopy = unit !== undefined && killed.has(unit.mutant) +if (run.units && !skippedCopy) vi.resetModules() const state = unit ? { ...run, ...unit } : run // The seed travels with each run's state, so a worker that a warm context // started under an earlier check's seed runs under this run's seed. @@ -424,7 +430,7 @@ const reproducibleRoutes = new Set([ `case`, `enumerated`, ]) -let stopped = false +let stopped = skippedCopy let currentTask // When the current test started, so its row records how long it took. let testStarted = 0 @@ -487,8 +493,10 @@ afterEach((context) => { (v) => v.law !== `reach agreement` && reproducibleRoutes.has(v.route ?? route), ) - ) + ) { stopped = true + if (unit) killed.add(unit.mutant) + } if (!state.capture) return appendFileSync( state.capture, @@ -529,6 +537,10 @@ afterAll(() => { state.capture, `${JSON.stringify({ key: `${currentFile} > (background)`, background: true, hits: [...background] })}\n`, ) - // A batch's watchdog reads this row to know that the copy finished. - appendFileSync(state.capture, `${JSON.stringify({ file: thisFile, closed: true })}\n`) + // A batch's watchdog reads this row to know that the copy finished; a + // skipped copy says so, and its run is no evidence. + appendFileSync( + state.capture, + `${JSON.stringify({ file: thisFile, closed: true, ...(skippedCopy ? { skipped: true } : {}) })}\n`, + ) }) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index f64fa60616..ec4d6b0624 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -493,16 +493,21 @@ export default mergeConfig(base, { const result = await Promise.race([running, watchdog]) clearTimeout(timer) const seconds = (Date.now() - started) / 1000 + // Each copy's rows, and the copies that skipped their tests because an + // earlier copy in the batch killed their mutant. + const skipped = new Set() const rowsOf = () => { const rows = new Map(copies.map((copy) => [copy, []])) if (existsSync(capture)) for (const line of readFileSync(capture, `utf8`).split(`\n`).filter(Boolean)) { const row = JSON.parse(line) const copy = copies.find((c) => row.key?.startsWith(`${c} > `) || row.file === c) + if (copy && row.skipped) skipped.add(copy) if (copy && !row.closed) rows.get(copy).push(row) } return rows } + const skippedUnit = (item) => ({ file: item.file, skipped: true }) try { if (result === undefined) { await Promise.race([ @@ -515,6 +520,7 @@ export default mergeConfig(base, { const rows = rowsOf() return { units: items.slice(0, current).map((item, i) => { + if (skipped.has(copies[i])) return skippedUnit(item) const map = keyFor(copies[i], item.file) const own = rows.get(copies[i]).map((row) => ({ ...row, key: map(row.key) })) return { @@ -545,6 +551,7 @@ export default mergeConfig(base, { return { units: items.map((item, i) => { const copy = copies[i] + if (skipped.has(copy)) return skippedUnit(item) const map = keyFor(copy, item.file) const modules = result.testModules.filter((m) => m.moduleId === copy) return { @@ -715,8 +722,10 @@ export default mergeConfig(base, { // Units for many requests at once. Each is looked up first; the rest run // as one batch (runBatch). A batch cut off by a hang records that item // as timed out and reruns the rest in a fresh context; a batch with - // errors outside any test runs its items alone. - async function unitsBatch(slot, requests) { + // errors outside any test runs its items alone. `needed` says, from the + // units found so far, whether a request must still run; a request that + // need not run, or whose copy skipped its tests, gets no unit. + async function unitsBatch(slot, requests, needed = () => true) { const index = indexOf.get(slot.context) const got = new Array(requests.length) let rest = [] @@ -728,20 +737,24 @@ export default mergeConfig(base, { got[i] = valid.unit } else rest.push({ ...request, i, id }) }) - while (rest.length > 0) { + for (;;) { + rest = rest.filter((r) => needed(r, got)) + if (rest.length === 0) break const batch = await runBatch(slot.context, options.out, rest, options.seed ?? null) if (batch.unattributed) { for (const r of rest) - got[r.i] = await unit(slot.context, r.file, r.spec, { - mutant: r.mutant, - select: r.select, - stopOnKill: r.stopOnKill, - ignore: r.ignore, - limitMs: r.limitMs, - }) + if (needed(r, got)) + got[r.i] = await unit(slot.context, r.file, r.spec, { + mutant: r.mutant, + select: r.select, + stopOnKill: r.stopOnKill, + ignore: r.ignore, + limitMs: r.limitMs, + }) break } batch.units.forEach((u, k) => { + if (u.skipped) return got[rest[k].i] = u remember(rest[k].id, rest[k].file, u, index) evidence.computed += 1 @@ -923,20 +936,27 @@ export default mergeConfig(base, { contexts[slot.index] = slot.context span(`replace context`, started, { slot: slot.index }) } - // Mutants run in groups per context. In each round, every undecided - // mutant of the group runs its next owner file, all in one batch; a - // mutant is decided once an owner kills it or every owner has run. The + // Mutants run in groups per context, all in one batch: each undecided + // mutant's owner files, file by file, so every mutant's first file runs + // before any mutant's second. A copy whose mutant an earlier copy killed + // skips its tests, and a file after a kill is not run at all. A mutant's + // units are those of its files in order, up to and including the first + // that kills it, however they were found (run, reused, or rerun after a + // hang), so a result never depends on how its evidence was produced. The // ordinary tier, when asked for, runs the same way afterwards. const GROUP = 4 - const rounds = async (slot, states, filesOf, keysOf) => { - for (;;) { - const active = states.filter((st) => !st.done) - if (active.length === 0) return - const requests = [] + const kills = (u) => outcomeOf([u], baseline.nondeterministic).outcome === `killed` + const runOwners = async (slot, states, filesOf, keysOf) => { + const active = states.filter((st) => !st.done) + const requests = [] + const depth = Math.max(0, ...active.map((st) => filesOf(st).length)) + for (let position = 0; position < depth; position += 1) for (const st of active) { - const file = filesOf(st)[st.next] + const file = filesOf(st)[position] + if (file === undefined) continue requests.push({ st, + position, file, spec: `mutant:${st.mutant.number}`, mutant: st.mutant.number, @@ -946,17 +966,29 @@ export default mergeConfig(base, { ignore: ignoredIn(file), }) } - const got = await unitsBatch(slot, requests) - got.forEach((u, i) => { - const st = requests[i].st - st.units.push(u) - st.next += 1 - if ( - st.next >= filesOf(st).length || - outcomeOf(st.units, baseline.nondeterministic).outcome === `killed` - ) - st.done = true - }) + // A request is needed until one of its mutant's earlier files kills. + const killedBefore = (request, got) => + requests.some( + (other, i) => + other.st === request.st && + other.position < request.position && + got[i] !== undefined && + kills(got[i]), + ) + const got = await unitsBatch(slot, requests, (request, found) => !killedBefore(request, found)) + for (const st of active) { + for (const [i, request] of requests.entries()) { + if (request.st !== st) continue + // Only a kill earlier in the mutant's files leaves a file without + // a unit. + if (got[i] === undefined) { + if (st.units.some(kills)) break + throw new Error(`${st.mutant.id}: ${relative(request.file)} has no unit and no earlier kill`) + } + st.units.push(got[i]) + if (kills(got[i])) break + } + st.done = true } } async function evaluateGroup(group, slot) { @@ -970,11 +1002,10 @@ export default mergeConfig(base, { owners, targets, units: [], - next: 0, done: owners.size === 0 || targets.oracleTargets.length === 0, } }) - await rounds( + await runOwners( slot, states, (st) => st.targets.oracleTargets, @@ -995,9 +1026,9 @@ export default mergeConfig(base, { }), } if (needsOrdinary(st.result, st.targets)) - ordinary.push({ ...st, units: [], next: 0, done: false }) + ordinary.push({ ...st, units: [], done: false }) } - await rounds( + await runOwners( slot, ordinary, (st) => st.targets.testTargets, From 0f69aca28aecb579c52d8d9146028ab89b51dc2c Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 14:27:13 -0700 Subject: [PATCH 060/168] perf(oracle-gaps): check every test selection beside the second pass A mutant's test selection (its owner file's tests from the first one that reached its site) was checked when the mutant first asked for it: one single run each, serially, before each context's first batch. Reach comes from the first unmutated pass alone, so every selection the mutants will ask for is now known before the second pass. The checks run as batches on the contexts the second pass leaves idle. Only a selection that no plan foresaw (the ordinary tier's) is still checked lazily. - Law: unchanged. A selection is used only when the left-out tests change nothing that the rest record (law 4, test selection). - Reach: the CLI oracle's selection witnesses (w_shared, w_prop, w_late) were reached in both runs of fixed seed 20261105. - RED: not applicable; this is a speed change. GREEN on the parent. - GREEN: the CLI oracle's fixed seed 20261105 passed. A cold FIFO run went from 19.2 s to 18.6 s with no lazy checks left in the mutant phase (12 planned). The classes, killing tests, and laws of all 145 mutants are identical. - Mutant: a planned check that accepts every selection failed the oracle with "src/sut.ts:w_shared:96:if-true#0: comparison-gap, owed generator-gap". - Limits: checks on cold contexts pay the first transform of their files. Running them on the contexts that ran those files, or warming idle contexts, was measured and saved nothing. Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 156 ++++++++++++++++++++++------------ 1 file changed, 103 insertions(+), 53 deletions(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index ec4d6b0624..32869465b7 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -790,7 +790,17 @@ export default mergeConfig(base, { // and turned a kill into a timeout, so the base defaults to 10 s // (`--hang-limit`); hangs are cached, so it costs only cold runs. const limitFor = (file) => options.hangLimit + 3000 * (fileSeconds.get(file) ?? 10) - const runAcross = async (files, pass) => { + // A cancelled run can leave work behind, so its context is replaced. + const replace = async (slot) => { + const started = Date.now() + await Promise.race([slot.context.close().catch(() => undefined), sleep(10_000)]) + slot.context = await warmContext(settings, slot.index) + await globSpecs(slot.context) + contexts[slot.index] = slot.context + span(`replace context`, started, { slot: slot.index }) + } + // `after(index)` is more work for context `index` once its files ran. + const runAcross = async (files, pass, after = async () => {}) => { const parts = contexts.map(() => []) files.forEach((file, index) => parts[index % contexts.length].push(file)) const units = ( @@ -799,6 +809,7 @@ export default mergeConfig(base, { const done = [] for (const file of parts[index]) done.push(await unit(context, file, `unmutated:${pass}`)) + await after(index) return done }), ) @@ -848,25 +859,7 @@ export default mergeConfig(base, { if (fileOf(key) === file) first.rows.delete(key) } } - const second = await runAcross(files, 2) - endPhase(`unmutated pass 2`, { files: files.length }) - const baseline = baselineOf(first.rows, second.rows, oracleFiles) - const { nondeterministic } = baseline - // A nondeterministic test judges nothing, so its verdict must not stop - // a run at its first kill: a later test of the file may decide. - const ignoredIn = (file) => [...nondeterministic].filter((key) => fileOf(key) === file).sort() - log( - `unmutated: ${[...first.rows.values()].filter((r) => !r.background).length} tests in ${first.seconds.toFixed(1)}s and ${second.seconds.toFixed(1)}s; nondeterministic: ${nondeterministic.size}`, - ) - const ownersOf = (mutant) => owned.of(mutant.source, mutant.function) - - // 3–5. Mutants. - const diffs = new Map( - options.sources.map((source) => [source, diffLines(pkg, options.diffBase, source)]), - ) - const results = [] - const started = Date.now() let queue = mutants.filter((m) => !options.only || options.only.has(m.id)) if (options.sample !== undefined) { // A fixed-seed shuffle, so a survey samples the same mutants every run. @@ -882,28 +875,103 @@ export default mergeConfig(base, { return own.slice(0, options.sample) }) } - // Results are appended as they finish, so a stopped survey keeps them. - const partial = join(options.out, `results.jsonl`) - rmSync(partial, { force: true }) // The tests of a file that a mutant must run: every test from the first // one that reached its site. A later test can see the mutant through // state that a reaching test changed, so only the tests before the // first reaching one are left out, and only when running the rest with // no mutant active reproduces what they recorded when the whole file - // ran: a later test can depend on state that a left-out test set. - const selectionChecks = new Map() - const selectionFor = async (slot, file, keys) => { - const all = [...baseline.firstRows.keys()].filter( - (k) => fileOf(k) === file && !baseline.firstRows.get(k).background, + // ran: a later test can depend on state that a left-out test set. Reach + // comes from the first pass alone, so every mutant's selections are + // known before the second, and their checks run beside it, on the + // contexts it leaves idle. + const selectionOf = (file, keys) => { + if (keys.some((k) => k === `${file} > (background)`)) return null + const all = [...first.rows.keys()].filter( + (k) => fileOf(k) === file && !first.rows.get(k).background, ) const reaching = new Set(keys) - if (keys.some((k) => k === `${file} > (background)`)) return null - const first = all.findIndex((k) => reaching.has(k)) - if (first <= 0) return null - const selection = all.slice(first) - const id = `${file}\0${selection.join(`\0`)}` - // The check runs once per selection, even when several contexts ask - // for it at the same time. + const at = all.findIndex((k) => reaching.has(k)) + return at <= 0 ? null : all.slice(at) + } + const selectionId = (file, selection) => `${file}\0${selection.join(`\0`)}` + const reproduces = (selection, run) => { + const rows = new Map(run.rows ?? []) + return ( + !run.timedOut && + outcomeOf([run]).outcome === `survived` && + selection.every( + (k) => + rows.get(k)?.hash === first.rows.get(k)?.hash && + rows.get(k)?.observed === first.rows.get(k)?.observed, + ) + ) + } + const selectionChecks = new Map() + const planned = new Map() + const reach = baselineOf(first.rows, first.rows, oracleFiles) + for (const mutant of queue) { + const owners = ownersOf(mutant) + if (owners.size === 0) continue + const { oracleKeys, oracleTargets } = targetsOf(mutant, reach, owners, oracleFiles) + for (const file of oracleTargets) { + const selection = selectionOf(file, oracleKeys) + if (selection) planned.set(selectionId(file, selection), { file, selection }) + } + } + // Each context's share of the checks: contexts that run no second-pass + // file take theirs first. + const shares = contexts.map(() => []) + const byLoad = contexts + .map((_, index) => index) + .sort((a, b) => (a < files.length) - (b < files.length) || a - b) + ;[...planned.entries()].forEach(([id, item], i) => + shares[byLoad[i % byLoad.length]].push({ id, ...item }), + ) + const checkShare = async (index) => { + const share = shares[index] + if (share.length === 0) return + const slot = { context: contexts[index], index } + const runs = await unitsBatch( + slot, + share.map(({ file, selection }) => ({ + file, + spec: `selected`, + mutant: null, + select: selection, + limitMs: limitFor(file), + stopOnKill: false, + })), + ) + share.forEach(({ id, selection }, i) => + selectionChecks.set(id, Promise.resolve(reproduces(selection, runs[i]))), + ) + } + const second = await runAcross(files, 2, checkShare) + endPhase(`unmutated pass 2`, { files: files.length, selections: planned.size }) + const baseline = baselineOf(first.rows, second.rows, oracleFiles) + const { nondeterministic } = baseline + // A nondeterministic test judges nothing, so its verdict must not stop + // a run at its first kill: a later test of the file may decide. + const ignoredIn = (file) => [...nondeterministic].filter((key) => fileOf(key) === file).sort() + log( + `unmutated: ${[...first.rows.values()].filter((r) => !r.background).length} tests in ${first.seconds.toFixed(1)}s and ${second.seconds.toFixed(1)}s; nondeterministic: ${nondeterministic.size}`, + ) + + // 3–5. Mutants. + const diffs = new Map( + options.sources.map((source) => [source, diffLines(pkg, options.diffBase, source)]), + ) + const results = [] + const started = Date.now() + // Results are appended as they finish, so a stopped survey keeps them. + const partial = join(options.out, `results.jsonl`) + rmSync(partial, { force: true }) + // A selection no plan foresaw (the ordinary tier's) is checked when a + // mutant first asks for it, once even when several contexts ask at once. + const selectionFor = async (slot, file, keys) => { + const selection = selectionOf(file, keys) + if (!selection) return null + const id = selectionId(file, selection) if (!selectionChecks.has(id)) selectionChecks.set( id, @@ -913,29 +981,11 @@ export default mergeConfig(base, { limitMs: limitFor(file), }) if (run.timedOut) await replace(slot) - const rows = new Map(run.rows ?? []) - return ( - !run.timedOut && - outcomeOf([run]).outcome === `survived` && - selection.every( - (k) => - rows.get(k)?.hash === baseline.firstRows.get(k)?.hash && - rows.get(k)?.observed === baseline.firstRows.get(k)?.observed, - ) - ) + return reproduces(selection, run) })(), ) return (await selectionChecks.get(id)) ? selection : null } - // A cancelled run can leave work behind, so its context is replaced. - const replace = async (slot) => { - const started = Date.now() - await Promise.race([slot.context.close().catch(() => undefined), sleep(10_000)]) - slot.context = await warmContext(settings, slot.index) - await globSpecs(slot.context) - contexts[slot.index] = slot.context - span(`replace context`, started, { slot: slot.index }) - } // Mutants run in groups per context, all in one batch: each undecided // mutant's owner files, file by file, so every mutant's first file runs // before any mutant's second. A copy whose mutant an earlier copy killed From a67d8790baf7012b76ab7b4b1480aaadde8dafc1 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 14:41:59 -0700 Subject: [PATCH 061/168] test(offline-transactions): bound oracle checkpoints in virtual time `atOracleCheckpoint` raced each checkpoint against a real 1 s timer. When a mutant left work waiting on a retry backoff, or left the executor stopped, every checkpoint in the history waited out the full second before it failed. Under mutant M145 (the call in `resetRetryDelays()` deleted) one copy of the settlement oracle took 5 s, and a cold gap run took 11.7 s on that mutant alone. `useControlledClock()` now runs a history with virtual timers and `Date` and real macrotask turns. Under it, a checkpoint moves the clock in 5 ms steps between real turns, and it fails once virtual time passes its bound. A test that fakes timers and moves them itself keeps its own clock. The settlement and connectivity-replay oracles run every history on the controlled clock. - Law: unchanged. Each checkpoint's event must happen within its bound (README FIFO, retry, and settlement contracts). The bound is now virtual time, so a loaded machine cannot fail a correct history, and a stalled history fails at once. - Reach: unchanged. Both oracles' fixed and random campaigns reach the same states. - RED: not applicable; the defect class is speed. With M145 applied by hand, the retry-record check failed with "Offline oracle checkpoint timed out: retry recovery settled" after 5 s before this change. - GREEN: `npx vitest run` in packages/offline-transactions passed 396 tests in 24 files. The settlement oracle's 87 tests take 242 ms of test time (about 400 ms before). - Mutant: with M145 applied by hand, the same check fails with the same message in 23 ms. In the gap CLI, a cold FIFO run went from 18.6 s to 16.5 s, with no test over 700 ms and identical classes, killing tests, and laws for all 145 mutants. - Limits: the other offline oracles still use real-time checkpoints. A checkpoint timeout is still an unnamed failure, not a named law verdict. Co-authored-by: Isaac --- .../tests/connectivity-replay-oracle.test.ts | 19 ++++++- .../tests/oracle-lifecycle.ts | 57 +++++++++++++++++++ ...saction-settlement-oracle.property.test.ts | 18 +++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts index 04d09da378..0e454ec8fa 100644 --- a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts +++ b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts @@ -1,8 +1,12 @@ -import { expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { NonRetriableError } from '../src/types' import { declareOracle } from '../../db/tests/oracle-declaration' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' -import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' +import { + atOracleCheckpoint, + cleanupOfflineOracle, + useControlledClock, +} from './oracle-lifecycle' /** * # May a false browser online hint strand a visible tab's durable work? @@ -29,6 +33,9 @@ import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' * transaction path; public outbox, provider calls, and commit settlement are * compared before and after notification. Baseline main fails at the visible * checkpoint with zero provider calls; the fixture uses no elapsed-time retry. + * Each history runs on a controlled clock (timers and `Date` virtual, + * macrotask turns real), so a checkpoint that a retry backoff would end + * fails at its virtual bound at once. */ function expectedAt(phase: `hidden` | `visible`): { providerCalls: number @@ -51,6 +58,14 @@ declareOracle({ model: [expectedAt], }) +let releaseClock: (() => void) | undefined +beforeEach(() => { + releaseClock = useControlledClock() +}) +afterEach(() => { + releaseClock?.() +}) + it.each([`visibilitychange`, `explicit retry`] as const)( `replays a committed outbox write on %s in a visible tab with a false online hint`, async (signal) => { diff --git a/packages/offline-transactions/tests/oracle-lifecycle.ts b/packages/offline-transactions/tests/oracle-lifecycle.ts index a96671db71..e79a9045fd 100644 --- a/packages/offline-transactions/tests/oracle-lifecycle.ts +++ b/packages/offline-transactions/tests/oracle-lifecycle.ts @@ -1,3 +1,13 @@ +import { vi } from 'vitest' + +// The real macrotask yield, taken before any test fakes the clock. +const realImmediate = globalThis.setImmediate +// How far a checkpoint moves a controlled clock between real turns. +const VIRTUAL_STEP = 5 +// Whether `useControlledClock` runs the clock. A test that fakes timers and +// moves them itself keeps checkpoints on its own clock. +let controlled = false + /** * Bound offline-oracle observation and exhaust test-owned cleanup. * @@ -12,6 +22,7 @@ export async function atOracleCheckpoint( label: string, timeout = 1000, ): Promise { + if (controlled) return atVirtualCheckpoint(promise, label, timeout) let timer: ReturnType | undefined try { return await Promise.race([ @@ -29,6 +40,52 @@ export async function atOracleCheckpoint( } } +/** + * A checkpoint on the controlled clock (`useControlledClock`). The bound is + * virtual: between real macrotask turns, which let in-memory work finish, + * the clock moves `VIRTUAL_STEP` ms, firing the timers that come due; the + * checkpoint fails once the clock has moved `timeout` ms. A wait that real + * time would end only after a backoff or a timeout therefore fails at once, + * and a loaded machine cannot slow a correct history past its bound. + */ +async function atVirtualCheckpoint( + promise: Promise, + label: string, + timeout: number, +): Promise { + let outcome: { value: T } | { error: unknown } | undefined + promise.then( + (value) => (outcome = { value }), + (error: unknown) => (outcome = { error }), + ) + for (let elapsed = 0; ; elapsed += VIRTUAL_STEP) { + await new Promise((resolve) => realImmediate(resolve)) + if (outcome) { + if (`error` in outcome) throw outcome.error + return outcome.value + } + if (elapsed + VIRTUAL_STEP >= timeout) + throw new Error(`Offline oracle checkpoint timed out: ${label}`) + await vi.advanceTimersByTimeAsync(VIRTUAL_STEP) + } +} + +/** + * Runs a history on a controlled clock: timers and `Date` are virtual, and + * macrotask turns (`setImmediate`) stay real, so in-memory work finishes + * between clock steps. Returns the release, which restores real timers. + */ +export function useControlledClock(): () => void { + vi.useFakeTimers({ + toFake: [`setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `Date`], + }) + controlled = true + return () => { + controlled = false + vi.useRealTimers() + } +} + /** * Record unhandled process rejections until `cleanup` runs. Call `cleanup` in * a `finally` block. diff --git a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts index 483bd22a96..2f37c13079 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -1,6 +1,6 @@ import fc from 'fast-check' import { createCollection, createTransaction } from '@tanstack/db' -import { afterAll, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NonRetriableError } from '../src/types' import { OutboxManager } from '../src/outbox/OutboxManager' import { @@ -19,6 +19,7 @@ import { atOracleCheckpoint, captureUnhandledRejections, cleanupOfflineOracle, + useControlledClock, } from './oracle-lifecycle' import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { TestItem } from './harness' @@ -74,6 +75,12 @@ import type { OfflineConfig } from '../src/types' * the marginal peer and width cases. Fresh production IDs exclude duplicate-ID * histories; an outcome-less provider call is not a completed history here. * + * Every history runs on a controlled clock: timers and `Date` are virtual, + * and macrotask turns stay real, so in-memory work finishes between clock + * steps. A checkpoint fails once virtual time passes its bound, so work left + * waiting on a retry backoff or a timer fails at once rather than after real + * seconds. + * * Gates expose each provider boundary. The real executor, Collection, and * outbox are the driver. At each held-provider checkpoint, the refinement * check compares exact calls, IDs, promise outcomes, durable outbox state, @@ -156,6 +163,15 @@ import type { OfflineConfig } from '../src/types' * file stand aside. */ +// Every history runs on the controlled clock (see the opening prose). +let releaseClock: (() => void) | undefined +beforeEach(() => { + releaseClock = useControlledClock() +}) +afterEach(() => { + releaseClock?.() +}) + // Each campaign is its own replay property, because a recorded path belongs to // one generator. A path replay of a campaign's property runs only that campaign. const SETTLEMENT_PROPERTY = `offline.settlement.transaction` From 9db543739672db221ca91bda905d440f8481352d Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 14:50:13 -0700 Subject: [PATCH 062/168] perf(oracle-gaps): run Vitest workers as threads by default Every batch starts a Vitest worker, and a child process costs far more to start than a worker thread. A cold FIFO run spent 22 s of system CPU. The pool is now `--pool`, `threads` by default; a package whose tests need process isolation runs with `forks`. The pool is part of the run settings, so it is part of the evidence key and re-warms the contexts when it changes. - Law: unchanged. The capture must not change what a test observes (law 3); a package whose tests need forks refuses its run under threads, because its owners fail with no mutant active. - Reach: the CLI oracle's constructed witnesses, the hang states included, were reached in both runs of fixed seed 20261105 under threads. - RED: not applicable; this is a speed change. - GREEN: the CLI oracle's fixed seed 20261105 passed under threads. A cold FIFO run went from 16.4 s to 13.9 s, and system CPU from 22 s to 16 s, with identical classes, killing tests, and laws for all 145 mutants. db-ivm (multiset.ts, hash.ts, murmur.ts) gave identical classes for all 277 mutants under forks and threads (111 s and 109 s). - Mutant: none specific; a pool that changed observations would show as a class difference in the comparisons above. - Limits: no package in the repository pins a pool, so `forks` is not yet exercised by a package that needs it. Co-authored-by: Isaac --- scripts/oracle-gaps/args.mjs | 3 +++ scripts/oracle-gaps/check.mjs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/scripts/oracle-gaps/args.mjs b/scripts/oracle-gaps/args.mjs index 03839b384a..e347db2185 100644 --- a/scripts/oracle-gaps/args.mjs +++ b/scripts/oracle-gaps/args.mjs @@ -47,6 +47,9 @@ export function parseArgs(argv, cwd = process.cwd()) { // The base of each mutant run's time limit, in milliseconds; the limit // adds three times the file's unmutated duration (see limitFor). hangLimit: Number(args[`hang-limit`] ?? 10000), + // Vitest's worker pool. Threads start much faster than child processes; + // a package whose tests need process isolation runs with `forks`. + pool: args.pool ?? `threads`, // A directory of authored mutants (see loadAuthored). mutants: args.mutants ? resolve(cwd, args.mutants) : undefined, } diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 32869465b7..6d186e1f8a 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -237,6 +237,9 @@ export default mergeConfig(base, { // environment (runBatch), in the order given, so a hang is attributed // to the copy that was running. isolate: false, + // Worker threads start much faster than child processes, and each + // batch starts one (`--pool`; see args.mjs). + pool: settings.pool, sequence: { sequencer: KeepOrder }, reporters: [{}], }) @@ -607,6 +610,7 @@ export default mergeConfig(base, { process.env.ORACLE_GAPS_STATE = join(options.out, `state.json`) const settings = { testTimeout: options.testTimeout, + pool: options.pool, bail: Number(process.env.ORACLE_GAPS_BAIL ?? 0), } // Contexts are warmed again when their settings or the Vite config they From 83a546d392c20ee60c1987fdae2c95f89a8d0921 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 15:48:00 -0700 Subject: [PATCH 063/168] fix(oracle-gaps): confirm a cut-off run alone before calling it a timeout A mutant run that passed its time limit was a timeout, whatever the reason. But a loaded machine slows a run that would end. In db-ivm, copies of the distinct oracles do a module-level search of up to a million digests under some hash mutants. About 20 such copies per cold run passed the 13 s limit under 16-way load and were reported as timeouts. The same mutants, run one per batch or alone, are random-only kills. So a mutant's class depended on the machine's load and on which mutants shared a batch. Now a mutant run first gets a short budget (`--first-limit`, base 2 s plus three times the file's unmutated time). A copy cut off there runs again alone, in a fresh context, with the full limit (`--hang-limit`, base 10 s), and only a run cut off there too is a timeout. A file's unmutated time now includes loading and collecting it, not only its tests. - Law: law 4. A run that ends within the full limit is judged as if it had never been cut off, so a slow kill is a kill ("short budget, confirm timeouts", maintainer decision of 2026-11-06). - Reach: the new required state "a slow kill past the first time limit killed" (witness w_slowkill: a test that waits 3 s, then judges, under a 1 s first budget and a 6 s full limit), reached in both runs of fixed seed 20261105. The hang states are still reached. - RED (parent 9db543739): the CLI oracle's fixed seed 20261105 failed with "src/sut.ts:w_slowkill:133:if-false#0: timeout, owed killed". - GREEN: the same command passed (85 s; hangs now cost a confirming run). A cold db-ivm run (multiset.ts, hash.ts, murmur.ts) reports one timeout (murmur.ts:M43, a real hang) instead of 9 to 11. Every class equals a run with one mutant per batch, which had no cut-offs. It takes 134 s; the unsound version took 110 s. FIFO is unchanged: 14 s, no cut-offs, the same classes. - Mutant: a confirming run that keeps the short budget failed with the same message as the RED. - Limits: a timeout still depends on the machine, now only at the full limit for a run alone. Slow kills past the full limit are not generated. Co-authored-by: Isaac --- scripts/oracle-gaps/args.mjs | 3 + scripts/oracle-gaps/check.mjs | 55 ++++++++++++++++--- .../oracle-gaps/gap-report-model-oracle.mjs | 26 ++++++++- .../gap-report-oracle.property.test.mjs | 32 +++++++---- 4 files changed, 96 insertions(+), 20 deletions(-) diff --git a/scripts/oracle-gaps/args.mjs b/scripts/oracle-gaps/args.mjs index e347db2185..1c0c18208e 100644 --- a/scripts/oracle-gaps/args.mjs +++ b/scripts/oracle-gaps/args.mjs @@ -47,6 +47,9 @@ export function parseArgs(argv, cwd = process.cwd()) { // The base of each mutant run's time limit, in milliseconds; the limit // adds three times the file's unmutated duration (see limitFor). hangLimit: Number(args[`hang-limit`] ?? 10000), + // The base of a mutant run's first, short budget; a copy past it runs + // again alone with the full limit before it can be a timeout. + firstLimit: Number(args[`first-limit`] ?? 2000), // Vitest's worker pool. Threads start much faster than child processes; // a package whose tests need process isolation runs with `forks`. pool: args.pool ?? `threads`, diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 6d186e1f8a..16d2b7d68a 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -397,9 +397,13 @@ export default mergeConfig(base, { file, timedOut: false, seconds, + // The file's own time: loading and collecting it, and its tests. duration: Math.max( 0, - ...modules.map((m) => (m.diagnostic?.().duration ?? 0) / 1000), + ...modules.map((m) => { + const d = m.diagnostic?.() ?? {} + return ((d.collectDuration ?? 0) + (d.duration ?? 0)) / 1000 + }), ), setupErrors: modules.flatMap((m) => (m.errors?.() ?? []).map((error) => error.message), @@ -764,13 +768,42 @@ export default mergeConfig(base, { evidence.computed += 1 }) if (batch.timedOutAt === undefined) break + // A copy past its first budget may only be slow: a loaded machine + // slows a run that ends. It runs again alone, in a fresh context, + // with the full limit, and only a run cut off there too is a timeout. const hung = rest[batch.timedOutAt] - const timedOut = { file: hung.file, timedOut: true, seconds: hung.limitMs / 1000 } - got[hung.i] = timedOut - remember(hung.id, hung.file, timedOut, index) - evidence.computed += 1 + const full = limitFor(hung.file) evidence.cut += 1 await replace(slot) + // A copy that already had the full limit is a timeout as it stands. + const alone = + hung.limitMs >= full + ? { timedOutAt: 0, units: [] } + : await runBatch(slot.context, options.out, [{ ...hung, limitMs: full }], options.seed ?? null) + const hadFullLimit = hung.limitMs >= full + if (alone.unattributed) + got[hung.i] = await unit(slot.context, hung.file, hung.spec, { + mutant: hung.mutant, + select: hung.select, + stopOnKill: hung.stopOnKill, + ignore: hung.ignore, + limitMs: full, + }) + else { + const confirmed = + alone.timedOutAt === 0 + ? { file: hung.file, timedOut: true, seconds: full / 1000 } + : alone.units[0] + if (alone.timedOutAt === 0 && !hadFullLimit) { + evidence.cut += 1 + await replace(slot) + } + if (!confirmed.skipped) { + got[hung.i] = confirmed + remember(hung.id, hung.file, confirmed, index) + evidence.computed += 1 + } + } rest = rest.slice(batch.timedOutAt + 1) evidence.rerun += rest.length } @@ -790,10 +823,14 @@ export default mergeConfig(base, { // changes. Each file's unmutated duration sets a mutant run's time limit. const fileSeconds = new Map() // A mutant can slow a run that still ends, for example by lengthening a - // retry backoff. A 5 s base cut off such a run in offline-transactions - // and turned a kill into a timeout, so the base defaults to 10 s - // (`--hang-limit`); hangs are cached, so it costs only cold runs. + // retry backoff, and a loaded machine slows every run. A mutant run + // first gets a short budget (`--first-limit`); a copy cut off there runs + // again alone, with the full limit (`--hang-limit`, 10 s by default: + // a 5 s base once turned a slow kill into a timeout), and only a run + // cut off there too is a timeout (see unitsBatch). const limitFor = (file) => options.hangLimit + 3000 * (fileSeconds.get(file) ?? 10) + const firstLimitFor = (file) => + Math.min(limitFor(file), options.firstLimit + 3000 * (fileSeconds.get(file) ?? 10)) // A cancelled run can leave work behind, so its context is replaced. const replace = async (slot) => { const started = Date.now() @@ -1015,7 +1052,7 @@ export default mergeConfig(base, { spec: `mutant:${st.mutant.number}`, mutant: st.mutant.number, select: await selectionFor(slot, file, keysOf(st)), - limitMs: limitFor(file), + limitMs: firstLimitFor(file), stopOnKill: true, ignore: ignoredIn(file), }) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 293b8bda81..011fc8b373 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -18,6 +18,10 @@ const ts = createRequire( const SOURCE = `src/sut.ts` // A second source in the same package, which the first imports from. const LIB = `src/lib.ts` +// How long a slow test waits, in real milliseconds: past the 1 s first +// budget that the driver passes (`--first-limit`), within the 6 s full limit +// (`--hang-limit`). +const SLOW_MS = 3000 const ORACLE_FILES = [`a`, `b`] const METHOD_NAMES = [`run`, `step`, `tick`] @@ -129,6 +133,8 @@ const testCase = fc.record({ // on a promise that never settles. Rare, since each hang costs a run its // whole time limit. hangs: fc.constantFrom(false, false, false, false, false, false, false, true), + // Slow tests cost seconds of real time each, so only a witness has one. + slow: fc.constant(false), // A test may run a second campaign, on its own route and input, as // `runOracleCampaign` does. then: fc.option( @@ -241,6 +247,7 @@ const judging = (file, route, input, extra = {}) => ({ flaky: false, judgeBy: `verdict`, hangs: false, + slow: false, then: null, ...extra, }) @@ -416,6 +423,15 @@ const witness = [ body: [kill], tests: [judging(`a`, `random seed`, 7), judging(`b`, `fixed seed`, 7)], }, + // A slow kill: the run ends after the first budget, within the full + // limit, so the confirming rerun kills it (a run is a timeout only when + // it passes the full limit alone). + { + id: `w_slowkill`, + owners: [`a`], + body: [kill], + tests: [judging(`a`, `fixed seed`, 7, { slow: true })], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -460,6 +476,7 @@ const captureWitness = new Set([ `w_hangsurvive`, `w_flakykill`, `w_crosskill`, + `w_slowkill`, ]) // ## Rendering and layout @@ -1573,6 +1590,13 @@ function oracleTestFile(plan, file) { ` if (reads${i} !== 0) throw new Error('the payload was read')`, ] : []), + // A slow test waits past the first budget, within the full limit, + // where the output differs; it then judges as usual. + ...(t.slow + ? [ + ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => setTimeout(r, ${SLOW_MS}))`, + ] + : []), // A flaky test never hangs: the model leaves flaky tests out. ...(t.hangs && !t.flaky ? [ @@ -1595,7 +1619,7 @@ function oracleTestFile(plan, file) { } out.push( `describe('t${index}', () => {`, - ` test('${testName(t, index)}', ${t.hangs && !t.flaky ? `async ` : ``}() => {`, + ` test('${testName(t, index)}', ${(t.hangs && !t.flaky) || t.slow ? `async ` : ``}() => {`, ...partsOf(t).flatMap(campaign), ` })`, `})`, diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index a61e02742b..6d30cca041 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -71,9 +71,13 @@ * mutant that no owner test reaches is `unreached`; code that runs when * a file loads the module reaches every test of that file (maintainer * decision of 2026-11-06); a mutant that only nondeterministic owner tests - * could judge is `indeterminate`. An owner test that does not end cuts - * its file's run off at the run's time limit, and the cut-off run - * records nothing, not even its earlier verdicts; a kill in another + * could judge is `indeterminate`. A run is cut off when it passes its + * first, short budget, and it then runs again alone with the full limit + * (the short budget with confirmation, maintainer decision of + * 2026-11-06): a run that ends within the full limit is judged as if it + * had never been cut off, so a slow kill is a kill. An owner test that + * does not end within the full limit cuts its file's run off, and the + * cut-off run records nothing, not even its earlier verdicts; a kill in another * owner still decides (a kill outranks a timeout, v19), and failing one, * the mutant is a `timeout`, whatever any other owner reported. A mutant * that no owner test fails is a survivor: a `comparison-gap` when an owner @@ -141,9 +145,11 @@ * * Limits. Sources are one file of exported functions and classes over one * number. A hang is generated only as an owner test that waits on a promise - * that never settles, under a 3 s base limit (`--hang-limit`); not - * generated: a busy loop, a run slower than its limit that would still end, - * a hang in an ordinary or nondeterministic test, setup errors, multi-line + * that never settles, under a 1 s first budget and a 6 s full limit + * (`--first-limit`, `--hang-limit`); a slow run that ends is only a + * witness, a test that waits 3 s before it judges. Not generated: a busy + * loop, a slow run past the full limit that would still end, a hang in an + * ordinary or nondeterministic test, setup errors, multi-line * conditions, arrow functions and functions passed as values, nested * functions, two functions on one line, getters and setters, * `await`, `new`, and `void` statements, `--only`, `--sample`, and @@ -266,10 +272,13 @@ function runCli( // the count. `--contexts`, `4`, - // A fixture's tests run in milliseconds, so a 3 s base limit cannot cut - // off a run that would end, and each hang costs about 3 s. + // A fixture's tests run in milliseconds. A 1 s first budget cuts off + // the slow witness, and the 6 s full limit lets it end; each hang costs + // about 7 s. + `--first-limit`, + `1000`, `--hang-limit`, - `3000`, + `6000`, `--out`, path(out), ] @@ -574,6 +583,8 @@ async function driver(plan, observe) { states.add(`a kill in one owner after a gap in another`) if (m.function === `w_flakykill` && m.outcome === `killed`) states.add(`a kill after a nondeterministic verdict in its file`) + if (m.function === `w_slowkill` && m.outcome === `killed`) + states.add(`a slow kill past the first time limit killed`) if (m.function === `w_hang` && m.outcome === `timeout`) states.add(`a hang alone timed out`) if (m.function === `w_hangkill` && m.outcome === `killed`) @@ -1026,6 +1037,7 @@ const required = [ `timeout`, `a kill after a nondeterministic verdict in its file`, `a kill in one owner after a gap in another`, + `a slow kill past the first time limit killed`, `a hang alone timed out`, `a hang in one owner and a kill in another killed`, `a hang in one owner and a survivor in another timed out`, @@ -1048,7 +1060,7 @@ oracleProperty({ law: { statement: `The gap report enumerates the promised operator mutants, leaves out exactly the probe-only code, discovers every oracle that owns a function's call tree and refuses broken ownership, judges each mutant only by its owners' tests on their registered routes and counts only law verdicts on reproducible routes as kills, which outrank a run that never ends, anchors each mutant by its function and normalized statement, lists every gap and unowned mutant once with consistent counts, keeps keys stable under unrelated edits, and reports through its daemon what a cold run reports at every state of a sequence of edits.`, authority: `The maintainer's decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), not yet in the repository's decision register, including that a kill by reach agreement alone is a gap and that an incremental run through the daemon equals a cold one (v19.3); AGENTS.md on quiet exclusions; oracle-rubric ORC-006 outcome kinds; the CLI's documented operator list in schemata.mjs.`, - limits: `Hangs only as an owner test that awaits a promise that never settles, under a 3 s base limit; no busy loops, slow runs that would end, hangs in ordinary or nondeterministic tests, setup errors, loops, multi-line conditions, arrow or nested functions, two functions on one line, getters and setters, or await, new, and void statements; two source files of one package, with no re-exports, namespace imports, or calls into another package; no --only, --sample, or --diff-base; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks.`, + limits: `Hangs only as an owner test that awaits a promise that never settles, under a 1 s first budget and a 6 s full limit, and a slow run only as one witness that waits 3 s before it judges; no busy loops, slow runs past the full limit that would end, hangs in ordinary or nondeterministic tests, setup errors, loops, multi-line conditions, arrow or nested functions, two functions on one line, getters and setters, or await, new, and void statements; two source files of one package, with no re-exports, namespace imports, or calls into another package; no --only, --sample, or --diff-base; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks.`, owner: `scripts/oracle-gaps`, }, histories: { From e49f00b12e0a7a89a297247745a3b7e761cc0d13 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 16:22:33 -0700 Subject: [PATCH 064/168] test(oracle-gaps): witness value changes at load and in another owner Two witnesses guard the coming skip of mutants that change no value. In `w_load`, an `if` runs only at load, with 0, so its `if-true` mutant changes a value only there. In `w_otherinfect`, each of two owners makes only one of an `if`'s two mutants change a value. A runner that skips such mutants must count load-time values and every owner. The witnesses exposed two model rules that the decision on load-time code contradicts: code that runs at load reaches every test of its file (maintainer decision, 2026-11-06). - The model counted every site of `w_load` as reached at load. Only the sites that the load ran are; a branch the load skips is unreached. The CLI already said so. - The model took no `if` outcomes from load-time code. Now the outcomes that load-time code takes count as taken in each owner file's tests, so an `if` that the load runs one way only is a generator gap. The CLI already said so. - Law: law 4 (reach, and generator-gap from outcomes), with the load-time decision as authority. - Reach: "a mutant that changes a value only at load killed" and "each owner's value change killed its own mutant", each reached in both runs of fixed seed 20261105. - RED: before the model corrections, the same command failed with "w_load:103:if-false#0: generator-gap, owed no-observed-difference", then "w_load:103:delete-call#0: unreached, owed generator-gap". The model, not the CLI, was wrong. - GREEN: the CLI oracle's fixed seed 20261105, and the static and classify oracles, passed. - Mutant: not applicable until the skip exists. The witnesses are its guards. - Limits: the CLI records no `if` outcomes from load-time code. It says generator-gap for a load-only `if` because it sees neither outcome, and that agrees with the law only while load-time code takes one outcome. An `if` that load-time code takes both ways is not generated. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 37 +++++++++++++++---- .../gap-report-oracle.property.test.mjs | 10 +++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 011fc8b373..f469a08ab3 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -344,7 +344,10 @@ const witness = [ body: [{ kind: `memoSet` }, { kind: `memoGet` }], tests: [judging(`a`, `fixed seed`, 1)], }, - { id: `w_load`, owners: [`a`], body: [push], tests: [] }, + // `w_load`'s `if` runs only at load, with 0: its `if-true` mutant + // changes a value only there, so a runner that skips mutants which change + // no value must count what runs at load. + { id: `w_load`, owners: [`a`], body: [kill, push], tests: [] }, { id: `w_loaded`, owners: [`a`], @@ -432,6 +435,14 @@ const witness = [ body: [kill], tests: [judging(`a`, `fixed seed`, 7, { slow: true })], }, + // Each owner makes only one of this `if`'s two mutants change a value: + // owner a calls it with 1, owner b with 7. + { + id: `w_otherinfect`, + owners: [`a`, `b`], + body: [kill], + tests: [judging(`a`, `fixed seed`, 1), judging(`b`, `fixed seed`, 7)], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -477,6 +488,7 @@ const captureWitness = new Set([ `w_flakykill`, `w_crosskill`, `w_slowkill`, + `w_otherinfect`, ]) // ## Rendering and layout @@ -1378,6 +1390,13 @@ function model(plan) { plan.tests.filter( (t) => ORACLE_FILES.includes(t.file) && owners(id).has(t.file), ) + // Code that ran when the module loaded: the call at the end of the source, + // and the sites of `w_load` that the call reached (with 0). + const loadRun = + plan.moduleLoadLine !== undefined ? interpret(plan, `w_load`, 0, sites) : undefined + const ranAtLoad = (site) => + site.function === `` || + (site.function === `w_load` && loadRun?.reached.has(site.id) === true) const kindOf = (site) => { if (owners(site.function).size === 0) return `unowned` const tests = ownerTests(site.function) @@ -1390,10 +1409,15 @@ function model(plan) { for (const [line, value] of run.outcomes) taken.set(line, new Set([...(taken.get(line) ?? []), value])) } - // Code that runs when the module loads (`w_load`, and the call at the - // end of the source) runs in every owner file that has a test, and - // reaches every test of that file (maintainer decision, 2026-11-06). - const atLoad = site.function === `w_load` || site.function === `` + // What runs at load reaches every test of the file (maintainer decision, + // 2026-11-06), so the outcomes its `if`s take count as taken there. + if (loadRun && tests.length > 0) + for (const [line, value] of loadRun.outcomes) + taken.set(line, new Set([...(taken.get(line) ?? []), value])) + // Code that ran when the module loaded runs in every owner file that has + // a test, and reaches every test of that file (maintainer decision, + // 2026-11-06); a site that the load did not run is reached only by tests. + const atLoad = ranAtLoad(site) if (!reached.has(site.id) && !(atLoad && tests.length > 0)) return `unreached` const reaching = atLoad ? tests @@ -1416,8 +1440,7 @@ function model(plan) { // the module, ordinary files included (maintainer decision, 2026-11-06). const ordinaryReaches = (site) => { const ordinary = plan.tests.filter((t) => t.file === `c`) - if (site.function === `w_load` || site.function === ``) - return ordinary.length > 0 + if (ranAtLoad(site)) return ordinary.length > 0 return ordinary.some((t) => interpret(plan, t.fn, t.input, sites).reached.has(site.id), ) diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 6d30cca041..3307ddce37 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -583,6 +583,14 @@ async function driver(plan, observe) { states.add(`a kill in one owner after a gap in another`) if (m.function === `w_flakykill` && m.outcome === `killed`) states.add(`a kill after a nondeterministic verdict in its file`) + if (m.function === `w_load` && m.operator === `if-true` && m.outcome === `killed`) + states.add(`a mutant that changes a value only at load killed`) + if ( + m.function === `w_otherinfect` && + m.outcome === `killed` && + cli.mutants.filter((x) => x.function === `w_otherinfect` && x.outcome === `killed`).length >= 2 + ) + states.add(`each owner's value change killed its own mutant`) if (m.function === `w_slowkill` && m.outcome === `killed`) states.add(`a slow kill past the first time limit killed`) if (m.function === `w_hang` && m.outcome === `timeout`) @@ -1038,6 +1046,8 @@ const required = [ `a kill after a nondeterministic verdict in its file`, `a kill in one owner after a gap in another`, `a slow kill past the first time limit killed`, + `a mutant that changes a value only at load killed`, + `each owner's value change killed its own mutant`, `a hang alone timed out`, `a hang in one owner and a kill in another killed`, `a hang in one owner and a survivor in another timed out`, From 3e0e7a03878c72ea9f416aaa1dbeb203ca964fa6 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 17:06:28 -0700 Subject: [PATCH 065/168] fix(oracle-gaps): keep fast-check's settings in mutant runs; skip mutants that change no value Two changes, each with its own evidence. 1. Mutant runs set fast-check's `endOnFailure`, so a failing property stopped without shrinking. Framework calibrations inject a property failure and check that it shrinks and replays, so they failed under every mutant whose run included them. That made a framework error or an unregistered kill out of a mutant that changed nothing the calibration saw. The capture must not change what a test observes (law 3). Mutant runs now keep fast-check's settings, apart from the shared seed. - Law: law 3, the capture does not change what a test observes. - Reach: the new required state "a shrinking calibration passes under a mutant" (witness w_shrink: a calibration that shrinks an injected failure to 5), reached in both runs of fixed seed 20261105. - RED (parent e49f00b12 plus the witness): the CLI oracle failed with "src/sut.ts:w_kill:32:if-true#0: framework-error, owed comparison-gap". The calibration failed in a run of another function's mutant. - GREEN: the same command passed. FIFO took 13.4 s with identical classes. In db-ivm about 30 kills were artifacts: unregistered kills went from 82 to 53, and comparison gaps from 8 to 18. Six of them were mutants whose site never changes a value. - Mutant: the original setting is the RED. 2. A mutant whose site never changes a value in its owners' unmutated runs runs exactly as the unmutated code does. Its class follows from the unmutated units, so it needs no run of its own. The capture records, per test and per file at load, the sites whose mutant would have changed a value. `if-true` and `if-false` change a value when the condition had the other value. A comparison's mutant changes a value when the swapped operator disagrees on primitive operands; other operands count as a change without a second comparison. Calls, `!`, and logical operators always count as a change. A site that a nondeterministic test reaches is never skipped. - Law: laws 4 and 9; a class does not depend on how it was computed. - Reach: "a mutant that changes no value judged without a run" was reached 5 times in fixed seed 20261105. The guards "a mutant that changes a value only at load killed" and "each owner's value change killed its own mutant" are reached in both runs. - RED: not applicable; this is a speed change. - GREEN: the CLI oracle's fixed seed 20261105 passed. FIFO skipped 8 mutants, with classes identical to the previous run. db-ivm skipped 7, and all 277 classes are identical with the skip on and off (136 s and 137 s). - Mutants: a skip that ignores load-time rows failed with "w_load:103:if-true#0: generator-gap, owed killed". A skip that reads only the first owner failed with "w_otherinfect:137:if-false#0: no-observed-difference, owed killed". - Limits: the saving is small here (8 of 145 FIFO mutants). Comparisons on objects are never skipped. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 28 ++++++++++++++-- scripts/oracle-gaps/check.mjs | 33 ++++++++++++++++--- .../oracle-gaps/gap-report-model-oracle.mjs | 22 +++++++++++++ .../gap-report-oracle.property.test.mjs | 6 ++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 1a4bce6a15..d2c4b5bca1 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -56,11 +56,12 @@ const fc = await import(`fast-check`).catch(() => undefined) if (fc) { // A property that takes no seed from the shared config would draw new // histories on every run; a global seed makes every run draw the same ones. - // A mutant run stops a property at its first failure. + // Nothing else changes: a mutant run that stopped properties at their + // first failure would also stop the shrinking that a calibration checks, + // and fail it under every mutant. fc.configureGlobal({ ...fc.readConfigureGlobal(), seed: fc.readConfigureGlobal().seed ?? 20251102, - ...(state.mutant !== null ? { endOnFailure: true } : {}), }) } @@ -131,6 +132,19 @@ const hit = (id) => { ;(inTest ? hits : background).add(id) historyHits?.add(id) } +// The sites whose mutant would have changed a value here, in the running +// test or, outside any test, in the file's background. A mutant that +// changes no value in any unmutated run of its owners runs exactly as the +// unmutated code does, so it needs no run of its own (check.mjs). Calls, `!`, +// and logical operators always change a value. A comparison's mutant changes +// its value only when the two operators disagree on these operands; an +// operand that is not a primitive might run code when compared, so it counts +// as a change without a second comparison. +let infected = new Set() +const backgroundInfected = new Set() +const infect = (id) => (inTest ? infected : backgroundInfected).add(id) +const primitive = (value) => + value === null || (typeof value !== `object` && typeof value !== `function`) // Temporary profiling (ORACLE_GAPS_PROFILE): how many fast-check histories // each test ran, and in how many of them each site was hit, to measure what @@ -211,15 +225,19 @@ function compare(op, a, b) { globalThis.__oracleGaps = { m(id) { hit(id) + infect(id) return id === active }, v: (value) => value, cmp(id, a, b, op) { hit(id) + if (active === null && (!primitive(a) || !primitive(b) || compare(op, a, b) !== compare(swapped[op], a, b))) + infect(id) return compare(id === active ? swapped[op] : op, a, b) }, not(id, value) { hit(id) + infect(id) return id === active ? value : !value }, // Records whether the condition was truthy or falsy, then applies a forced @@ -227,6 +245,7 @@ globalThis.__oracleGaps = { cond(idTrue, idFalse, value) { hit(idTrue) hit(idFalse) + infect(value ? idFalse : idTrue) if (inTest) { const seen = outcomes.get(idTrue) ?? [false, false] seen[value ? 0 : 1] = true @@ -470,6 +489,7 @@ beforeEach((context) => { uuids = new Map() identities = new Map() hits = new Set() + infected = new Set() outcomes = new Map() verdicts = new Map() testStarted = performance.now() @@ -506,6 +526,7 @@ afterEach((context) => { count, observed: observations > 0 ? observed.digest(`hex`) : undefined, hits: [...hits], + infected: [...infected], outcomes: [...outcomes], verdicts: [...verdicts.values()], route, @@ -527,6 +548,7 @@ afterEach((context) => { })}\n`, ) hits = new Set() + infected = new Set() }) const thisFile = globalThis.__vitest_worker__?.filepath @@ -535,7 +557,7 @@ afterAll(() => { if (currentFile) appendFileSync( state.capture, - `${JSON.stringify({ key: `${currentFile} > (background)`, background: true, hits: [...background] })}\n`, + `${JSON.stringify({ key: `${currentFile} > (background)`, background: true, hits: [...background], infected: [...backgroundInfected] })}\n`, ) // A batch's watchdog reads this row to know that the copy finished; a // skipped copy says so, and its run is no evidence. diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 16d2b7d68a..58930842af 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -674,8 +674,9 @@ export default mergeConfig(base, { ]), ) // How the evidence was produced: units reused and computed, runs cut off - // at their time limit, and items rerun after a cut-off batch. - const evidence = { reused: 0, computed: 0, cut: 0, rerun: 0 } + // at their time limit, items rerun after a cut-off batch, and mutants + // that changed no value and so needed no run. + const evidence = { reused: 0, computed: 0, cut: 0, rerun: 0, unchanged: 0 } // Each file's imports in its unmutated run. A run that timed out reports // none, so it is stored under these: a mutant changes no import. const fileInputs = new Map() @@ -864,7 +865,7 @@ export default mergeConfig(base, { .filter((u) => outcomeOf([u]).outcome !== `survived`) .map((u) => [u.file, failureOf(u)]), ) - return { rows, failed, seconds: Math.max(0, ...units.map((u) => u.seconds)) } + return { rows, units, failed, seconds: Math.max(0, ...units.map((u) => u.seconds)) } } // Without --ordinary, only oracle files run. let files = [...fileSpecs.keys()].filter( @@ -994,6 +995,23 @@ export default mergeConfig(base, { // A nondeterministic test judges nothing, so its verdict must not stop // a run at its first kill: a later test of the file may decide. const ignoredIn = (file) => [...nondeterministic].filter((key) => fileOf(key) === file).sort() + // The mutants that change a value somewhere in each file's unmutated + // runs (both passes), at load or in a test (capture-setup.mjs). A mutant + // that changes no value in any of its owners' runs runs exactly as the + // unmutated code does, as long as no nondeterministic test reaches it, + // so its units are the unmutated ones and it needs no run of its own. + const valueChanges = new Map() + for (const rows of [first.rows, second.rows]) + for (const [key, row] of rows) { + const file = fileOf(key) + if (!valueChanges.has(file)) valueChanges.set(file, new Set()) + for (const number of row.infected ?? []) valueChanges.get(file).add(number) + } + const unmutatedUnits = new Map(first.units.map((u) => [u.file, u])) + const changesNothing = (mutant, targets) => + targets.oracleTargets.every( + (file) => unmutatedUnits.has(file) && !valueChanges.get(file)?.has(mutant.number), + ) && targets.oracleKeys.every((key) => !nondeterministic.has(key)) log( `unmutated: ${[...first.rows.values()].filter((r) => !r.background).length} tests in ${first.seconds.toFixed(1)}s and ${second.seconds.toFixed(1)}s; nondeterministic: ${nondeterministic.size}`, ) @@ -1088,12 +1106,17 @@ export default mergeConfig(base, { mutant.inDiff = diffs.get(mutant.source)?.has(mutant.line) const owners = ownersOf(mutant) const targets = targetsOf(mutant, baseline, owners, oracleFiles) + const decided = owners.size === 0 || targets.oracleTargets.length === 0 + const unchanged = !decided && changesNothing(mutant, targets) + if (unchanged) evidence.unchanged += 1 return { mutant, owners, targets, - units: [], - done: owners.size === 0 || targets.oracleTargets.length === 0, + units: unchanged + ? targets.oracleTargets.map((file) => ({ ...unmutatedUnits.get(file), seconds: 0 })) + : [], + done: decided || unchanged, } }) await runOwners( diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index f469a08ab3..17c6b30db9 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -135,6 +135,8 @@ const testCase = fc.record({ hangs: fc.constantFrom(false, false, false, false, false, false, false, true), // Slow tests cost seconds of real time each, so only a witness has one. slow: fc.constant(false), + // Only a witness checks that fast-check shrinks an injected failure. + shrinks: fc.constant(false), // A test may run a second campaign, on its own route and input, as // `runOracleCampaign` does. then: fc.option( @@ -248,6 +250,7 @@ const judging = (file, route, input, extra = {}) => ({ judgeBy: `verdict`, hangs: false, slow: false, + shrinks: false, then: null, ...extra, }) @@ -443,6 +446,16 @@ const witness = [ body: [kill], tests: [judging(`a`, `fixed seed`, 1), judging(`b`, `fixed seed`, 7)], }, + // A calibration that injects a property failure and checks that it + // shrinks, as framework calibrations do. The runner must not change how + // fast-check runs a property, or this test fails under every mutant that + // reaches it. + { + id: `w_shrink`, + owners: [`a`], + body: [kill], + tests: [judging(`a`, `calibration`, 1, { judges: false, shrinks: true })], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -489,6 +502,7 @@ const captureWitness = new Set([ `w_crosskill`, `w_slowkill`, `w_otherinfect`, + `w_shrink`, ]) // ## Rendering and layout @@ -1554,6 +1568,7 @@ function oracleTestFile(plan, file) { : [`declareOracle({ owns: [${entries.join(`, `)}] })`] const out = [ `import { describe, expect, test } from 'vitest'`, + ...(plan.tests.some((t) => t.file === file && t.shrinks) ? [`import fc from 'fast-check'`] : []), `import * as sut from '../src/sut'`, ...(file === `b` ? [`import { strict } from './strict'`] : []), `function declareOracle(_parts: { owns: Array }): void {}`, @@ -1620,6 +1635,13 @@ function oracleTestFile(plan, file) { ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => setTimeout(r, ${SLOW_MS}))`, ] : []), + // An injected failure shrinks to its smallest counterexample. + ...(t.shrinks + ? [ + ` const shrunk${i} = fc.check(fc.property(fc.integer({ min: 0, max: 1000 }), (n) => n < 5), { seed: 1 })`, + ` if (shrunk${i}.counterexample?.[0] !== 5) throw new Error('the injected failure did not shrink')`, + ] + : []), // A flaky test never hangs: the model leaves flaky tests out. ...(t.hangs && !t.flaky ? [ diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 3307ddce37..63f5bc7893 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -591,6 +591,8 @@ async function driver(plan, observe) { cli.mutants.filter((x) => x.function === `w_otherinfect` && x.outcome === `killed`).length >= 2 ) states.add(`each owner's value change killed its own mutant`) + if (m.function === `w_shrink` && m.operator === `if-true` && m.class === `comparison-gap`) + states.add(`a shrinking calibration passes under a mutant`) if (m.function === `w_slowkill` && m.outcome === `killed`) states.add(`a slow kill past the first time limit killed`) if (m.function === `w_hang` && m.outcome === `timeout`) @@ -619,6 +621,8 @@ async function driver(plan, observe) { } // A batch that a hang cut off reran its other items in a new context. if (cli.evidence?.rerun > 0) states.add(`a batch cut off by a hang reran the rest`) + // A mutant that changed no value was judged from the unmutated runs. + if (cli.evidence?.unchanged > 0) states.add(`a mutant that changes no value judged without a run`) if (plan.ordinary && cli.mutants.some((m) => m.ordinaryOutcome)) states.add(`ordinary tests run when asked`) if (!plan.ordinary && plan.tests.some((t) => t.file === `c`)) @@ -1047,6 +1051,8 @@ const required = [ `a kill in one owner after a gap in another`, `a slow kill past the first time limit killed`, `a mutant that changes a value only at load killed`, + `a mutant that changes no value judged without a run`, + `a shrinking calibration passes under a mutant`, `each owner's value change killed its own mutant`, `a hang alone timed out`, `a hang in one owner and a kill in another killed`, From 7d1d376f04d0158fde9cb797eb869b3ef91590ac Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 17:32:21 -0700 Subject: [PATCH 066/168] feat(oracle-property): bound work on a controlled clock with a law verdict `useOracleClock({ maxTimers })` runs a driver's history with virtual timers and `Date`; macrotask turns stay real. The clock fires at most `maxTimers` timers (1,000 by default) in one history. Past that it stops firing them, which ends a storm of timers that never goes quiet, and the history fails under law `bounded clock` at checkpoint `clock`. The spin ranks as a driver error does, by where it happens: only a violation at a checkpoint recorded before it still decides, and a driver error after it does not. So every fake-timer oracle that adopts it kills a mutant that makes production spin, and the gap report counts that as a kill. - Law: `clock` (maintainer decision, 2026-11-06: an idle spin is a bad bug; a spin is a law verdict, not a framework error). - Reach: the new framework state `clock-violation`. Item 7 in a `clock` spec spins the clock. Three fixed histories witness the state and its precedence: [7] (bounded clock), [3, 7] (`three` still decides), and [7, 6] (the driver error after the spin does not decide). - RED: before the helper existed, with an unbounded fake clock, `pnpm test:oracle-framework -t "a clock that never goes quiet"` failed with `"fixed history: fixed-0" passed as null, owed {"kind":"violation","law": "bounded clock","checkpoint":"clock"}`. - GREEN: `pnpm test:oracle-framework` passed 37 tests in 193 s; the three clock witnesses pass after the lint fix. - Mutant: a spin that always decides failed "a violation recorded before the clock spins still decides": `failed as ... "law":"bounded clock" ..., owed {"kind":"violation","law":"three"}`. - Limits: generated campaigns rarely combine a clock spec with item 7 (0 in this run), so the fixed histories carry the law. No oracle uses the helper yet. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../oracle-property-self-oracle-fixture.ts | 39 +++++- .../tests/oracle-property-self-oracle.test.ts | 55 ++++++-- packages/db/tests/oracle-property.ts | 123 ++++++++++++++++-- 4 files changed, 194 insertions(+), 25 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index efb61dd544..2fbf60c7e3 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1877,7 +1877,7 @@ generic visitor behavior outside the named collectors. | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | | React paced-mutation render cuts | [render-cut oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx), [PR #2170 review](oracle-reviews/pr-2170-react-paced-render-cuts.md) | `usePacedMutations` publishes callbacks only from committed renders and does so before descendant layout effects, while callback-only commits preserve mutate identity. An independent committed-document model crosses ordinary and StrictMode schedules with mutation before or after a suspended candidate, mutation after the candidate commits, and delayed or immediate mutation from a descendant layout effect. It compares ordered callback traces and public rows at the mutation cut, the trace after persistence, and mutate identity at the commit cut. Render-time publication, layout-effect-only publication, and split insertion/layout publication mutants fail at the intended comparisons. A commit between transaction admission and persistence, arbitrary React schedules, server delivery, and other frameworks remain outside this receiving owner. | | Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [core property](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.property.test.ts) (`optimistic-history.core`), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | `optimistic-history.core` runs the core grammar through `oracleProperty`: the history resolves to a plan of concrete actions, the driver records each checkpoint, and the model owes allowed cuts, rows, outcomes, receipts, and tracked transactions, judged as named laws; the former settlement-boundary regressions are its fixed histories, and the seven observation mutants are calibrations, each rejected by its named law. Its grammar does not yet generate pending manual edits, external completion, rejected handlers, reentrant drains, partial updates, ordered operations, or schema defaults; those dimensions are still covered only by fixed histories in the owners that drive `runOptimisticHistory` (open: move them into the grammar). Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction grammar also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | -| oracleProperty framework | [self-oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle.test.ts) (`self-oracle.framework`), [synthetic properties](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle-fixture.ts) | `packages/db/tests/oracle-property.ts` promises its authors registration, verdicts, shrinking, membership, reach, calibration, determinism, timeouts, replay, and rerun commands. The self-oracle writes synthetic properties (histories of one to three integers), runs them under real Vitest in a child process, and compares each test's state and failure with an independent model that reads only the synthetic driver's log of the histories each test ran. It probes a replay of every failing campaign (other tests must stand aside, the replay must start from the counterexample and reproduce it) and a rerun of each property's first violating fixed history or history (the printed `-t` command must run exactly that test). Eighteen fixed histories replay the framework's regressions and witness each state its laws are about; campaigns count those states. Maintainer decisions of 2026-11-03: a calibration skips a candidate whose clean trace fails; within one history a driver error decides first, then a law violation, then a missed constructed-history state; a generated property's determinism test also runs three generated histories. Seventeen framework mutants, including every survivor class from the three reviews, are rejected. Limits: one rerun probe per property; the model relies on fast-check running a grammar's constructed histories first; `forwardCut`, `withinTurns`, `resolve` errors, and the gap report's observation hook have no law here; a run takes about two minutes. | +| oracleProperty framework | [self-oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle.test.ts) (`self-oracle.framework`), [synthetic properties](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-property-self-oracle-fixture.ts) | `packages/db/tests/oracle-property.ts` promises its authors registration, verdicts, shrinking, membership, reach, calibration, determinism, timeouts, replay, and rerun commands. The self-oracle writes synthetic properties (histories of one to three integers), runs them under real Vitest in a child process, and compares each test's state and failure with an independent model that reads only the synthetic driver's log of the histories each test ran. It probes a replay of every failing campaign (other tests must stand aside, the replay must start from the counterexample and reproduce it) and a rerun of each property's first violating fixed history or history (the printed `-t` command must run exactly that test). Eighteen fixed histories replay the framework's regressions and witness each state its laws are about; campaigns count those states. Maintainer decisions of 2026-11-03: a calibration skips a candidate whose clean trace fails; within one history a driver error decides first, then a law violation, then a missed constructed-history state; a generated property's determinism test also runs three generated histories. Seventeen framework mutants, including every survivor class from the three reviews, are rejected. The `clock` law (maintainer decision, 2026-11-06): a driver on `useOracleClock` whose clock fires more than its bound fails that history under law `bounded clock`, ranked as a driver error is; three fixed histories witness it, and a spin that always decides is rejected. Generated campaigns rarely combine a clock property with a spinning item, so the fixed histories carry that law. Limits: one rerun probe per property; the model relies on fast-check running a grammar's constructed histories first; `forwardCut`, `withinTurns`, `resolve` errors, and the gap report's observation hook have no law here; a run takes about two minutes. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial keys-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; fixed histories require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner fixes which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, and delete through handler parameter and mutation-alias access. All calls explicitly refetch; former `refetch`/`clearError` coordinates remain only as historical replay labels, preserving all 12 identity and 12 mutation-overlap executions. The ordinary [health scenarios](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.test.ts) observe exact public causes and settlement. A held refetch retains the current public error. A failed initial load, including a failed initial Collection application, remains a loading error for that sync run regardless of later fetch results. After readiness, a pending retry retains the prior failure reason; failure reports the later cause, and successful result application clears it. Query retry attempts and an older held success are crossed with newer failures, including reuse of one Error value in one clock tick. These scenarios supplement the oracle; they do not claim removed utility fields or error counts. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session) | | Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. The `live-query-observer.hydration-terminal` property (`oracleProperty`, in the path-replay registry) generates histories of up to 10 steps that cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart. Its 11 declared states are model states (a status while the seed is active, attached or detached; attach to a failure; cleanup after a failure ended the seed; restart under the seed). Eight constructed histories enter them, so every fixed and random campaign reaches every state, and half of each grammar's histories start with a constructed history; each grammar prints its reach counts. Fixed histories replay the crash, fail-then-cleanup, and crash-before-attach traces in both modes. The check judges laws `view` (status, flags, claimed rows) and `announcement` (last-read snapshot, granular rebuilt rows). The migration from the hand-built campaign kept every mutant kill and every gap-report outcome on `src/live-query-observer.ts` (seed 42); the old prefixed histories ran up to 13 steps. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests. | diff --git a/packages/db/tests/oracle-property-self-oracle-fixture.ts b/packages/db/tests/oracle-property-self-oracle-fixture.ts index 0b0ac0e0e1..989fd85e8b 100644 --- a/packages/db/tests/oracle-property-self-oracle-fixture.ts +++ b/packages/db/tests/oracle-property-self-oracle-fixture.ts @@ -10,6 +10,9 @@ * - `3` and `4` violate the laws `three` and `four`. The check reports the * first of them in the history. * - `6` makes the driver throw right after it records that item. + * - `7`, in a `clock` spec, makes the driver arm a timer that re-arms itself + * at 0 ms and then advance the controlled clock by a second: work that + * never goes quiet. Elsewhere it is an ordinary item. * * A constructed history builds its history as its prefix followed by a generated history, * cut to three items. The driver appends every history it runs, with the running test's name, to @@ -35,8 +38,13 @@ */ import { appendFileSync } from 'node:fs' import fc from 'fast-check' -import { expect } from 'vitest' -import { oracleProperty, pass, violation } from './oracle-property.js' +import { expect, vi } from 'vitest' +import { + oracleProperty, + pass, + useOracleClock, + violation, +} from './oracle-property.js' import type { Calibration } from './oracle-property.js' export const syntheticIds = [ @@ -85,18 +93,23 @@ export type SyntheticSpec = { noisyProbe?: boolean /** `missing` omits `predict` for a bound probe; `extra` passes it unbound. */ predictMode?: `missing` | `extra` + /** Runs every history on a controlled clock, where a 7 spins it. */ + clock?: boolean } /** The synthetic language: one to three items, mostly ordinary ones. */ export const syntheticArbitrary: fc.Arbitrary = fc.array( fc.oneof( { arbitrary: fc.integer({ min: 0, max: 2 }), weight: 6 }, - { arbitrary: fc.constantFrom(3, 4, 6), weight: 1 }, + { arbitrary: fc.constantFrom(3, 4, 6, 7), weight: 1 }, ), { minLength: 1, maxLength: 3 }, ) const allStates = [`saw-0`, `saw-1`, `saw-2`] +// The clock a `clock` spec's driver runs on. A 7's spin would fire about a +// thousand timers in its second. +const controlledClock = () => useOracleClock({ maxTimers: 100 }) const syntheticProbe = `synthetic#saw-0` let nonce = 0 let noise = 0 @@ -174,10 +187,22 @@ function syntheticDriver(spec: SyntheticSpec, mutant = false) { ) if (spec.reachVia === `observe`) for (const state of reachedStates(spec)(history)) observe(`reach`, state) - for (const [index, item] of history.entries()) { - observe(`item ${index}`, mutant && index === 0 ? 3 : item) - if (item === 6) - throw new Error(`synthetic driver failed at item ${index}`) + const clock = spec.clock ? controlledClock() : undefined + try { + for (const [index, item] of history.entries()) { + observe(`item ${index}`, mutant && index === 0 ? 3 : item) + if (item === 6) + throw new Error(`synthetic driver failed at item ${index}`) + if (item === 7 && clock) { + const spin = () => { + setTimeout(spin, 0) + } + spin() + await vi.advanceTimersByTimeAsync(1000) + } + } + } finally { + clock?.release() } // Last, so a comparison of only the first observations misses it. if (spec.nondeterministic) observe(`nonce`, nonce++) diff --git a/packages/db/tests/oracle-property-self-oracle.test.ts b/packages/db/tests/oracle-property-self-oracle.test.ts index e9d1674abb..eb4de429e2 100644 --- a/packages/db/tests/oracle-property-self-oracle.test.ts +++ b/packages/db/tests/oracle-property-self-oracle.test.ts @@ -41,6 +41,10 @@ * `predict` without one, fails to load. A call after its history ended is an * unhandled error of the run, and a driver whose probe records differ between * two runs fails the determinism test. + * - `clock`: a driver on `useOracleClock` whose clock passes its bound fails + * that history under law `bounded clock` at checkpoint `clock`, ranked as + * a driver error is: a violation recorded before the spin still decides, + * and a driver error after it does not (maintainer decision, 2026-11-06). * - `timeout`: a property's `timeout` limits every test it registers. * - `replay`: replaying a failing campaign's seed and path runs only that * property's random campaign, starts from the counterexample itself, and @@ -53,7 +57,8 @@ * Authority: the contract in the header of `oracle-property.ts`, including * its state probes (user decision of 2026-11-04 adopting differential reach); the * maintainer decisions of 2026-11-03 on calibration candidates, failure - * precedence, and determinism scope; and the oracle rubric's replay + * precedence, and determinism scope; the maintainer decision of 2026-11-06 + * that work which never goes quiet on a controlled clock fails its history; and the oracle rubric's replay * (ORC-007), calibration (ORC-006), and declared-reach (ORC-015) rules. * * Limits: synthetic properties with histories of one to three integers, one @@ -119,6 +124,7 @@ type Draft = { overReport: boolean noisyProbe: boolean predictMode: `match` | `missing` | `extra` + clock: boolean } type Suite = { drafts: Array } @@ -220,6 +226,11 @@ const draft: fc.Arbitrary = fc.record({ arbitrary: fc.constantFrom(`missing` as const, `extra` as const), }, ), + // A property whose driver runs on the framework's controlled clock. + clock: fc.oneof( + { weight: 3, arbitrary: fc.constant(false) }, + { weight: 1, arbitrary: fc.constant(true) }, + ), }) const suite: fc.Arbitrary = fc.record({ drafts: fc.array(draft, { minLength: 1, maxLength: 2 }), @@ -277,6 +288,7 @@ function resolveSuite({ drafts }: Suite): Plan { ...(d.noisyProbe ? { noisyProbe: true } : {}), ...(d.predictMode !== `match` ? { predictMode: d.predictMode } : {}), ...(d.timing === `slow` ? { slow: true, timeout: 100 } : {}), + ...(d.clock ? { clock: true } : {}), calibrations: d.calibrations.map((kind, at) => ({ name: `cal-${at}`, kind, @@ -313,7 +325,7 @@ type Kind = | `timeout` type Outcome = { kind: Kind - law?: `three` | `four` | `reach agreement` + law?: `three` | `four` | `reach agreement` | `bounded clock` /** A violation whose driver threw afterwards carries that error. */ cause?: `driver` /** The checkpoint a framework-made violation names. */ @@ -323,22 +335,31 @@ type Outcome = { } type LogEntry ={ id: string; test: string; history: SyntheticHistory } -const allowed = (item: number) => [0, 1, 2, 3, 4, 6].includes(item) +const allowed = (item: number) => [0, 1, 2, 3, 4, 6, 7].includes(item) const member = (history: SyntheticHistory) => history.length >= 1 && history.length <= 3 && history.every(allowed) // A constructed history builds its history from its prefix followed by a generated history, cut to // three items; generated items are always allowed. const constructedMember = (prefix: SyntheticHistory) => prefix.length >= 1 && prefix.slice(0, 3).every(allowed) -function verdictOf(history: SyntheticHistory): Outcome { +// A 7 on a controlled clock spins it: past the clock's bound, the history +// fails under law `bounded clock` at checkpoint `clock`, ranked as a driver +// error is, by where it happens. A violation recorded before it still decides; +// a driver error after it does not. +function verdictOf(history: SyntheticHistory, clock = false): Outcome { + const stops = (item: number) => item === 6 || (clock && item === 7) for (const [at, item] of history.entries()) { - if (item === 3 || item === 4) + if (item === 3 || item === 4) { + const stop = history.slice(at + 1).find(stops) return { kind: `violation`, law: item === 3 ? `three` : `four`, - ...(history.slice(at + 1).includes(6) ? { cause: `driver` } : {}), + ...(stop === 6 ? { cause: `driver` } : {}), } + } if (item === 6) return { kind: `setup` } + if (clock && item === 7) + return { kind: `violation`, law: `bounded clock`, checkpoint: `clock` } } return { kind: `pass` } } @@ -390,7 +411,7 @@ function judged( history: SyntheticHistory, forced?: boolean, ): Outcome { - const verdict = verdictOf(history) + const verdict = verdictOf(history, spec.clock === true) if (verdict.kind !== `pass` || !spec.probe) return verdict const predicts = predictsZero(history) if (enteredFor(spec, history, forced) === predicts) @@ -551,7 +572,7 @@ function predictSpec( } if (spec.source.kind === `enumerated`) for (const history of spec.source.histories) - tests.set(label(history), verdictOf(history)) + tests.set(label(history), verdictOf(history, spec.clock === true)) else { tests.set(names[0]!, campaign(names[0]!)) tests.set(`random seed`, campaign(`random seed`)) @@ -688,6 +709,7 @@ const frameworkStates = [ `forced-probe-calibration-applied`, `late-probe-error`, `over-reported-reach`, + `clock-violation`, ] as const function frameworkReach( @@ -714,6 +736,7 @@ function frameworkReach( if (name === `random seed`) reached.add(`random-campaign`) if (outcome.kind === `determinism`) reached.add(`determinism-caught`) if (outcome.law === agreementLaw) reached.add(`agreement-violation`) + if (outcome.law === `bounded clock`) reached.add(`clock-violation`) if (outcome.diagnosis === `never-reached`) reached.add(`agreement-never-reached`) if (outcome.diagnosis === `false-condition`) @@ -1352,6 +1375,7 @@ const generatedDraft = (overrides: Partial): Draft => ({ overReport: false, noisyProbe: false, predictMode: `match`, + clock: false, ...overrides, }) const fixedHistory = ( @@ -1386,6 +1410,21 @@ oracleProperty({ ], }, }), + fixedHistory( + `a clock that never goes quiet fails its history under bounded clock`, + [`clock-violation`], + generatedDraft({ clock: true, fixed: [at([7])] }), + ), + fixedHistory( + `a violation recorded before the clock spins still decides`, + [`campaign-violation`], + generatedDraft({ clock: true, constructed: [{ prefix: [3, 7], enters: [] }] }), + ), + fixedHistory( + `a driver error after the clock spins does not decide`, + [`clock-violation`], + generatedDraft({ clock: true, fixed: [at([7, 6])] }), + ), fixedHistory( `a driver error after a violation is the violation's cause`, [`cause-attached`], diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index 1a9d9c9630..ff9e3f6b49 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -118,6 +118,14 @@ * the reported counterexample still shows the first failure (ORC-010). * Within one history a driver error decides first, then a law violation, * then a constructed history that missed its state. + * - **Bounded clock.** A driver that runs on `useOracleClock` has virtual + * timers and `Date`; macrotask turns stay real. The clock fires at most + * `maxTimers` timers (1,000 by default) in one history. Past that it stops + * firing them, and the history fails under law `bounded clock` at + * checkpoint `clock`: work that never goes quiet on a controlled clock is + * a defect (maintainer decision, 2026-11-06). The spin ranks as a driver + * error does, by where it happens: only a violation at a checkpoint + * recorded before it still decides, and a driver error after it does not. * * `oracle-property-self-oracle.test.ts` checks these promises the way an * oracle checks production: it runs synthetic properties under real Vitest @@ -131,7 +139,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { appendFileSync } from 'node:fs' import { isDeepStrictEqual } from 'node:util' import fc from 'fast-check' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { assertRegisteredOracleProperty, oraclePropertyOptions, @@ -605,6 +613,60 @@ function enumeratedTest( } } +// The running history's trace, and where in it a controlled clock passed its +// bound (see `useOracleClock`). +let historyTrace: Array | undefined +let clockSpin: { at: number; maxTimers: number } | undefined +/** The running history's spin, if its clock passed its bound; clears it. */ +const takeClockSpin = () => { + const spin = clockSpin + clockSpin = undefined + return spin +} + +/** + * Runs the rest of a driver's history on a controlled clock: timers and + * `Date` are virtual, and macrotask turns (`setImmediate`) and microtasks + * stay real, so in-memory work finishes between clock steps. The clock fires + * at most `maxTimers` timers in the history; past that it stops firing them, + * which ends a storm of timers that never goes quiet, and the history fails + * under law `bounded clock`. Raise `maxTimers` for a legal history that needs + * more. Call `release` in the driver's `finally`. + */ +export function useOracleClock({ maxTimers = 1000 }: { maxTimers?: number } = {}): { + release: () => void +} { + vi.useFakeTimers({ + toFake: [`setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `Date`], + }) + let fired = 0 + const counted = + >(callback: (...args: Args) => void) => + (...args: Args) => { + fired += 1 + if (fired > maxTimers) { + clockSpin ??= { at: historyTrace?.length ?? 0, maxTimers } + return + } + callback(...args) + } + const fakeTimeout = globalThis.setTimeout + const fakeInterval = globalThis.setInterval + globalThis.setTimeout = ((callback: unknown, ms?: number, ...args: Array) => + fakeTimeout( + typeof callback === `function` ? counted(callback as (...a: Array) => void) : (callback as () => void), + ms, + ...args, + )) as typeof setTimeout + globalThis.setInterval = ((callback: unknown, ms?: number, ...args: Array) => + fakeInterval( + typeof callback === `function` ? counted(callback as (...a: Array) => void) : (callback as () => void), + ms, + ...args, + )) as typeof setInterval + return { release: () => vi.useRealTimers() } +} + /** Runs only the driver, for a history's recorded trace. */ async function recordTrace( driver: ( @@ -619,12 +681,21 @@ async function recordTrace( entered: new Set(), open: true, } - await probeWindows.run(window, () => - driver(plan, (checkpoint, value) => { - observations.push({ checkpoint, value }) - }), - ) + historyTrace = observations + clockSpin = undefined + try { + await probeWindows.run(window, () => + driver(plan, (checkpoint, value) => { + observations.push({ checkpoint, value }) + }), + ) + } finally { + historyTrace = undefined + } window.open = false + // Where a controlled clock passed its bound is part of the trace. + const spin = takeClockSpin() + if (spin) observations.push({ checkpoint: `clock`, value: spin.at }) // The probe calls a history makes are part of what a deterministic driver repeats. observations.push({ checkpoint: `probes`, @@ -715,11 +786,37 @@ async function runOne( forced?: ForcedProbe, ): Promise<{ verdict: Verdict; reached: Set }> { const plan = planOf(options, history) - const { observations, driverError, probes } = await recordGuarded( + const { observations, driverError, probes, spin } = await recordGuarded( driver, plan, forced, ) + // A spin ranks as a driver error does, by where it happens: the trace is + // judged up to it, and a driver error after it does not decide. + if (spin) { + const { verdict, reached } = judge( + options, + plan, + observations.slice(0, spin.at), + undefined, + probes, + ) + const recorded = + verdict.kind === `violation` && + observations + .slice(0, spin.at) + .some(({ checkpoint }) => checkpoint === verdict.checkpoint) + return recorded + ? { verdict, reached } + : { + verdict: violation( + `clock`, + `the controlled clock fired more than ${spin.maxTimers} timers in one history: work that never goes quiet (raise maxTimers if a legal history needs more)`, + `bounded clock`, + ), + reached, + } + } return judge(options, plan, observations, driverError, probes) } @@ -735,6 +832,7 @@ async function recordGuarded( observations: Array driverError?: { error: unknown } probes: ProbeRecord + spin?: { at: number; maxTimers: number } }> { const observations: Array = [] const window: ProbeWindow = { @@ -752,6 +850,13 @@ async function recordGuarded( } return window } + historyTrace = observations + clockSpin = undefined + const settle = () => { + historyTrace = undefined + const spin = takeClockSpin() + return spin ? { spin } : {} + } try { await probeWindows.run(window, () => driver(plan, (checkpoint, value) => { @@ -760,9 +865,9 @@ async function recordGuarded( }), ) } catch (error) { - return { observations, driverError: { error }, probes: close() } + return { observations, driverError: { error }, probes: close(), ...settle() } } - return { observations, probes: close() } + return { observations, probes: close(), ...settle() } } /** The model predicts and the check judges a recorded trace. */ From 82b8bdd21eb60330b42a2dde0326c689846430dc Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 17:34:23 -0700 Subject: [PATCH 067/168] test(offline-transactions): run the offline oracles on the framework clock The FIFO oracle and `useControlledClock` (the settlement and connectivity-replay oracles) now run on `useOracleClock`. A history whose timers never go quiet fails under law `bounded clock`. Outside an `oracleProperty` history, which reports nothing itself, `release` reports the verdict through the gap report's hook and throws, so a spin there is loud too, not a silently dropped timer. - Law: `bounded clock` (framework, maintainer decision of 2026-11-06), on top of each oracle's own laws, which are unchanged. - Reach: unchanged. FIFO's idle law still checks the timers left after every retry. - RED: not applicable; the oracles' laws are unchanged. - GREEN: `npx vitest run` in packages/offline-transactions passed 396 tests in 24 files. In the gap CLI, a cold FIFO run gives classes identical to the previous run. - Mutant: with M134 applied by hand, FIFO's fixed seed 20260916 fails with "violated at checkpoint "clock" (law: bounded clock)". The idle law still covers an armed timer that never fires. - Limits: leadership-replay and interruption-settlement still use their own fake or real timers. Co-authored-by: Isaac --- packages/db/tests/oracle-property.ts | 21 +++++++++++++++++-- .../tests/fifo-retry-oracle.property.test.ts | 8 +++++-- .../tests/oracle-lifecycle.ts | 16 +++++++------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index ff9e3f6b49..d11c584ae6 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -631,7 +631,9 @@ const takeClockSpin = () => { * at most `maxTimers` timers in the history; past that it stops firing them, * which ends a storm of timers that never goes quiet, and the history fails * under law `bounded clock`. Raise `maxTimers` for a legal history that needs - * more. Call `release` in the driver's `finally`. + * more. Call `release` in the driver's `finally`. Outside an + * `oracleProperty` history, no history reports the spin, so `release` + * reports the verdict itself and throws. */ export function useOracleClock({ maxTimers = 1000 }: { maxTimers?: number } = {}): { release: () => void @@ -664,7 +666,22 @@ export function useOracleClock({ maxTimers = 1000 }: { maxTimers?: number } = {} ms, ...args, )) as typeof setInterval - return { release: () => vi.useRealTimers() } + return { + release: () => { + vi.useRealTimers() + if (historyTrace !== undefined) return + const spin = takeClockSpin() + if (!spin) return + globalThis.__oracleVerdict?.({ + property: `useOracleClock`, + law: `bounded clock`, + checkpoint: `clock`, + }) + throw new Error( + `bounded clock: the controlled clock fired more than ${spin.maxTimers} timers: work that never goes quiet (raise maxTimers if a legal test needs more)`, + ) + }, + } } /** Runs only the driver, for a history's recorded trace. */ diff --git a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts index 1c93531fbc..a98943c5bc 100644 --- a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts @@ -8,6 +8,7 @@ import { differs, oracleProperty, pass, + useOracleClock, violation, } from '../../db/tests/oracle-property' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -270,8 +271,11 @@ async function driveFifo( let warn: { mockRestore: () => void } | undefined let primary: unknown let failed = false + let clock: { release: () => void } | undefined try { - vi.useFakeTimers() + // The framework's controlled clock: a history whose timers never go + // quiet fails under law `bounded clock`. + clock = useOracleClock() // A storage fault stops the executor, which logs the failed retry batch. warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) // Fails the planned phase write or deletion for one transaction. @@ -381,7 +385,7 @@ async function driveFifo( for (const release of [ () => executor?.clear(), () => warn?.mockRestore(), - () => vi.useRealTimers(), + () => clock?.release(), ]) try { release() diff --git a/packages/offline-transactions/tests/oracle-lifecycle.ts b/packages/offline-transactions/tests/oracle-lifecycle.ts index e79a9045fd..1c219a64d1 100644 --- a/packages/offline-transactions/tests/oracle-lifecycle.ts +++ b/packages/offline-transactions/tests/oracle-lifecycle.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest' +import { useOracleClock } from '../../db/tests/oracle-property' // The real macrotask yield, taken before any test fakes the clock. const realImmediate = globalThis.setImmediate @@ -71,18 +72,19 @@ async function atVirtualCheckpoint( } /** - * Runs a history on a controlled clock: timers and `Date` are virtual, and - * macrotask turns (`setImmediate`) stay real, so in-memory work finishes - * between clock steps. Returns the release, which restores real timers. + * Runs a history on a controlled clock (the framework's `useOracleClock`): + * timers and `Date` are virtual, and macrotask turns (`setImmediate`) stay + * real, so in-memory work finishes between clock steps. Returns the release, + * which restores real timers and fails the test if its timers never went + * quiet. */ export function useControlledClock(): () => void { - vi.useFakeTimers({ - toFake: [`setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `Date`], - }) + // The framework's clock, which fails a test whose timers never go quiet. + const clock = useOracleClock() controlled = true return () => { controlled = false - vi.useRealTimers() + clock.release() } } From 9fcd8b5617d7c19179888e8cf96d9219b2cbb29e Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 17:38:33 -0700 Subject: [PATCH 068/168] fix(oracle-property): give the real timers back when a controlled clock is released `useOracleClock` replaced Vitest's fake `setTimeout` and `setInterval` with counting wrappers, and `vi.useRealTimers()` restores only what Vitest installed. After `release`, the wrappers stayed, calling a fake clock that no longer ran. Every later timer in the worker silently never fired. In the gap CLI, mutant copies share a worker, so the next copy's timers broke. With the previous commit (82b8bdd21), 16 FIFO mutants changed class: the interruption-settlement oracle, which ran after a FIFO copy, failed under its `unhandled` law. That commit's statement that FIFO's classes were identical was wrong. - Law: `clock` (framework). Releasing the clock gives the real timers back. - Reach: a `clock` spec's driver now awaits a real 1 ms timer before each history, so a history after a released clock depends on the real timers. - RED: `pnpm test:oracle-framework -t "a violation recorded before the clock spins"` failed: the random campaign "failed as {"kind":"other" ...}, owed {"kind":"violation","law":"three"}". The second history's real timer never fired. - GREEN: the three clock witnesses pass. A cold FIFO gap run gives classes identical to the run before the offline oracles moved to the framework clock, and M134 is killed under `bounded clock`. - Mutant: the original release is the RED. - Limits: only `setTimeout` and `setInterval` are wrapped and restored. Co-authored-by: Isaac --- packages/db/tests/oracle-property-self-oracle-fixture.ts | 3 +++ packages/db/tests/oracle-property.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/packages/db/tests/oracle-property-self-oracle-fixture.ts b/packages/db/tests/oracle-property-self-oracle-fixture.ts index 989fd85e8b..20a3fdceb8 100644 --- a/packages/db/tests/oracle-property-self-oracle-fixture.ts +++ b/packages/db/tests/oracle-property-self-oracle-fixture.ts @@ -187,6 +187,9 @@ function syntheticDriver(spec: SyntheticSpec, mutant = false) { ) if (spec.reachVia === `observe`) for (const state of reachedStates(spec)(history)) observe(`reach`, state) + // A real timer first: a clock that an earlier history released must have + // given the real timers back, or this history never goes on. + if (spec.clock) await new Promise((resolve) => setTimeout(resolve, 1)) const clock = spec.clock ? controlledClock() : undefined try { for (const [index, item] of history.entries()) { diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts index d11c584ae6..a8825f2b14 100644 --- a/packages/db/tests/oracle-property.ts +++ b/packages/db/tests/oracle-property.ts @@ -638,6 +638,10 @@ const takeClockSpin = () => { export function useOracleClock({ maxTimers = 1000 }: { maxTimers?: number } = {}): { release: () => void } { + // The timers the clock replaces. Vitest's own restore would leave the + // counting wrappers below in place. + const realTimeout = globalThis.setTimeout + const realInterval = globalThis.setInterval vi.useFakeTimers({ toFake: [`setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `Date`], }) @@ -669,6 +673,8 @@ export function useOracleClock({ maxTimers = 1000 }: { maxTimers?: number } = {} return { release: () => { vi.useRealTimers() + globalThis.setTimeout = realTimeout + globalThis.setInterval = realInterval if (historyTrace !== undefined) return const spin = takeClockSpin() if (!spin) return From af96c2b295eb20977411bf87dcbee28e22a6215a Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 18:22:04 -0700 Subject: [PATCH 069/168] feat(oracle-gaps): add the bool-flip operator A boolean literal outside a type, `true` or `false`, now has a `bool-flip` mutant (from the Stryker donor survey, MU02). The CLI oracle's grammar gains a `flag` statement (`out.push(String(true))`). The model enumerates its `delete-call` and its `bool-flip`, a judged `bool-flip` on the preamble's `return false`, and a probe-only `bool-flip` on every probe whose condition is the literal `true`. A deleted push never evaluates its literal. - Law: law 1, the documented operator list (schemata.mjs header), and law 2, probe-only code: a probe's literal is left out. - Reach: the new required state "a boolean flip killed" (witness w_flag), reached in fixed seed 20261105. - RED: the static oracle failed with "the mutant set differs" before the instrumenter knew the operator. - GREEN: the static, classify, and CLI oracles (fixed seed 20261105) passed. Cold FIFO and db-ivm runs keep every existing key's class. FIFO gains 13 bool-flip mutants (8 gaps: 4 no-observed-difference, 2 crashed, 1 agreement-only, 1 unreached); db-ivm gains 14 (11 gaps). - Mutant: a helper that never flips failed the CLI oracle with "src/sut.ts:w_flag:143:bool-flip#0: no-observed-difference, owed killed". - Limits: generated `flag` statements are one kind among about twenty, so the witness carries the kill. Literals in type positions are left out by construction, with no generated case. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 5 +++ .../oracle-gaps/gap-report-model-oracle.mjs | 33 ++++++++++++++++++- .../gap-report-oracle.property.test.mjs | 6 +++- scripts/oracle-gaps/schemata.mjs | 10 ++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index d2c4b5bca1..30269e24fd 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -240,6 +240,11 @@ globalThis.__oracleGaps = { infect(id) return id === active ? value : !value }, + b(id, value) { + hit(id) + infect(id) + return id === active ? !value : value + }, // Records whether the condition was truthy or falsy, then applies a forced // value if one of this `if`'s two mutants is active. cond(idTrue, idFalse, value) { diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 17c6b30db9..eb542fce07 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -55,6 +55,8 @@ const quote = fc.constantFrom(`'`, `\``) const constant = fc.integer({ min: 0, max: 9 }) const statement = fc.oneof( fc.record({ kind: fc.constant(`push`), quote }), + // A boolean literal in production code, written out as text. + fc.record({ kind: fc.constant(`flag`), value: fc.boolean() }), fc.record({ kind: fc.constant(`if`), op: comparison, @@ -456,6 +458,13 @@ const witness = [ body: [kill], tests: [judging(`a`, `calibration`, 1, { judges: false, shrinks: true })], }, + // A boolean literal that a judging test sees: its flip changes the output. + { + id: `w_flag`, + owners: [`a`], + body: [{ kind: `flag`, value: true }], + tests: [judging(`a`, `fixed seed`, 1)], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -503,6 +512,7 @@ const captureWitness = new Set([ `w_slowkill`, `w_otherinfect`, `w_shrink`, + `w_flag`, ]) // ## Rendering and layout @@ -588,6 +598,8 @@ function renderStatement(stmt, callText) { switch (stmt.kind) { case `push`: return [pushText(stmt)] + case `flag`: + return [`out.push(String(${stmt.value}));`] case `if`: return [`if (${condText(stmt)}) ${pushText(stmt)}`] case `throwIf`: @@ -1017,16 +1029,21 @@ function sitesOf(plan) { }) // A decided probe statement: `&&`, then the guard's `!==`, then a // comparison in its condition. It has no call statement to delete. + // A probe whose condition is the literal `true` has a `bool-flip` there. const guard = (fn, line, probe, condition, quote) => { const text = probeText(probe, condition, quote) add(fn, line, `binary-swap`, text, { probe: true }) add(fn, line, `binary-swap`, text, { probe: true }) - if (condition !== `true`) add(fn, line, `binary-swap`, text, { probe: true }) + add(fn, line, condition === `true` ? `bool-flip` : `binary-swap`, text, { + probe: true, + }) } // `devBuild()` is an ordinary function now; only the older probe form calls it. add(`devBuild`, 4, `binary-swap`, preambleStatements.devBuild, { role: `devBuild`, }) + // Its `return false` runs only when reading the environment throws. + add(`devBuild`, 6, `bool-flip`, `return false`, { role: `devBuildFalse` }) guard(`report`, 10, `probe`, `true`) add(`reportTwice`, 13, `delete-call`, preambleStatements.reportTwice, { probe: true, @@ -1059,6 +1076,10 @@ function sitesOf(plan) { } if (stmt.kind === `push` || stmt.kind === `pushTag`) at(`delete-call`, `push`, stmt.line, whole) + if (stmt.kind === `flag`) { + at(`delete-call`, `push`, stmt.line, whole) + at(`bool-flip`, `flag`, stmt.line, whole) + } if (stmt.kind === `memoSet`) { at(`if-true`, `true`, stmt.line, whole) at(`if-false`, `false`, stmt.line, whole) @@ -1236,6 +1257,16 @@ function interpret(plan, fnId, x, sites, active) { const fn = plan.functions.find((f) => f.id === id) for (const stmt of fn.body) { if (stmt.kind === `push`) write(stmt) + if (stmt.kind === `flag`) { + // A deleted push never evaluates its literal. + const call = siteOf(stmt, `push`) + reached.add(call.id) + if (!isActive(call)) { + const flip = siteOf(stmt, `flag`) + reached.add(flip.id) + trace.push(String(isActive(flip) ? !stmt.value : stmt.value)) + } + } if (stmt.kind === `memoSet`) { for (const role of [`true`, `false`, `not`]) reached.add(siteOf(stmt, role).id) let value = !memo.has(x) diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 63f5bc7893..8d32ff9082 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -14,7 +14,8 @@ * CLI's documented interface (the header of `schemata.mjs`) promises for * each source form: `if-true` and `if-false` for an `if` condition, * `binary-swap` for a comparison or a logical operator, `drop-not` for - * `!`, and `delete-call` for a call statement. Each names its function + * `!`, `bool-flip` for a boolean literal outside a type, and + * `delete-call` for a call statement. Each names its function * (`name`, or `Class.method` for a method) and its line. * 2. **Probe-only code.** A mutant is left out as probe code exactly when its * site is probe-only code: a probe statement in the decided form, @@ -573,6 +574,8 @@ async function driver(plan, observe) { if (kindAt.get(`${m.source}:${m.line}`) === `elseIf` && m.operator === `if-true`) states.add(`production else judged`) if (m.function === `reportAndPush`) states.add(`mixed helper judged`) + if (m.operator === `bool-flip` && !m.probe && m.outcome === `killed`) + states.add(`a boolean flip killed`) if (m.function === `w_leaf` && m.outcome !== `unowned`) states.add(`owned through a call chain`) if (m.function === `W0.step` && m.outcome !== `unowned`) @@ -1052,6 +1055,7 @@ const required = [ `a slow kill past the first time limit killed`, `a mutant that changes a value only at load killed`, `a mutant that changes no value judged without a run`, + `a boolean flip killed`, `a shrinking calibration passes under a mutant`, `each owner's value change killed its own mutant`, `a hang alone timed out`, diff --git a/scripts/oracle-gaps/schemata.mjs b/scripts/oracle-gaps/schemata.mjs index a9fa2358ed..15a484e905 100644 --- a/scripts/oracle-gaps/schemata.mjs +++ b/scripts/oracle-gaps/schemata.mjs @@ -9,6 +9,7 @@ // Operators, in the order the sites are numbered: // - `binary-swap`: `===`↔`!==`, `<`↔`<=`, `>`↔`>=`, `&&`↔`||`, `??`→`&&`. // - `drop-not`: `!x` → `x`. +// - `bool-flip`: a boolean literal `true` ↔ `false` (not a literal type). // - `if-true` / `if-false`: force an `if` condition. // - `delete-call`: skip a call statement (`switch || call()`). // @@ -208,6 +209,14 @@ export function instrument(ts, path, text, source, offset = 0) { node.operator === ts.SyntaxKind.ExclamationToken ) ids.not = add(node, `drop-not`, `!`, ``) + if ( + (node.kind === ts.SyntaxKind.TrueKeyword || + node.kind === ts.SyntaxKind.FalseKeyword) && + !ts.isLiteralTypeNode(node.parent) + ) { + const literal = node.getText(file) + ids.bool = add(node, `bool-flip`, literal, literal === `true` ? `false` : `true`) + } if (ts.isIfStatement(node)) { const condition = node.expression.getText(file) ids.ifTrue = add(node.expression, `if-true`, condition, `true`) @@ -246,6 +255,7 @@ export function instrument(ts, path, text, source, offset = 0) { } if (ids.not !== undefined) return `${G}.not(${ids.not}, ${emitted.get(node.operand).text})` + if (ids.bool !== undefined) return `${G}.b(${ids.bool}, ${node.getText(file)})` if (ids.ifTrue !== undefined) { // Replace only the condition; the rest of the `if` stays as emitted. const { text: condition, at } = emitted.get(node.expression) From d57dd9defb719764fb5f286c15895a33f2b07b79 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 6 Nov 2026 18:43:50 -0700 Subject: [PATCH 070/168] feat(oracle-gaps): add the value-undefined operator A call whose value is used now has a `value-undefined` mutant: the call does not run, and its value is `undefined` (from the Stryker donor survey, MU01; the nearest donor for dropped-value misses such as M63). A call statement keeps `delete-call`. A callee, or the object of a property or element access, gets no mutant, because `undefined` there would only throw. So do `super(...)` and `import(...)`. The model enumerates and interprets the value-position calls of the grammar: `String(...)` and `memo.get(x)` in `memoGet`, `memo.has(x)` in `memoSet`, `String(...)` in `flag`, `devBuild()` in the older probe form, and every probe call (the right operand of the probe's `&&`, probe-only). - Law: law 1 (the documented operator list) and law 2 (a probe's call is probe-only). - Reach: the new required state "a value-position call killed", reached in fixed seed 20261105. - RED: the static oracle failed with "the mutant set differs" before the instrumenter knew the operator. - GREEN: the static, classify, and CLI oracles (fixed seed 20261105) passed. Cold FIFO and db-ivm keep every existing key's class. - Mutant: an instrumenter that runs the call whatever the switch says failed with "src/sut.ts:w_memo:100:value-undefined#0: no-observed-difference, owed killed". - Cost and new gaps: FIFO gains 63 mutants (31 killed; 8 timeouts, 7 agreement-only, 6 unreached, 3 crashed, 3 unregistered, 2 no-observed-difference, 3 unowned). db-ivm gains 136. FIFO's 8 timeouts are real hangs: an `undefined` result is dereferenced inside async work, and the driver waits forever. Each costs a first budget and a full confirming run, so the cold FIFO mutant phase went from about 9 s to 156 s. Faster hang detection is the next step. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 40 +++++++++++++++---- .../gap-report-oracle.property.test.mjs | 9 ++++- scripts/oracle-gaps/schemata.mjs | 18 +++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index eb542fce07..ef598e256d 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -1030,10 +1030,12 @@ function sitesOf(plan) { // A decided probe statement: `&&`, then the guard's `!==`, then a // comparison in its condition. It has no call statement to delete. // A probe whose condition is the literal `true` has a `bool-flip` there. + // The probe call is the `&&`'s right operand, so it is also a value-position call. const guard = (fn, line, probe, condition, quote) => { const text = probeText(probe, condition, quote) add(fn, line, `binary-swap`, text, { probe: true }) add(fn, line, `binary-swap`, text, { probe: true }) + add(fn, line, `value-undefined`, text, { probe: true }) add(fn, line, condition === `true` ? `bool-flip` : `binary-swap`, text, { probe: true, }) @@ -1078,16 +1080,22 @@ function sitesOf(plan) { at(`delete-call`, `push`, stmt.line, whole) if (stmt.kind === `flag`) { at(`delete-call`, `push`, stmt.line, whole) + at(`value-undefined`, `string`, stmt.line, whole) at(`bool-flip`, `flag`, stmt.line, whole) } if (stmt.kind === `memoSet`) { at(`if-true`, `true`, stmt.line, whole) at(`if-false`, `false`, stmt.line, whole) at(`drop-not`, `not`, stmt.line, whole) + at(`value-undefined`, `has`, stmt.line, whole) at(`delete-call`, `memoSet`, stmt.line, `memo.set(x, 's${stmt.label}');`) } if (stmt.kind === `memoGet` || stmt.kind === `pushLoaded`) at(`delete-call`, stmt.kind, stmt.line, whole) + if (stmt.kind === `memoGet`) { + at(`value-undefined`, `string`, stmt.line, whole) + at(`value-undefined`, `get`, stmt.line, whole) + } if (stmt.kind === `primeCall`) at(`delete-call`, `call`, stmt.line, whole) if (stmt.kind === `if`) { condition() @@ -1106,6 +1114,7 @@ function sitesOf(plan) { at(`if-true`, `oldTrue`, stmt.line, whole) at(`if-false`, `oldFalse`, stmt.line, whole) at(`binary-swap`, `oldAnd`, stmt.line, whole) + at(`value-undefined`, `oldDevBuild`, stmt.line, whole) at(`binary-swap`, `oldNe`, stmt.line, whole) at(`delete-call`, `oldCall`, stmt.line, oldProbeCallText(`'p${stmt.label}'`)) } @@ -1262,14 +1271,20 @@ function interpret(plan, fnId, x, sites, active) { const call = siteOf(stmt, `push`) reached.add(call.id) if (!isActive(call)) { - const flip = siteOf(stmt, `flag`) - reached.add(flip.id) - trace.push(String(isActive(flip) ? !stmt.value : stmt.value)) + // `String(...)` replaced by undefined never evaluates its literal. + const string = siteOf(stmt, `string`) + reached.add(string.id) + if (isActive(string)) trace.push(undefined) + else { + const flip = siteOf(stmt, `flag`) + reached.add(flip.id) + trace.push(String(isActive(flip) ? !stmt.value : stmt.value)) + } } } if (stmt.kind === `memoSet`) { - for (const role of [`true`, `false`, `not`]) reached.add(siteOf(stmt, role).id) - let value = !memo.has(x) + for (const role of [`true`, `false`, `not`, `has`]) reached.add(siteOf(stmt, role).id) + let value = !(isActive(siteOf(stmt, `has`)) ? undefined : memo.has(x)) if (isActive(siteOf(stmt, `not`))) value = !value outcomes.push([stmt.line, value]) if (isActive(siteOf(stmt, `true`))) value = true @@ -1283,8 +1298,17 @@ function interpret(plan, fnId, x, sites, active) { if (stmt.kind === `memoGet` || stmt.kind === `pushLoaded`) { const site = siteOf(stmt, stmt.kind) reached.add(site.id) - if (!isActive(site)) - trace.push(...(stmt.kind === `memoGet` ? [String(memo.get(x))] : loaded())) + if (!isActive(site) && stmt.kind === `pushLoaded`) trace.push(...loaded()) + if (!isActive(site) && stmt.kind === `memoGet`) { + const string = siteOf(stmt, `string`) + reached.add(string.id) + if (isActive(string)) trace.push(undefined) + else { + const get = siteOf(stmt, `get`) + reached.add(get.id) + trace.push(String(isActive(get) ? undefined : memo.get(x))) + } + } } if (stmt.kind === `pushTag`) { const site = siteOf(stmt, `push`) @@ -1306,7 +1330,7 @@ function interpret(plan, fnId, x, sites, active) { if (stmt.kind === `oldProbe`) { // `devBuild()` is true under test, so the guard holds and the probe // is called; none of it writes output. - for (const role of [`oldTrue`, `oldFalse`, `oldAnd`, `oldNe`, `oldCall`]) + for (const role of [`oldTrue`, `oldFalse`, `oldAnd`, `oldDevBuild`, `oldNe`, `oldCall`]) reached.add(siteOf(stmt, role).id) reached.add(sites.find((s) => s.role === `devBuild`).id) outcomes.push([stmt.line, true]) diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 8d32ff9082..b023a0e184 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -14,8 +14,10 @@ * CLI's documented interface (the header of `schemata.mjs`) promises for * each source form: `if-true` and `if-false` for an `if` condition, * `binary-swap` for a comparison or a logical operator, `drop-not` for - * `!`, `bool-flip` for a boolean literal outside a type, and - * `delete-call` for a call statement. Each names its function + * `!`, `bool-flip` for a boolean literal outside a type, + * `value-undefined` for a call whose value is used (not a callee or the + * object of a property or element access), and `delete-call` for a call + * statement. Each names its function * (`name`, or `Class.method` for a method) and its line. * 2. **Probe-only code.** A mutant is left out as probe code exactly when its * site is probe-only code: a probe statement in the decided form, @@ -576,6 +578,8 @@ async function driver(plan, observe) { if (m.function === `reportAndPush`) states.add(`mixed helper judged`) if (m.operator === `bool-flip` && !m.probe && m.outcome === `killed`) states.add(`a boolean flip killed`) + if (m.operator === `value-undefined` && !m.probe && m.outcome === `killed`) + states.add(`a value-position call killed`) if (m.function === `w_leaf` && m.outcome !== `unowned`) states.add(`owned through a call chain`) if (m.function === `W0.step` && m.outcome !== `unowned`) @@ -1056,6 +1060,7 @@ const required = [ `a mutant that changes a value only at load killed`, `a mutant that changes no value judged without a run`, `a boolean flip killed`, + `a value-position call killed`, `a shrinking calibration passes under a mutant`, `each owner's value change killed its own mutant`, `a hang alone timed out`, diff --git a/scripts/oracle-gaps/schemata.mjs b/scripts/oracle-gaps/schemata.mjs index 15a484e905..1c33e53659 100644 --- a/scripts/oracle-gaps/schemata.mjs +++ b/scripts/oracle-gaps/schemata.mjs @@ -10,6 +10,10 @@ // - `binary-swap`: `===`↔`!==`, `<`↔`<=`, `>`↔`>=`, `&&`↔`||`, `??`→`&&`. // - `drop-not`: `!x` → `x`. // - `bool-flip`: a boolean literal `true` ↔ `false` (not a literal type). +// - `value-undefined`: a call whose value is used → `undefined`, and the +// call does not run. Not a call statement (`delete-call`), and not a +// callee or the object of a property or element access, where +// `undefined` would only throw. // - `if-true` / `if-false`: force an `if` condition. // - `delete-call`: skip a call statement (`switch || call()`). // @@ -217,6 +221,19 @@ export function instrument(ts, path, text, source, offset = 0) { const literal = node.getText(file) ids.bool = add(node, `bool-flip`, literal, literal === `true` ? `false` : `true`) } + if ( + ts.isCallExpression(node) && + !ts.isExpressionStatement(node.parent) && + !( + (ts.isCallExpression(node.parent) || + ts.isPropertyAccessExpression(node.parent) || + ts.isElementAccessExpression(node.parent)) && + node.parent.expression === node + ) && + node.expression.kind !== ts.SyntaxKind.SuperKeyword && + node.expression.kind !== ts.SyntaxKind.ImportKeyword + ) + ids.value = add(node, `value-undefined`, node.getText(file), `undefined`) if (ts.isIfStatement(node)) { const condition = node.expression.getText(file) ids.ifTrue = add(node.expression, `if-true`, condition, `true`) @@ -256,6 +273,7 @@ export function instrument(ts, path, text, source, offset = 0) { if (ids.not !== undefined) return `${G}.not(${ids.not}, ${emitted.get(node.operand).text})` if (ids.bool !== undefined) return `${G}.b(${ids.bool}, ${node.getText(file)})` + if (ids.value !== undefined) return `${G}.v(${G}.m(${ids.value}) ? void 0 : ${out})` if (ids.ifTrue !== undefined) { // Replace only the condition; the rest of the `if` stays as emitted. const { text: condition, at } = emitted.get(node.expression) From 801d7731220bad0bd604fcf010ff6207176c12a6 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 15:11:57 -0700 Subject: [PATCH 071/168] test(oracle-gaps): witness fresh module state in a workspace dependency The CLI oracle's fixture now has a workspace dependency, `state-dep`, linked through node_modules, which keeps a log in module state. `w_dep` notes an entry and pushes the log. Each mutant run needs fresh module state (law 3), in the package's dependencies too, or a later copy sees an earlier copy's notes. This guards a faster way to load workspace dependencies. - Law: law 3 (fresh module state per mutant run; the capture does not change what a test observes). - Reach: "a workspace dependency's state judged fresh" (w_dep's deleted note is killed), reached in fixed seed 20261105. - RED: not applicable; the current runner loads the dependency afresh in each copy. A probe in the real batch runner showed the same for `@tanstack/db`: no copy saw an earlier copy's module object. - GREEN: the CLI oracle (fixed seed 20261105), static, and classify oracles passed. - Mutant: a runner that externalizes the dependency, so Node shares it across copies, failed with "src/sut.ts:w_agree:41:delete-call#0: killed, owed agreement-only-kill": an unrelated mutant turned into a false kill. - Limits: one dependency, imported by name, with a single entry point. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 53 +++++++++++++++++-- .../gap-report-oracle.property.test.mjs | 3 ++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index ef598e256d..5cc3a32151 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -4,7 +4,7 @@ * oracle runs the CLI's static half in-process on these fixtures; the * end-to-end oracle runs the real CLI on them. */ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -465,6 +465,15 @@ const witness = [ body: [{ kind: `flag`, value: true }], tests: [judging(`a`, `fixed seed`, 1)], }, + // A workspace dependency (`state-dep`, linked through node_modules) keeps + // a log in module state. Every copy must load it afresh, or a later copy + // sees the notes of an earlier one. + { + id: `w_dep`, + owners: [`a`], + body: [{ kind: `depNote` }, { kind: `depSeen` }], + tests: [judging(`a`, `fixed seed`, 1)], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -513,6 +522,7 @@ const captureWitness = new Set([ `w_otherinfect`, `w_shrink`, `w_flag`, + `w_dep`, ]) // ## Rendering and layout @@ -600,6 +610,10 @@ function renderStatement(stmt, callText) { return [pushText(stmt)] case `flag`: return [`out.push(String(${stmt.value}));`] + case `depNote`: + return [`note('d');`] + case `depSeen`: + return [`out.push(...seen());`] case `if`: return [`if (${condText(stmt)}) ${pushText(stmt)}`] case `throwIf`: @@ -791,7 +805,12 @@ function resolveHistory(history) { ), ] const libLines = [] - const lines = [`import { ${imported.join(`, `)} } from './lib'`, ...preamble] + // The workspace dependency's import shares the first line, so the + // preamble's lines do not move. + const lines = [ + `import { ${imported.join(`, `)} } from './lib'; import { note, seen } from 'state-dep'`, + ...preamble, + ] const emit = (fn, indent) => { const name = fn.container === `free` ? fn.id : fn.id.split(`.`)[1] if (fn.overload) @@ -1083,6 +1102,11 @@ function sitesOf(plan) { at(`value-undefined`, `string`, stmt.line, whole) at(`bool-flip`, `flag`, stmt.line, whole) } + if (stmt.kind === `depNote`) at(`delete-call`, `note`, stmt.line, whole) + if (stmt.kind === `depSeen`) { + at(`delete-call`, `push`, stmt.line, whole) + at(`value-undefined`, `seen`, stmt.line, whole) + } if (stmt.kind === `memoSet`) { at(`if-true`, `true`, stmt.line, whole) at(`if-false`, `false`, stmt.line, whole) @@ -1251,6 +1275,7 @@ function interpret(plan, fnId, x, sites, active) { active?.authored === stmt.label ? `s${stmt.label}!` : `s${stmt.label}`, ) } + const depLog = [] // Module state as a freshly loaded module has it: an empty cache, and // the labels `w_load` wrote at load, under the same mutant. const memo = new Map() @@ -1266,6 +1291,23 @@ function interpret(plan, fnId, x, sites, active) { const fn = plan.functions.find((f) => f.id === id) for (const stmt of fn.body) { if (stmt.kind === `push`) write(stmt) + // The workspace dependency's log, as a freshly loaded module has it. + if (stmt.kind === `depNote`) { + const call = siteOf(stmt, `note`) + reached.add(call.id) + if (!isActive(call)) depLog.push(`d`) + } + if (stmt.kind === `depSeen`) { + const call = siteOf(stmt, `push`) + reached.add(call.id) + if (!isActive(call)) { + const seenSite = siteOf(stmt, `seen`) + reached.add(seenSite.id) + // Spreading undefined throws. + if (isActive(seenSite)) throw new TypeError(`seen() is undefined`) + trace.push(...depLog) + } + } if (stmt.kind === `flag`) { // A deleted push never evaluates its literal. const call = siteOf(stmt, `push`) @@ -1807,6 +1849,9 @@ function fixtureFiles(plan, state = initialState(plan)) { : plan.source, [LIB]: plan.librarySource, 'tests/strict.ts': `export const strict = ${state.strict}\n`, + // A workspace dependency with module state, linked through node_modules. + 'deps/state-dep/package.json': `{ "name": "state-dep", "private": true, "type": "module", "exports": "./index.js" }\n`, + 'deps/state-dep/index.js': `const log = []\nexport function note(entry) {\n log.push(entry)\n}\nexport function seen() {\n return [...log]\n}\n`, 'tests/c.test.ts': ordinaryTestFile(plan), } for (const file of ORACLE_FILES) @@ -1845,8 +1890,10 @@ function fixtureFiles(plan, state = initialState(plan)) { // the state before it removes that file. const written = new Map() function writeFixture(dir, files) { - for (const sub of [`src`, `tests`, `mutants`]) + for (const sub of [`src`, `tests`, `mutants`, `deps/state-dep`, `node_modules`]) mkdirSync(join(dir, sub), { recursive: true }) + if (!existsSync(join(dir, `node_modules/state-dep`))) + symlinkSync(`../deps/state-dep`, join(dir, `node_modules/state-dep`), `dir`) for (const path of written.get(dir) ?? []) if (!(path in files)) rmSync(join(dir, path), { force: true }) for (const [path, text] of Object.entries(files)) diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index b023a0e184..79a3b57447 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -598,6 +598,8 @@ async function driver(plan, observe) { cli.mutants.filter((x) => x.function === `w_otherinfect` && x.outcome === `killed`).length >= 2 ) states.add(`each owner's value change killed its own mutant`) + if (m.function === `w_dep` && m.operator === `delete-call` && m.outcome === `killed`) + states.add(`a workspace dependency's state judged fresh`) if (m.function === `w_shrink` && m.operator === `if-true` && m.class === `comparison-gap`) states.add(`a shrinking calibration passes under a mutant`) if (m.function === `w_slowkill` && m.outcome === `killed`) @@ -1062,6 +1064,7 @@ const required = [ `a boolean flip killed`, `a value-position call killed`, `a shrinking calibration passes under a mutant`, + `a workspace dependency's state judged fresh`, `each owner's value change killed its own mutant`, `a hang alone timed out`, `a hang in one owner and a kill in another killed`, From 5e3e565720f147b839d5bc4ca1d4c97f3b209f42 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 15:35:51 -0700 Subject: [PATCH 072/168] perf(oracle-gaps): load workspace dependencies from one-file bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest fetched a workspace dependency's built files one at a time: about 150 modules for `@tanstack/db` in offline-transactions, in every context. The CLI now bundles each workspace dependency with esbuild into one ESM file in the dependency's own directory, with every package import left external, and resolves the package name to it. A workspace dependency is a package the target reaches by name through a node_modules link to a directory outside any node_modules, and theirs in turn. The bundle is outside node_modules, so Vitest inlines it and every copy evaluates it afresh after its module reset. Its file name carries the hash of its text, so evidence keys and warm contexts follow its content. A package's tsconfig `paths` are ignored, so a dependency is bundled as built, not from its sources. The package under test is never bundled. Vite's dependency optimizer was measured first and rejected: it cut the load as much, but `vi.resetModules()` keeps an optimized dependency, so its module state would carry from one mutant copy to the next (law 3). - Law: law 3 (fresh module state per mutant run). - Reach: the fixture declares its workspace dependency, so the bundle is exercised. `w_dep` now reads the log before it notes, so a shared module shows in its own mutants. "a workspace dependency's state judged fresh" was reached in fixed seed 20261105. - RED: not applicable; this is a speed change. - GREEN: the CLI oracle's fixed seed 20261105 passed with bundling. A probe in the real batch runner found every copy loads `@tanstack/db` afresh (0 of 7 copies saw an earlier one). Cold FIFO (the mutants other than value-undefined) gives identical classes in 13.6 s against 14.1 s. Pass 1's cold collect per file went from about 1.4 s to 0.7–1.0 s, and pass 2 with its selection checks from about 3.5 s to 2.4 s. - Mutant: bundles externalized through `server.deps.external` (shared across copies: 5 of 7 copies saw an earlier copy's module) failed with "src/sut.ts:w_agree:41:delete-call#0: killed, owed agreement-only-kill". - Limits: one entry per dependency (`exports` for `.`); deep imports of a workspace dependency are not bundled. `.oracle-gaps/` is now ignored. Co-authored-by: Isaac --- .gitignore | 3 + scripts/oracle-gaps/check.mjs | 81 +++++++++++++++++++ .../oracle-gaps/gap-report-model-oracle.mjs | 9 ++- .../gap-report-oracle.property.test.mjs | 2 +- 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index ee163892cd..1d0f25210a 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ tsconfig.vitest-temp.json # Other checkouts and scratch copies. A plain directory here is not part of # this checkout's branch, so the oracle lint hooks must not see its files. /.worktrees/ + +# The gap report CLI: evidence store and workspace bundles +.oracle-gaps/ diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 58930842af..0b32fc1944 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -138,6 +138,77 @@ function lockfileOf(pkg) { } } +/** + * One-file bundles of the package's workspace dependencies: the packages it + * reaches by name through a node_modules link to a directory outside any + * node_modules, and theirs in turn. Vitest otherwise fetches a dependency's + * built files one at a time, about 150 for `@tanstack/db`, in every context. + * A bundle sits in its package's own directory, so its bare imports resolve + * as the package's do; each bundle leaves them external, so a dependency of + * a dependency loads from its own bundle. It is outside node_modules, so + * Vitest inlines it, and every copy evaluates it afresh after its module + * reset (fresh module state, law 3). Its file name carries the hash of its + * text, so evidence that read it is keyed by what it contains. The package + * under test is never bundled: its sources are the mutated ones. + */ +export async function workspaceBundles(pkg) { + const linked = new Map() + const queue = [pkg] + while (queue.length > 0) { + const dir = queue.shift() + const manifest = JSON.parse(readFileSync(join(dir, `package.json`), `utf8`)) + const names = Object.keys({ + ...manifest.dependencies, + ...manifest.peerDependencies, + ...manifest.devDependencies, + }) + for (const name of names) { + if (linked.has(name)) continue + const link = join(dir, `node_modules`, name) + if (!existsSync(link)) continue + const real = realpathSync(link) + if (real.includes(`/node_modules/`) || real === pkg) continue + linked.set(name, { dir: real, from: dir }) + queue.push(real) + } + } + if (linked.size === 0) return new Map() + const requireVite = createRequire(createRequire(join(pkg, `package.json`)).resolve(`vite`)) + const esbuild = await import(pathToFileURL(requireVite.resolve(`esbuild`)).href) + // A package's ESM entry: its `exports` for `.`, under `import`, else + // `node` or `default`, or its `module` or `main`. + const entryOf = (dir) => { + const manifest = JSON.parse(readFileSync(join(dir, `package.json`), `utf8`)) + let entry = manifest.exports?.[`.`] ?? manifest.exports ?? manifest.module ?? manifest.main ?? `index.js` + while (entry && typeof entry === `object`) entry = entry.import ?? entry.node ?? entry.default + return join(dir, entry) + } + const bundles = new Map() + for (const [name, { dir }] of linked) { + const built = await esbuild.build({ + entryPoints: [entryOf(dir)], + absWorkingDir: dir, + bundle: true, + write: false, + format: `esm`, + platform: `node`, + packages: `external`, + // A package's tsconfig `paths` can point a dependency at its sources; + // the bundle keeps every package import as the built code has it. + tsconfigRaw: `{}`, + logLevel: `silent`, + }) + const text = built.outputFiles[0].text + const out = join(dir, `.oracle-gaps`, `bundle-${sha1(text).slice(0, 16)}.mjs`) + if (!existsSync(out)) { + mkdirSync(dirname(out), { recursive: true }) + writeFileSync(out, text) + } + bundles.set(name, out) + } + return bundles +} + /** * A session over one package: the TypeScript and Vitest that the package * resolves, and warm Vitest contexts that later checks reuse. A cold run @@ -169,6 +240,10 @@ export default mergeConfig(base, { plugins: [{ name: 'oracle-gaps-schemata', enforce: 'pre', + // A workspace dependency loads from its one-file bundle (workspaceBundles). + resolveId(source) { + return globalThis.__oracleGapsBundles?.get(source) + }, load(id) { const path = id.split('?')[0] // This context's authored patch, or else the instrumented source of the @@ -612,10 +687,16 @@ export default mergeConfig(base, { } // The state file is the fallback for setups that cannot `inject`. process.env.ORACLE_GAPS_STATE = join(options.out, `state.json`) + // Workspace dependencies load from one-file bundles (workspaceBundles), + // which are part of the settings: a changed dependency warms the + // contexts again and changes the evidence key. + const bundles = await workspaceBundles(pkg) + globalThis.__oracleGapsBundles = bundles const settings = { testTimeout: options.testTimeout, pool: options.pool, bail: Number(process.env.ORACLE_GAPS_BAIL ?? 0), + bundles: [...bundles], } // Contexts are warmed again when their settings or the Vite config they // loaded changed. diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 5cc3a32151..30f36a389a 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -466,12 +466,13 @@ const witness = [ tests: [judging(`a`, `fixed seed`, 1)], }, // A workspace dependency (`state-dep`, linked through node_modules) keeps - // a log in module state. Every copy must load it afresh, or a later copy - // sees the notes of an earlier one. + // a log in module state. `w_dep` reads the log, then notes an entry, so + // a fresh module shows an empty log. Every copy must load it afresh, or a + // copy after one that noted sees that note. { id: `w_dep`, owners: [`a`], - body: [{ kind: `depNote` }, { kind: `depSeen` }], + body: [{ kind: `depSeen` }, { kind: `depNote` }], tests: [judging(`a`, `fixed seed`, 1)], }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, @@ -1842,7 +1843,7 @@ const initialState = (plan) => ({ /** The fixture package's files in one state. */ function fixtureFiles(plan, state = initialState(plan)) { const files = { - 'package.json': `{ "name": "gap-fixture", "private": true, "type": "module" }\n`, + 'package.json': `{ "name": "gap-fixture", "private": true, "type": "module", "dependencies": { "state-dep": "*" } }\n`, 'vite.config.ts': `import { defineConfig } from 'vitest/config'\nexport default defineConfig({ test: { include: ['tests/**/*.test.ts'] } })\n`, [SOURCE]: state.prepend ? edited({ ...plan, stability: `prepend` }) diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 79a3b57447..40a0d808a2 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -598,7 +598,7 @@ async function driver(plan, observe) { cli.mutants.filter((x) => x.function === `w_otherinfect` && x.outcome === `killed`).length >= 2 ) states.add(`each owner's value change killed its own mutant`) - if (m.function === `w_dep` && m.operator === `delete-call` && m.outcome === `killed`) + if (m.function === `w_dep` && m.operator === `value-undefined` && m.outcome === `crashed`) states.add(`a workspace dependency's state judged fresh`) if (m.function === `w_shrink` && m.operator === `if-true` && m.class === `comparison-gap`) states.add(`a shrinking calibration passes under a mutant`) From 79cc6ab4d292a0a9aa60c36c836637d980cfaf4b Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 16:06:25 -0700 Subject: [PATCH 073/168] feat(oracle-gaps): add the loop-false operator The condition of a `for`, `while`, or `do` loop now has a `loop-false` mutant: the loop never runs (a `do` runs its body once). A `for` with no condition, `for...of`, and `for...in` have no condition and no mutant. It cannot create a hang, since it only removes iterations (from the Stryker donor survey, MU03). The CLI oracle's grammar gains a loop statement, `for (let i = 0; i < 2; i += 1) out.push(...)`. The model enumerates its `loop-false`, the `binary-swap` on `<`, and the `delete-call` on the push, and runs the condition before each pass. - Law: law 1, the documented operator list. - Reach: the new required state "a loop that never runs killed" (witness w_loop), reached in fixed seed 20261105. - RED: the static oracle failed with "the mutant set differs" before the instrumenter knew the operator. - GREEN: the static, classify, and CLI oracles (fixed seed 20261105) passed. Cold FIFO and db-ivm keep every existing key's class. FIFO gains 1 loop-false mutant (killed); db-ivm gains 2 (both unregistered kills). - Mutant: an instrumenter that evaluates the condition whatever the switch says failed with "src/sut.ts:w_loop:150:loop-false#0: no-observed-difference, owed killed". - Limits: generated loops are one fixed form; `while` and `do` loops are instrumented but not generated. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 30 +++++++++++++++++++ .../gap-report-oracle.property.test.mjs | 8 +++-- scripts/oracle-gaps/schemata.mjs | 15 ++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 30f36a389a..af28e14ff3 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -57,6 +57,8 @@ const statement = fc.oneof( fc.record({ kind: fc.constant(`push`), quote }), // A boolean literal in production code, written out as text. fc.record({ kind: fc.constant(`flag`), value: fc.boolean() }), + // A loop that pushes twice. + fc.record({ kind: fc.constant(`loop`), quote }), fc.record({ kind: fc.constant(`if`), op: comparison, @@ -475,6 +477,14 @@ const witness = [ body: [{ kind: `depSeen` }, { kind: `depNote` }], tests: [judging(`a`, `fixed seed`, 1)], }, + // A loop that a judging test sees: forcing its condition false empties + // the output. + { + id: `w_loop`, + owners: [`a`], + body: [{ kind: `loop`, quote: `'` }], + tests: [judging(`a`, `fixed seed`, 1)], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -524,6 +534,7 @@ const captureWitness = new Set([ `w_shrink`, `w_flag`, `w_dep`, + `w_loop`, ]) // ## Rendering and layout @@ -611,6 +622,8 @@ function renderStatement(stmt, callText) { return [pushText(stmt)] case `flag`: return [`out.push(String(${stmt.value}));`] + case `loop`: + return [`for (let i = 0; i < 2; i += 1) ${pushText(stmt)}`] case `depNote`: return [`note('d');`] case `depSeen`: @@ -1103,6 +1116,11 @@ function sitesOf(plan) { at(`value-undefined`, `string`, stmt.line, whole) at(`bool-flip`, `flag`, stmt.line, whole) } + if (stmt.kind === `loop`) { + at(`loop-false`, `loop`, stmt.line, whole) + at(`binary-swap`, `swap`, stmt.line, whole) + at(`delete-call`, `push`, stmt.line, pushText(stmt)) + } if (stmt.kind === `depNote`) at(`delete-call`, `note`, stmt.line, whole) if (stmt.kind === `depSeen`) { at(`delete-call`, `push`, stmt.line, whole) @@ -1292,6 +1310,18 @@ function interpret(plan, fnId, x, sites, active) { const fn = plan.functions.find((f) => f.id === id) for (const stmt of fn.body) { if (stmt.kind === `push`) write(stmt) + // The condition runs before each pass; a forced-false condition runs + // none, and `<=` runs one more. + if (stmt.kind === `loop`) + for (let i = 0; ; i += 1) { + const loopSite = siteOf(stmt, `loop`) + reached.add(loopSite.id) + if (isActive(loopSite)) break + const swapSite = siteOf(stmt, `swap`) + reached.add(swapSite.id) + if (!(isActive(swapSite) ? i <= 2 : i < 2)) break + write(stmt) + } // The workspace dependency's log, as a freshly loaded module has it. if (stmt.kind === `depNote`) { const call = siteOf(stmt, `note`) diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 40a0d808a2..20b47f6e2e 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -16,8 +16,9 @@ * `binary-swap` for a comparison or a logical operator, `drop-not` for * `!`, `bool-flip` for a boolean literal outside a type, * `value-undefined` for a call whose value is used (not a callee or the - * object of a property or element access), and `delete-call` for a call - * statement. Each names its function + * object of a property or element access), `loop-false` for the + * condition of a `for`, `while`, or `do` loop, and `delete-call` for a + * call statement. Each names its function * (`name`, or `Class.method` for a method) and its line. * 2. **Probe-only code.** A mutant is left out as probe code exactly when its * site is probe-only code: a probe statement in the decided form, @@ -580,6 +581,8 @@ async function driver(plan, observe) { states.add(`a boolean flip killed`) if (m.operator === `value-undefined` && !m.probe && m.outcome === `killed`) states.add(`a value-position call killed`) + if (m.operator === `loop-false` && m.outcome === `killed`) + states.add(`a loop that never runs killed`) if (m.function === `w_leaf` && m.outcome !== `unowned`) states.add(`owned through a call chain`) if (m.function === `W0.step` && m.outcome !== `unowned`) @@ -1063,6 +1066,7 @@ const required = [ `a mutant that changes no value judged without a run`, `a boolean flip killed`, `a value-position call killed`, + `a loop that never runs killed`, `a shrinking calibration passes under a mutant`, `a workspace dependency's state judged fresh`, `each owner's value change killed its own mutant`, diff --git a/scripts/oracle-gaps/schemata.mjs b/scripts/oracle-gaps/schemata.mjs index 1c33e53659..69a755a3df 100644 --- a/scripts/oracle-gaps/schemata.mjs +++ b/scripts/oracle-gaps/schemata.mjs @@ -15,6 +15,8 @@ // callee or the object of a property or element access, where // `undefined` would only throw. // - `if-true` / `if-false`: force an `if` condition. +// - `loop-false`: force the condition of a `for`, `while`, or `do` loop to +// `false` (a `for` with no condition, `for...of`, and `for...in` have none). // - `delete-call`: skip a call statement (`switch || call()`). // // Comparisons and `!` go through helpers that evaluate each operand once. @@ -234,6 +236,15 @@ export function instrument(ts, path, text, source, offset = 0) { node.expression.kind !== ts.SyntaxKind.ImportKeyword ) ids.value = add(node, `value-undefined`, node.getText(file), `undefined`) + // The loop's condition: a `for`'s may be absent. + const loopCondition = + ts.isForStatement(node) + ? node.condition + : ts.isWhileStatement(node) || ts.isDoStatement(node) + ? node.expression + : undefined + if (loopCondition) + ids.loop = add(loopCondition, `loop-false`, loopCondition.getText(file), `false`) if (ts.isIfStatement(node)) { const condition = node.expression.getText(file) ids.ifTrue = add(node.expression, `if-true`, condition, `true`) @@ -274,6 +285,10 @@ export function instrument(ts, path, text, source, offset = 0) { return `${G}.not(${ids.not}, ${emitted.get(node.operand).text})` if (ids.bool !== undefined) return `${G}.b(${ids.bool}, ${node.getText(file)})` if (ids.value !== undefined) return `${G}.v(${G}.m(${ids.value}) ? void 0 : ${out})` + if (ids.loop !== undefined) { + const { text: condition, at } = emitted.get(loopCondition) + return `${out.slice(0, at)}${G}.v(${G}.m(${ids.loop}) ? false : ${condition})${out.slice(at + condition.length)}` + } if (ids.ifTrue !== undefined) { // Replace only the condition; the rest of the `if` stays as emitted. const { text: condition, at } = emitted.get(node.expression) From 475408389f4b23e46284d802b3fed8ad9f5a85f8 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 16:16:57 -0700 Subject: [PATCH 074/168] test(db-ivm): register routes in the murmur, hash identity, and incrementalization oracles The gap report counts a verdict as a kill only on a reproducible route, and a test reports its route through `__oracleRoute`. These oracles ran fast-check directly, so they registered none. Their fixed-seed verdicts counted as unregistered kills, and their random campaigns, whose options are built when the file loads, had unknown routes too. `oracleRoute(route)` (oracle-config.ts) registers the running test's route. Fixed-seed tests call it first, random campaigns call it in their predicate, and enumerated examples call it with `case`. The murmur property now runs through `runOracleCampaign`: a fixed seed, then a random campaign. - Law: unchanged for each oracle. The route rule is the maintainer's v19 A4: a route comes from registration. - Reach: unchanged. - RED: the gap report (db-ivm: multiset.ts, hash.ts, murmur.ts) reported 76 unregistered kills and 111 random-only kills. - GREEN: the three oracle files pass (44 tests, no type errors). The gap report's reproducible kills went from 85 to 206: 75 random-only and 46 unregistered kills became kills. Two murmur survivors became comparison gaps, because the added fixed-seed campaign observes their effect. 30 unregistered kills remain, in other db-ivm files. - Mutant: not applicable; registration changes how a verdict is counted, not the laws. - Limits: structural identity, the shared-subtree comparison, and the cancellation witnesses still register no route. Co-authored-by: Isaac --- .../hash-identity-oracle.property.test.ts | 13 +++++++++-- ...ementalization-law-oracle.property.test.ts | 5 ++++ .../murmur-stream-oracle.property.test.ts | 23 +++++++++++-------- packages/db/tests/oracle-config.ts | 13 +++++++++++ 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts index c03bac89c5..98f4886713 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -9,6 +9,7 @@ import { import { oraclePropertyOptions, oracleReplayTest, + oracleRoute, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -808,6 +809,7 @@ const it = oracleReplayTest(vitestIt) describe(`hash identity oracle`, () => { it(`matches the identity model across generated value pairs (${FIXED_SEED})`, () => { + oracleRoute(`fixed seed`) fc.assert( fc.property(pairArb, fc.nat(), fc.nat(), (pair, left, right) => expectPair(pair, [left, right]), @@ -820,10 +822,14 @@ describe(`hash identity oracle`, () => { oraclePropertyOptions(300, PAIR_IDENTITY), )( `matches the identity model across generated value pairs (random)`, - (pair, left, right) => expectPair(pair, [left, right]), + (pair, left, right) => { + oracleRoute(`random seed`) + expectPair(pair, [left, right]) + }, ) it(`matches constructed verdicts across generated cyclic values (${FIXED_SEED})`, () => { + oracleRoute(`fixed seed`) fc.assert(fc.property(cyclicArb, expectCyclicPair), { numRuns: oracleRuns(300), seed: FIXED_SEED, @@ -834,7 +840,10 @@ describe(`hash identity oracle`, () => { oraclePropertyOptions(300, CYCLIC_IDENTITY), )( `matches constructed verdicts across generated cyclic values (random)`, - expectCyclicPair, + (value: CyclicPair) => { + oracleRoute(`random seed`) + expectCyclicPair(value) + }, ) // Positive execution witness: the fixed campaign reaches every value kind diff --git a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts index 848ed6685c..2b14d8a055 100644 --- a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts @@ -18,6 +18,7 @@ import { MultiSet } from '../src/multiset.js' import { oraclePropertyOptions, oracleReplayTest, + oracleRoute, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -589,6 +590,8 @@ describe(`DBSP incrementalization laws`, () => { campaign.test( `checks consolidate, reduce, and groupBy against full recomputation (${campaign.name} campaign)`, async () => { + // The gap report counts a fixed campaign's verdict as reproducible. + oracleRoute(campaign.name === `fixed` ? `fixed seed` : `random seed`) const reach = new CampaignReach() await fc.assert( fc.asyncProperty( @@ -698,6 +701,7 @@ describe(`DBSP incrementalization laws`, () => { campaign.test( `checks simultaneous binary join deltas and split delivery (${campaign.name} campaign)`, async () => { + oracleRoute(campaign.name === `fixed` ? `fixed seed` : `random seed`) const reach = new CampaignReach() await fc.assert( fc.asyncProperty( @@ -808,6 +812,7 @@ describe(`DBSP incrementalization laws`, () => { campaign.test( `checks global ordering and window membership (${campaign.name} campaign)`, async () => { + oracleRoute(campaign.name === `fixed` ? `fixed seed` : `random seed`) const reach = new CampaignReach() await fc.assert( fc.asyncProperty(orderedHistory, async (worlds) => { diff --git a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts index 6050e2c379..91010bcb51 100644 --- a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts +++ b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { fc } from '@fast-check/vitest' -import { oracleRuns } from '../../db/tests/oracle-config.js' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config.js' +import { runOracleCampaign } from '../../db/tests/oracle-campaign.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' /** @@ -102,14 +103,17 @@ declareOracle({ }) describe(`MurmurHashStream`, () => { - it(`digests every chunk sequence as the byte-at-a-time stream does`, () => { - fc.assert( - fc.property(fc.array(chunk, { minLength: 1, maxLength: 8 }), (chunks) => { - expect(digests(Current, chunks)).toEqual(digests(Reference, chunks)) - }), - { numRuns: oracleRuns(2000) }, - ) - }) + // A fixed-seed campaign, then a random one, each registering its route, + // so a failure on the fixed seed is a reproducible verdict. + it(`digests every chunk sequence as the byte-at-a-time stream does`, () => + runOracleCampaign(`IVM_MURMUR_STREAM`, [], (_reach, parameters) => { + fc.assert( + fc.property(fc.array(chunk, { minLength: 1, maxLength: 8 }), (chunks) => { + expect(digests(Current, chunks)).toEqual(digests(Reference, chunks)) + }), + { numRuns: oracleRuns(2000), ...parameters }, + ) + })) it.each([ [`two bytes, after a one-code-unit string`, [`x`, 1.5, -0, `yz`]], @@ -118,6 +122,7 @@ describe(`MurmurHashStream`, () => { ] as Array<[string, Array]>)( `digests the words that follow a block offset of %s`, (_, chunks) => { + oracleRoute(`case`) expectDigestsMatchReference(Current, Reference, chunks) }, ) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 2b2042088c..9a1ffed483 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -947,6 +947,19 @@ export function oracleCalibrationTest( export { declareOracle } from './oracle-declaration.js' +/** + * Registers the running test's route for the gap report, which counts a + * verdict as a kill only on a reproducible route: a fixed seed, a fixed + * history, or an enumerated case. A test that builds its fast-check options + * when its file loads cannot register them there, so it calls this inside + * the test: first, or, for a random campaign, in its predicate. + */ +export function oracleRoute( + route: `fixed seed` | `fixed history` | `case` | `random seed`, +): void { + globalThis.__oracleRoute?.(route) +} + /** Keeps ordinary CI bounded while allowing long randomized oracle campaigns. */ export function oracleRuns(baseRuns: number): number { return baseRuns * multiplier From 264159c01075d22e620f37cc2959adaade61df09 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 16:21:20 -0700 Subject: [PATCH 075/168] test(offline-transactions): register the case route in the enumerated settlement histories The restored-snapshot histories and the started-peer histories are enumerated with `it.each` and register no route, so a verdict they report was an unregistered kill in the gap report. Each test now calls `oracleRoute('case')` first. - Law: unchanged. The route rule is the maintainer's v19 A4. - Reach: unchanged. - RED: the gap report (offline-transactions, TransactionExecutor.ts) reported 13 unregistered kills, all from these two tests. - GREEN: the settlement oracle passes (87 tests). In the gap report all 13 became kills (118 killed), with no other class changed. - Mutant: not applicable; registration changes how a verdict is counted. - Limits: none in this package for TransactionExecutor.ts; other sources were not checked. Co-authored-by: Isaac --- .../tests/transaction-settlement-oracle.property.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts index 2f37c13079..edf1e939df 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -6,6 +6,7 @@ import { OutboxManager } from '../src/outbox/OutboxManager' import { oracleReplayRequested, oracleReplayTest, + oracleRoute, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' @@ -1049,6 +1050,8 @@ describe(`restored snapshots and independent outbox settlement`, () => { it.each(restoredHistories)( `restores whole snapshots through independent outcomes: %j`, async (history) => { + // An enumerated history: a reproducible route for the gap report. + oracleRoute(`case`) reach.beginHistory() const rows = restoredRows(history) const storage = new FakeStorageAdapter() @@ -1697,6 +1700,7 @@ it.each([ ] as const)( `settles a started peer across %s hook failure and %s write`, async (failureKind, peerWriteCase) => { + oracleRoute(`case`) const providerError = new Error(`HTTP 401 Unauthorized`) const hookError = new Error(`retry decision failed`) const storageError = new Error(`peer outbox write failed`) From 04980216559ac9d6c683d3a6e30ac3ca11dfab1c Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 16:28:02 -0700 Subject: [PATCH 076/168] test(db-ivm): register routes in the remaining fixed tests and calibrations Fixed law examples register `case`: the hash identity witnesses and collision example, and the structural identity cases. Properties with no seed register `random seed`: the reduce fast path and the top-k shared-subtree comparison. The incrementalization checker's rejection tests, which feed it wrong answers, register `calibration`, a framework check (`oracleRoute` gains that route). - Law: unchanged. The route rule is the maintainer's v19 A4. - Reach: unchanged. - RED: the gap report (db-ivm: multiset.ts, hash.ts, murmur.ts) reported 30 unregistered kills after the previous commit. - GREEN: the db-ivm suite passes (684 tests, no type errors). In the gap report, 14 random-only and 5 unregistered kills became kills (225 in all), and 5 unregistered kills became random-only kills. 20 unregistered kills became `crashed`: those mutants make production throw a TypeError, and only a checker calibration failed under them, which is a framework check, so the report now shows a crash that no law verdict catches. - Mutant: not applicable; registration changes how a verdict is counted. - Limits: the remaining crashes are gaps for the oracle owner, not route problems. Co-authored-by: Isaac --- packages/db-ivm/tests/hash-identity-oracle.property.test.ts | 2 ++ .../tests/incrementalization-law-oracle.property.test.ts | 3 +++ .../tests/operators/reduce-fast-path-oracle.property.test.ts | 4 +++- .../db-ivm/tests/operators/topk-batch-contract-oracle.test.ts | 3 +++ .../db-ivm/tests/structural-identity-oracle.property.test.ts | 2 ++ packages/db/tests/oracle-config.ts | 2 +- 6 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts index 98f4886713..9d9939335a 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -1044,6 +1044,7 @@ describe(`hash identity oracle`, () => { ], ] it.each(fixedWitnesses)(`%s`, (_name, left, right) => { + oracleRoute(`case`) for (const seeds of [ [1, 2], [3, 4], @@ -1069,6 +1070,7 @@ describe(`hash identity oracle`, () => { }) it(`keeps distinct identities separate when their digests collide`, () => { + oracleRoute(`case`) const pair: Pair = { left: { k: `map`, entries: [] }, right: { k: `set`, items: [] }, diff --git a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts index 2b14d8a055..26fb27419e 100644 --- a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts @@ -1085,6 +1085,8 @@ describe(`DBSP incrementalization laws`, () => { }) it(`rejects invalid negative and duplicate-key input states`, () => { + // A checker calibration: a failure here is the framework's, not a kill. + oracleRoute(`calibration`) expect(() => assertUnaryIncrementalization({ name: `negative input state excluded`, @@ -1115,6 +1117,7 @@ describe(`DBSP incrementalization laws`, () => { }) it(`rejects omitted, sign-flipped, wrong-member, wrong-window, wrong-limit, missing-promotion, and truthiness-filtered output`, () => { + oracleRoute(`calibration`) expect(() => assertUnaryIncrementalization({ name: `omitted output fault`, diff --git a/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts b/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts index 4f3e4b5727..e29055d291 100644 --- a/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts +++ b/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts @@ -4,7 +4,7 @@ import { D2 } from '../../src/d2.js' import { MultiSet } from '../../src/multiset.js' import { reduce } from '../../src/operators/reduce.js' import { output } from '../../src/operators/output.js' -import { oracleRuns } from '../../../db/tests/oracle-config.js' +import { oracleRoute, oracleRuns } from '../../../db/tests/oracle-config.js' import { declareOracle } from '../../../db/tests/oracle-declaration' // What this oracle owns: the gap report judges these entries, and their @@ -135,6 +135,8 @@ const stepArbitrary: fc.Arbitrary = fc.record({ describe(`reduce fast path`, () => { it(`emits the general path's entries after every run`, () => { + // No seed: a random campaign. + oracleRoute(`random seed`) fc.assert( fc.property( fc.array(fc.array(stepArbitrary, { minLength: 1, maxLength: 4 }), { diff --git a/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts b/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts index 0442bcafe6..1dfb170ad4 100644 --- a/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts @@ -7,6 +7,7 @@ import { registerOpaqueHash } from '../../src/hashing/hash.js' import { topKBatch } from '../../src/operators/topKState.js' import { D2 } from '../../src/d2.js' import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalIndex.js' +import { oracleRoute } from '../../../db/tests/oracle-config.js' import { output } from '../../src/operators/index.js' import { declareOracle } from '../../../db/tests/oracle-declaration' @@ -288,6 +289,8 @@ it.each([true, false])( ) it(`compares shared subtrees without expanding every path`, () => { + // No seed: a random campaign. + oracleRoute(`random seed`) vi.spyOn(hashing, `hash`).mockReturnValue(7) fc.assert( fc.property(fc.integer({ min: 0, max: 20 }), (depth) => { diff --git a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts index 1ec75642f8..2cb290113c 100644 --- a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts @@ -3,6 +3,7 @@ import { fc } from '@fast-check/vitest' import { equalHashValues, structuralIdentity } from '../src/hashing/hash.js' import { oraclePropertyOptions, + oracleRoute, readOracleRunConfig, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -574,6 +575,7 @@ describe(`structuralIdentity`, () => { ] as Array<[string, Spec, Spec]>)( `agrees with D2 value identity for %s`, (_name, left, right) => { + oracleRoute(`case`) expectAgreement( left, right, diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 9a1ffed483..21d81fbb9d 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -955,7 +955,7 @@ export { declareOracle } from './oracle-declaration.js' * the test: first, or, for a random campaign, in its predicate. */ export function oracleRoute( - route: `fixed seed` | `fixed history` | `case` | `random seed`, + route: `fixed seed` | `fixed history` | `case` | `random seed` | `calibration`, ): void { globalThis.__oracleRoute?.(route) } From aa2d9bbac7d8fd42bee69cde795410f601406142 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 18:13:49 -0700 Subject: [PATCH 077/168] feat(oracle-gaps): cut a hung copy that waits on nothing A mutant can make a test wait on a promise that never settles. Such a copy paid its first budget and then a confirming run alone at the full limit, about 15 s. Now the capture counts the timers code in the worker makes, where it makes them, each with when it is next due (Node's resource list does not reliably show one entry per timer). A copy is idle when: - no counted timer is due before its full limit; - Node lists no more timers than when its test began; - nothing but the runner's channel and stdio is open (no socket, child process, or file I/O); - its event loop is under 5% busy. After 500 ms of that, the batch's watchdog cuts it off, and it is a timeout with no confirming run. A file whose unmutated tests had any I/O handle open is never cut for idleness, so a test waiting on a container or a database keeps the time limits. There is no option to set. - Law: law 4, timeouts. A run that waits on nothing that can come never ends (maintainer decision, 2026-11-07: idle detection, automatic, I/O files excluded). The longest idle stretch in correct tests was 51 ms in 79,180 test runs (offline-transactions, db-ivm, db); the window is ten times that. - Reach: "a hang cut off for idleness" (w_hang) and "a copy waiting on I/O killed, not cut for idleness" (new witness w_iowait: a test that waits about 3 s on a child process, then judges), both reached in fixed seed 20261105. w_slowkill (a pending timer) is still killed. - RED: not applicable; this is a speed change. GREEN on the parent. - GREEN: the CLI oracle's fixed seed 20261105 passed in 45 s (85 s before; the fixture's hangs are now cut at once). Cold FIFO and db-ivm give identical classes. - Mutants: an idle rule that ignores I/O failed with "src/sut.ts:w_iowait:153:if-false#0: timeout, owed killed". One that ignores pending timers failed with "src/sut.ts:w_slowkill:134:if-false#0: timeout, owed killed". - Limits: FIFO's eight value-undefined hangs are not idle. They poll on real 1 s checkpoint timers (leadership-replay's real-time checkpoints), so they still pay the full limit. The fix there is to move that oracle onto the controlled clock. A test that waits on an I/O handle opened before it began is busy, so it is never cut, which is safe. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 111 ++++++++++++++++++ scripts/oracle-gaps/check.mjs | 54 +++++++-- .../oracle-gaps/gap-report-model-oracle.mjs | 23 +++- .../gap-report-oracle.property.test.mjs | 6 + 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 30269e24fd..579e7e202d 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -37,6 +37,50 @@ const run = // that copy's settings, and resets the module registry: the copy's imports // then load the package's modules afresh, under its mutant, with no state // left by an earlier copy. +// Real clocks and timers, taken once per worker, when the first copy's setup +// runs: before any test fakes timers, `Date`, or `performance`, and before +// the counting wrappers below replace the timers. +const real = (globalThis.__oracleGapsReal ??= { + setInterval: globalThis.setInterval, + now: Date.now.bind(Date), + use: performance.eventLoopUtilization.bind(performance), +}) +const realInterval = real.setInterval +const realNow = real.now +const realUse = real.use + +// The timers that code in this worker has pending, counted where they are +// made, each with when it is next due: Node's resource list does not +// reliably show one entry per timer. +// The wrappers are installed once per worker, before any test file loads, so +// a timer a module makes when it loads is counted too. Vitest's own timers +// are not: it took the real functions before this file ran. Fake timers +// install over the wrappers and give them back. +const pending = (globalThis.__oracleGapsPendingTimers ??= (() => { + const handles = new Map() + const wrap = (make, clear, once) => { + globalThis[make.name] = function (callback, ...rest) { + if (typeof callback !== `function`) return make(callback, ...rest) + // An immediate is due at once; a timer or interval after its delay. + const delay = make === globalThis.setImmediate ? 0 : Number(rest[0]) || 0 + const handle = make(function (...args) { + if (once) handles.delete(handle) + else handles.set(handle, real.now() + delay) + return callback.apply(this, args) + }, ...rest) + handles.set(handle, real.now() + delay) + return handle + } + globalThis[clear.name] = function (handle) { + handles.delete(handle) + return clear(handle) + } + } + wrap(globalThis.setTimeout, globalThis.clearTimeout, true) + wrap(globalThis.setInterval, globalThis.clearInterval, false) + wrap(globalThis.setImmediate, globalThis.clearImmediate, true) + return handles +})()) const unit = run.units?.[globalThis.__vitest_worker__?.filepath] // The mutants that a copy earlier in this batch killed. A later copy of the // same mutant runs nothing: the mutant is decided (stop at first kill). @@ -532,6 +576,7 @@ afterEach((context) => { observed: observations > 0 ? observed.digest(`hex`) : undefined, hits: [...hits], infected: [...infected], + ...(sawIo ? { io: true } : {}), outcomes: [...outcomes], verdicts: [...verdicts.values()], route, @@ -557,6 +602,72 @@ afterEach((context) => { }) const thisFile = globalThis.__vitest_worker__?.filepath + +// I/O and idleness (maintainer decision, 2026-11-07). Each test's row says +// whether the test ever had an I/O handle open: anything but the runner's +// own channel, stdio, and timers. A copy given `idleMs` (a file whose +// unmutated tests did no I/O) is idle when no counted timer is pending, no +// more timers are listed than when its test began (which covers timers made +// outside the wrappers, such as `node:timers/promises`), nothing but the +// channel and stdio is open, and its event loop is under 5% busy. After +// `idleMs` of that it writes an idle row, and the batch's watchdog cuts it +// off as a hang. A late sample means the thread was busy. With +// ORACLE_GAPS_IDLE set, each test's longest idle stretch is recorded +// (profiling). +const quiet = new Set([`MessagePort`, `PipeWrap`, `TTYWrap`, `Timeout`, `Immediate`]) +const timersListed = (kinds) => kinds.filter((kind) => kind === `Timeout` || kind === `Immediate`).length +let sawIo = false +const idleProfile = process.env.ORACLE_GAPS_IDLE +if (state.capture || idleProfile) { + let baseTimers + let since + let last + let longest = 0 + let use = realUse() + let reported = false + beforeEach(() => { + baseTimers = timersListed(process.getActiveResourcesInfo()) + since = undefined + longest = 0 + sawIo = false + }) + afterEach((context) => { + baseTimers = undefined + if (idleProfile) + appendFileSync(idleProfile, `${JSON.stringify({ key: testKey(context.task), longest })}\n`) + }) + realInterval(() => { + const now = realNow() + const late = last !== undefined && now - last > 150 + last = now + const used = realUse(use) + use = realUse() + if (baseTimers === undefined || reported) return + const kinds = process.getActiveResourcesInfo() + const io = kinds.some((kind) => !quiet.has(kind)) + sawIo ||= io + // A timer due after the copy's full time limit cannot end its wait in + // time, so it does not keep the copy busy: a test waiting on it would be + // a timeout at the limit anyway (a collection's five-minute GC timer). + const horizon = now + (state.idleHorizonMs ?? Infinity) + const timerDue = [...pending.values()].some((due) => due <= horizon) + if ( + late || + io || + used.utilization > 0.05 || + timerDue || + timersListed(kinds) > baseTimers + ) { + since = undefined + return + } + since ??= now + longest = Math.max(longest, now - since) + if (!state.idleMs || now - since < state.idleMs) return + reported = true + appendFileSync(state.capture, `${JSON.stringify({ file: thisFile, idle: true })}\n`) + }, 50).unref() +} afterAll(() => { if (!state.capture) return if (currentFile) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 0b32fc1944..822afc2188 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -50,6 +50,10 @@ import { const here = dirname(fileURLToPath(import.meta.url)) const sha1 = (text) => createHash(`sha1`).update(text).digest(`hex`) +// How long a copy of a file with no I/O may sit idle before it is cut off as +// a hang (capture-setup.mjs). The longest idle stretch measured in correct +// tests was 51 ms (79,180 test runs, 2026-11-07), so this is ten times that. +const IDLE_MS = 500 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) // This CLI's own code and the framework it reads. They are part of the run @@ -445,7 +449,7 @@ export default mergeConfig(base, { for (const line of readFileSync(capture, `utf8`).split(`\n`).filter(Boolean)) { const row = JSON.parse(line) // A closing row marks the end of a file for a batch's watchdog. - if (!row.closed) rows.push([row.key, row]) + if (!row.closed && !row.idle) rows.push([row.key, row]) } rmSync(capture) } @@ -534,6 +538,8 @@ export default mergeConfig(base, { select: items[i].select ? inCopy(copy, items[i].file, items[i].select) : null, stopOnKill: items[i].stopOnKill ?? false, ignore: inCopy(copy, items[i].file, items[i].ignore ?? []), + idleMs: items[i].idleMs ?? null, + idleHorizonMs: items[i].idleHorizonMs ?? null, }, ]), ) @@ -549,11 +555,15 @@ export default mergeConfig(base, { // The watchdog: each copy writes a closing row when it finishes, so the // copy that is running is the first without one. A copy that passes its // file's time limit is cut off. + // A copy that reported itself idle is cut off at once (capture-setup.mjs). + const idle = new Set() const finished = () => { if (!existsSync(capture)) return 0 const done = new Set() - for (const line of readFileSync(capture, `utf8`).split(`\n`)) + for (const line of readFileSync(capture, `utf8`).split(`\n`)) { if (line.includes(`"closed":true`)) done.add(JSON.parse(line).file) + if (line.includes(`"idle":true`)) idle.add(JSON.parse(line).file) + } return copies.findIndex((copy) => !done.has(copy)) } let current = 0 @@ -567,7 +577,8 @@ export default mergeConfig(base, { current = at copyStarted = Date.now() } - if (Date.now() - copyStarted > items[current].limitMs) return resolve(undefined) + if (idle.has(copies[current]) || Date.now() - copyStarted > items[current].limitMs) + return resolve(undefined) timer = setTimeout(poll, 50) } timer = setTimeout(poll, 50) @@ -585,7 +596,7 @@ export default mergeConfig(base, { const row = JSON.parse(line) const copy = copies.find((c) => row.key?.startsWith(`${c} > `) || row.file === c) if (copy && row.skipped) skipped.add(copy) - if (copy && !row.closed) rows.get(copy).push(row) + if (copy && !row.closed && !row.idle) rows.get(copy).push(row) } return rows } @@ -623,6 +634,7 @@ export default mergeConfig(base, { } }), timedOutAt: current, + idle: idle.has(copies[current]), } } // Errors outside any test cannot be attributed to one copy, so the @@ -755,9 +767,10 @@ export default mergeConfig(base, { ]), ) // How the evidence was produced: units reused and computed, runs cut off - // at their time limit, items rerun after a cut-off batch, and mutants - // that changed no value and so needed no run. - const evidence = { reused: 0, computed: 0, cut: 0, rerun: 0, unchanged: 0 } + // at their time limit or for idleness (of them, `idle`), items rerun + // after a cut-off batch, and mutants that changed no value and so needed + // no run. + const evidence = { reused: 0, computed: 0, cut: 0, idle: 0, rerun: 0, unchanged: 0 } // Each file's imports in its unmutated run. A run that timed out reports // none, so it is stored under these: a mutant changes no import. const fileInputs = new Map() @@ -856,13 +869,21 @@ export default mergeConfig(base, { const hung = rest[batch.timedOutAt] const full = limitFor(hung.file) evidence.cut += 1 + if (batch.idle) evidence.idle += 1 await replace(slot) - // A copy that already had the full limit is a timeout as it stands. + // A copy that already had the full limit, or that sat idle in a file + // with no I/O, is a timeout as it stands: an idle copy waits on + // nothing that can come. const alone = - hung.limitMs >= full + hung.limitMs >= full || batch.idle ? { timedOutAt: 0, units: [] } - : await runBatch(slot.context, options.out, [{ ...hung, limitMs: full }], options.seed ?? null) - const hadFullLimit = hung.limitMs >= full + : await runBatch( + slot.context, + options.out, + [{ ...hung, limitMs: full }], + options.seed ?? null, + ) + const hadFullLimit = hung.limitMs >= full || batch.idle if (alone.unattributed) got[hung.i] = await unit(slot.context, hung.file, hung.spec, { mutant: hung.mutant, @@ -878,6 +899,7 @@ export default mergeConfig(base, { : alone.units[0] if (alone.timedOutAt === 0 && !hadFullLimit) { evidence.cut += 1 + if (alone.idle) evidence.idle += 1 await replace(slot) } if (!confirmed.skipped) { @@ -1089,6 +1111,12 @@ export default mergeConfig(base, { for (const number of row.infected ?? []) valueChanges.get(file).add(number) } const unmutatedUnits = new Map(first.units.map((u) => [u.file, u])) + // Files whose unmutated tests had an I/O handle open (a socket, a child + // process, file I/O): a test there may wait on something outside the + // worker, so no copy of it is cut for idleness (capture-setup.mjs). + const ioFiles = new Set() + for (const rows of [first.rows, second.rows]) + for (const [key, row] of rows) if (row.io) ioFiles.add(fileOf(key)) const changesNothing = (mutant, targets) => targets.oracleTargets.every( (file) => unmutatedUnits.has(file) && !valueChanges.get(file)?.has(mutant.number), @@ -1152,6 +1180,10 @@ export default mergeConfig(base, { mutant: st.mutant.number, select: await selectionFor(slot, file, keysOf(st)), limitMs: firstLimitFor(file), + // Idle detection only where no unmutated test of the file did I/O. + idleMs: ioFiles.has(file) ? null : IDLE_MS, + // Timers due after the full limit do not keep a copy busy. + idleHorizonMs: limitFor(file), stopOnKill: true, ignore: ignoredIn(file), }) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index af28e14ff3..0d7253ba64 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -141,6 +141,8 @@ const testCase = fc.record({ slow: fc.constant(false), // Only a witness checks that fast-check shrinks an injected failure. shrinks: fc.constant(false), + // Only a witness waits on a child process: seconds of real time. + ioWait: fc.constant(false), // A test may run a second campaign, on its own route and input, as // `runOracleCampaign` does. then: fc.option( @@ -255,6 +257,7 @@ const judging = (file, route, input, extra = {}) => ({ hangs: false, slow: false, shrinks: false, + ioWait: false, then: null, ...extra, }) @@ -485,6 +488,15 @@ const witness = [ body: [{ kind: `loop`, quote: `'` }], tests: [judging(`a`, `fixed seed`, 1)], }, + // A test that waits on a child process where the output differs: an I/O + // handle is open, so its copy is busy, never cut for idleness, and the + // confirming run kills it. + { + id: `w_iowait`, + owners: [`a`], + body: [kill], + tests: [judging(`a`, `fixed seed`, 7, { ioWait: true })], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -535,6 +547,7 @@ const captureWitness = new Set([ `w_flag`, `w_dep`, `w_loop`, + `w_iowait`, ]) // ## Rendering and layout @@ -1697,6 +1710,7 @@ function oracleTestFile(plan, file) { const out = [ `import { describe, expect, test } from 'vitest'`, ...(plan.tests.some((t) => t.file === file && t.shrinks) ? [`import fc from 'fast-check'`] : []), + ...(plan.tests.some((t) => t.file === file && t.ioWait) ? [`import { spawn } from 'node:child_process'`] : []), `import * as sut from '../src/sut'`, ...(file === `b` ? [`import { strict } from './strict'`] : []), `function declareOracle(_parts: { owns: Array }): void {}`, @@ -1763,6 +1777,13 @@ function oracleTestFile(plan, file) { ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => setTimeout(r, ${SLOW_MS}))`, ] : []), + // A test that waits on I/O (a child process, about 3 s) where the + // output differs, then judges as usual. + ...(t.ioWait + ? [ + ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => spawn(process.execPath, ['-e', 'setTimeout(() => {}, ${SLOW_MS})']).on('exit', r))`, + ] + : []), // An injected failure shrinks to its smallest counterexample. ...(t.shrinks ? [ @@ -1792,7 +1813,7 @@ function oracleTestFile(plan, file) { } out.push( `describe('t${index}', () => {`, - ` test('${testName(t, index)}', ${(t.hangs && !t.flaky) || t.slow ? `async ` : ``}() => {`, + ` test('${testName(t, index)}', ${(t.hangs && !t.flaky) || t.slow || t.ioWait ? `async ` : ``}() => {`, ...partsOf(t).flatMap(campaign), ` })`, `})`, diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 20b47f6e2e..8ad1c272b4 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -605,6 +605,8 @@ async function driver(plan, observe) { states.add(`a workspace dependency's state judged fresh`) if (m.function === `w_shrink` && m.operator === `if-true` && m.class === `comparison-gap`) states.add(`a shrinking calibration passes under a mutant`) + if (m.function === `w_iowait` && m.outcome === `killed`) + states.add(`a copy waiting on I/O killed, not cut for idleness`) if (m.function === `w_slowkill` && m.outcome === `killed`) states.add(`a slow kill past the first time limit killed`) if (m.function === `w_hang` && m.outcome === `timeout`) @@ -633,6 +635,8 @@ async function driver(plan, observe) { } // A batch that a hang cut off reran its other items in a new context. if (cli.evidence?.rerun > 0) states.add(`a batch cut off by a hang reran the rest`) + // A hang that waited on nothing was cut off for idleness. + if (cli.evidence?.idle > 0) states.add(`a hang cut off for idleness`) // A mutant that changed no value was judged from the unmutated runs. if (cli.evidence?.unchanged > 0) states.add(`a mutant that changes no value judged without a run`) if (plan.ordinary && cli.mutants.some((m) => m.ordinaryOutcome)) @@ -1067,6 +1071,8 @@ const required = [ `a boolean flip killed`, `a value-position call killed`, `a loop that never runs killed`, + `a hang cut off for idleness`, + `a copy waiting on I/O killed, not cut for idleness`, `a shrinking calibration passes under a mutant`, `a workspace dependency's state judged fresh`, `each owner's value change killed its own mutant`, From 6f422596eb09a34c181a34f5ab921882d0b78b5d Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 18:48:55 -0700 Subject: [PATCH 078/168] fix(oracle-gaps): record a test's row even when its own cleanup throws The capture wrote each test's row (its route, verdicts, and values) in an `afterEach` hook. Vitest runs a test file's own hooks first, and one that throws skips the hooks after it. So a test whose cleanup threw after its failure had no row: no route and no verdicts, and its kill was reported as a crash gap. The offline oracles' controlled clock does this: its release in `afterEach` throws when the clock spun, after the test's own assertion has failed (FIFO's M196 and M197 came out `crashed` instead of `killed`). The row is now written from `onTestFinished`, registered before the skip check, which runs after every hook. - Law: law 2, judgment. Only a law verdict on a reproducible route kills, and a test's verdicts and route are what its body recorded; the test's cleanup does not change them (maintainer decisions v14 and v19). - Reach: a new generated test dimension, `cleanupThrows` (a test file's `afterEach` throws when its test has failed), and a witness, w_cleanup. State "a kill whose test's cleanup then threw" reached once in fixed seed 20261105. - RED: on the parent, `npx vitest run --config scripts/oracle-gaps/vitest.config.mjs gap-report-oracle.property -t "fixed seed 20261105"` failed: w_cleanup's if-false mutant owed `killed`, got `crashed`. - GREEN: the same command passed in 41 s. The classify and static CLI oracles pass (14 tests). - Mutant: the parent's code is the mutant; it fails as shown under RED. - Limits: a hook that throws before the test runs (in `beforeEach`) is not in the grammar. A throwing hook in an unmutated run still refuses or leaves out the file, as before. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 22 +++++++++++++------ .../oracle-gaps/gap-report-model-oracle.mjs | 21 +++++++++++++++++- .../gap-report-oracle.property.test.mjs | 3 +++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index 579e7e202d..fa6b436bfb 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -25,7 +25,7 @@ // of shrinking it: a kill needs one failing history, not the smallest one. import { createHash } from 'node:crypto' import { appendFileSync, readFileSync } from 'node:fs' -import { afterAll, afterEach, beforeEach, chai, expect, inject, vi } from 'vitest' +import { afterAll, beforeEach, chai, expect, inject, vi } from 'vitest' const run = inject(`oracleGaps`) ?? @@ -526,6 +526,10 @@ globalThis.__oracleObserve = (checkpoint, value) => { } beforeEach((context) => { + // The row is written after all the test's hooks: a test file's own + // `afterEach` that throws skips the hooks after it, and the row must not + // depend on the test's cleanup. + context.onTestFinished(() => finishTest(context)) currentFile = context.task.file?.filepath currentTask = context.task observed = createHash(`sha1`) @@ -551,7 +555,9 @@ beforeEach((context) => { // Tests whose observations varied between the two unmutated runs: their // verdicts judge nothing, so they never stop a run. const ignored = new Set(state.ignore ?? []) -afterEach((context) => { +// A test's row, written once the test has finished (registered in the +// first `beforeEach`). +function finishTest(context) { inTest = false if ( state.stopOnKill && @@ -599,7 +605,7 @@ afterEach((context) => { ) hits = new Set() infected = new Set() -}) +} const thisFile = globalThis.__vitest_worker__?.filepath @@ -631,10 +637,12 @@ if (state.capture || idleProfile) { longest = 0 sawIo = false }) - afterEach((context) => { - baseTimers = undefined - if (idleProfile) - appendFileSync(idleProfile, `${JSON.stringify({ key: testKey(context.task), longest })}\n`) + beforeEach((context) => { + context.onTestFinished(() => { + baseTimers = undefined + if (idleProfile) + appendFileSync(idleProfile, `${JSON.stringify({ key: testKey(context.task), longest })}\n`) + }) }) realInterval(() => { const now = realNow() diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 0d7253ba64..f0c0ad8238 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -143,6 +143,9 @@ const testCase = fc.record({ shrinks: fc.constant(false), // Only a witness waits on a child process: seconds of real time. ioWait: fc.constant(false), + // A test's own cleanup hook may throw once the test has failed, as a + // controlled clock's release does. The test's failure still judges. + cleanupThrows: fc.constantFrom(false, false, false, true), // A test may run a second campaign, on its own route and input, as // `runOracleCampaign` does. then: fc.option( @@ -258,6 +261,7 @@ const judging = (file, route, input, extra = {}) => ({ slow: false, shrinks: false, ioWait: false, + cleanupThrows: false, then: null, ...extra, }) @@ -497,6 +501,13 @@ const witness = [ body: [kill], tests: [judging(`a`, `fixed seed`, 7, { ioWait: true })], }, + // A kill whose test's cleanup hook then throws: the kill stands. + { + id: `w_cleanup`, + owners: [`a`], + body: [kill], + tests: [judging(`a`, `fixed seed`, 7, { cleanupThrows: true })], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -548,6 +559,7 @@ const captureWitness = new Set([ `w_dep`, `w_loop`, `w_iowait`, + `w_cleanup`, ]) // ## Rendering and layout @@ -1708,7 +1720,7 @@ function oracleTestFile(plan, file) { ? [`const OWNS = [${entries.join(`, `)}]`, `declareOracle({ owns: OWNS })`] : [`declareOracle({ owns: [${entries.join(`, `)}] })`] const out = [ - `import { describe, expect, test } from 'vitest'`, + `import { ${plan.tests.some((t) => t.file === file && t.cleanupThrows) ? `afterEach, ` : ``}describe, expect, test } from 'vitest'`, ...(plan.tests.some((t) => t.file === file && t.shrinks) ? [`import fc from 'fast-check'`] : []), ...(plan.tests.some((t) => t.file === file && t.ioWait) ? [`import { spawn } from 'node:child_process'`] : []), `import * as sut from '../src/sut'`, @@ -1813,6 +1825,13 @@ function oracleTestFile(plan, file) { } out.push( `describe('t${index}', () => {`, + ...(t.cleanupThrows + ? [ + ` afterEach((context) => {`, + ` if (context.task.result?.state === 'fail') throw new Error('cleanup failed after the failure')`, + ` })`, + ] + : []), ` test('${testName(t, index)}', ${(t.hangs && !t.flaky) || t.slow || t.ioWait ? `async ` : ``}() => {`, ...partsOf(t).flatMap(campaign), ` })`, diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 8ad1c272b4..5c551af588 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -605,6 +605,8 @@ async function driver(plan, observe) { states.add(`a workspace dependency's state judged fresh`) if (m.function === `w_shrink` && m.operator === `if-true` && m.class === `comparison-gap`) states.add(`a shrinking calibration passes under a mutant`) + if (m.function === `w_cleanup` && m.outcome === `killed`) + states.add(`a kill whose test's cleanup then threw`) if (m.function === `w_iowait` && m.outcome === `killed`) states.add(`a copy waiting on I/O killed, not cut for idleness`) if (m.function === `w_slowkill` && m.outcome === `killed`) @@ -1073,6 +1075,7 @@ const required = [ `a loop that never runs killed`, `a hang cut off for idleness`, `a copy waiting on I/O killed, not cut for idleness`, + `a kill whose test's cleanup then threw`, `a shrinking calibration passes under a mutant`, `a workspace dependency's state judged fresh`, `each owner's value change killed its own mutant`, From a06f23df36fd253127bedabc79704af5ab2659cb Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 18:52:46 -0700 Subject: [PATCH 079/168] test(offline-transactions): run the leadership-replay oracle on the controlled clock Leadership replay's checkpoints waited on real timers (1 s each), and its serial-work histories faked timers on their own, which left their checkpoints on a clock that nothing moved. A history stuck under a mutant therefore polled real checkpoint timers until the gap CLI's time limit, so the copy was never idle and each such mutant cost its full limit in every owner file. Each test now runs on `useControlledClock`, as the settlement and connectivity-replay oracles do: checkpoints are virtual, a stuck checkpoint fails at its bound at once, and timers that never go quiet fail under law `bounded clock`. - Law: the oracle's laws are unchanged; `bounded clock` (framework, maintainer decision of 2026-11-06) now also applies here. - Reach: unchanged; the file's reach assertions pass. - RED: not applicable; no law changed. - GREEN: `npx vitest run` in packages/offline-transactions passed 396 tests in 24 files; the leadership file passed under five more random campaigns. At most 20 timers fired in one test (99 tests over three runs), under the clock's bound of 1000. - Mutant: in the gap CLI's cold FIFO run, seven value-undefined mutants that were timeout gaps (M143, M147, M154, M161, M209, M219, M220) are now `crashed` gaps, which name the TypeError that the hang had hidden. M196 and M197 (executeAll called 1001 times) are still killed, now with the bounded clock beside the assertion. Every other class is identical. - Limits: interruption-settlement still uses real timers (its only timer is production's retry timer). The cold FIFO run is still 152 s: M30 makes the executor loop spin on microtasks in every owner file, which no clock bounds (next commit in the CLI). Co-authored-by: Isaac --- .../leadership-replay-oracle.property.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts index 1812602699..e8f3235215 100644 --- a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts @@ -1,6 +1,6 @@ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { OutboxManager } from '../src/outbox/OutboxManager' import { OfflineExecutor } from '../src/OfflineExecutor' import { TransactionExecutor } from '../src/executor/TransactionExecutor' @@ -13,7 +13,11 @@ import { turn, } from './harness' import { storedTransaction } from './leadership-replay-support' -import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' +import { + atOracleCheckpoint, + cleanupOfflineOracle, + useControlledClock, +} from './oracle-lifecycle' import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' @@ -57,6 +61,10 @@ import type { OfflineTransaction } from '../src/types' * unchanged. Gates control causal events. Named checkpoints compare provider * calls and idempotency keys, outbox contents, scheduler counts, leadership, * retry records, caller promise settlement, optimistic rows, and cleanup. + * Each test runs on a controlled clock (timers and `Date` virtual, macrotask + * turns real; `useControlledClock`), so a checkpoint that a retry backoff or + * a stuck replay would end fails at its virtual bound at once, and timers + * that never go quiet fail under law `bounded clock`. * * Reach and controls: fixed histories force each lifecycle boundary and both * mixed-deletion positions. Fixed and random campaigns cover legal adjacent @@ -73,6 +81,14 @@ import type { OfflineTransaction } from '../src/types' * storage engine. Exactly-once network execution across independent leaders is * not promised. Provider side effects after an issued call remain provider-owned. */ +let releaseClock: (() => void) | undefined +beforeEach(() => { + releaseClock = useControlledClock() +}) +afterEach(() => { + releaseClock?.() +}) + type ReplayLedgerEvent = | { type: `stored`; id: string } | { type: `read-captured`; ids: Array } @@ -328,7 +344,6 @@ async function checkSerialWork({ fc.integer({ min: 2, max: 5 }), fc.string({ maxLength: 20 }), async (count, payload) => { - vi.useFakeTimers() const executions = vi.spyOn(TransactionExecutor.prototype, `executeAll`) const entered = gate() const provider = gate() @@ -481,7 +496,6 @@ async function checkSerialWork({ provider.resolve() acknowledgment.resolve() executions.mockRestore() - vi.useRealTimers() await cleanupOfflineOracle( [ () => env.executor.dispose(), From d75bcc5fe48895130edc4992f0546c20e08b413b Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 19:40:29 -0700 Subject: [PATCH 080/168] fix(oracle-gaps): keep one row for a test.prop test across its runs The previous commit wrote each test's row from `onTestFinished`, registered in `beforeEach`. A property of @fast-check/vitest (`test.prop`) runs the suite's `afterEach` and `beforeEach` hooks between its runs, so one test registered one row writer per run, and the second failed with "Digest already called". db-ivm's hash-identity oracle then failed with no mutant active, and the CLI refused the package. Now a `beforeEach` call for the test already running continues it: nothing is reset or registered, and the test's one row covers all its runs. - Law: law 2, judgment (as in the previous commit): a test's verdicts are what its body recorded in any of its runs. - Reach: a new generated dimension, `asProp` (the test is a `test.prop` property that judges only when its generated `n` is positive, so its last run, a shrink to 0, passes), and a witness, w_fcprop. State "a test.prop kill whose last run passed" reached once in fixed seed 20261105. - RED: on the parent, `npx vitest run --config scripts/oracle-gaps/vitest.config.mjs gap-report-oracle.property -t "fixed seed 20261105"` failed: "the CLI did not complete (exit 1): oracle files fail with no mutant active ... Digest already called". - GREEN: the same command passed in 60 s. A cold db-ivm run (hash.ts, murmur.ts, multiset.ts; seed 1) completes, and its 429 classes are identical to those under the capture before the previous commit. - Mutant: a capture that resets a test's state on every run and writes one row at the end failed with "src/sut.ts:w_fcprop:159:if-false#0: crashed, owed killed". - Limits: Vitest `retry` attempts of one test now share one row, as the runs of a property do. Whether the older capture (one row per run, the last one kept) lost kills of this kind is not established: on that code the oracle fails first at w_cleanup. Co-authored-by: Isaac --- scripts/oracle-gaps/capture-setup.mjs | 9 ++++++ .../oracle-gaps/gap-report-model-oracle.mjs | 31 +++++++++++++++---- .../gap-report-oracle.property.test.mjs | 3 ++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/scripts/oracle-gaps/capture-setup.mjs b/scripts/oracle-gaps/capture-setup.mjs index fa6b436bfb..7b183887a5 100644 --- a/scripts/oracle-gaps/capture-setup.mjs +++ b/scripts/oracle-gaps/capture-setup.mjs @@ -525,7 +525,14 @@ globalThis.__oracleObserve = (checkpoint, value) => { observations += 1 } +// Whether this `beforeEach` call continues the test already running: a +// property of @fast-check/vitest (`test.prop`) runs the suite's hooks +// between its runs. Such a call starts nothing: the test's state carries +// across its runs, and its one row is written when it finishes. +let continuing = false beforeEach((context) => { + continuing = context.task === currentTask + if (continuing) return // The row is written after all the test's hooks: a test file's own // `afterEach` that throws skips the hooks after it, and the row must not // depend on the test's cleanup. @@ -632,12 +639,14 @@ if (state.capture || idleProfile) { let use = realUse() let reported = false beforeEach(() => { + if (continuing) return baseTimers = timersListed(process.getActiveResourcesInfo()) since = undefined longest = 0 sawIo = false }) beforeEach((context) => { + if (continuing) return context.onTestFinished(() => { baseTimers = undefined if (idleProfile) diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index f0c0ad8238..6cc93f71a3 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -146,6 +146,11 @@ const testCase = fc.record({ // A test's own cleanup hook may throw once the test has failed, as a // controlled clock's release does. The test's failure still judges. cleanupThrows: fc.constantFrom(false, false, false, true), + // A test may be a property of @fast-check/vitest (`test.prop`), which + // runs the suite's `afterEach` and `beforeEach` hooks between its runs. + // It judges only in runs whose generated `n` is positive, so its last + // run, a shrink to 0, passes: the test fails by an earlier run. + asProp: fc.constantFrom(false, false, false, true), // A test may run a second campaign, on its own route and input, as // `runOracleCampaign` does. then: fc.option( @@ -262,6 +267,7 @@ const judging = (file, route, input, extra = {}) => ({ shrinks: false, ioWait: false, cleanupThrows: false, + asProp: false, then: null, ...extra, }) @@ -508,6 +514,13 @@ const witness = [ body: [kill], tests: [judging(`a`, `fixed seed`, 7, { cleanupThrows: true })], }, + // A kill by a `test.prop` property whose last run passes. + { + id: `w_fcprop`, + owners: [`a`], + body: [kill], + tests: [judging(`a`, `fixed seed`, 7, { asProp: true })], + }, { id: `wg0`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg1`, file: LIB, owners: [], body: [push], tests: [] }, { id: `wg2`, file: LIB, owners: [], body: [push], tests: [] }, @@ -560,6 +573,7 @@ const captureWitness = new Set([ `w_loop`, `w_iowait`, `w_cleanup`, + `w_fcprop`, ]) // ## Rendering and layout @@ -1723,6 +1737,7 @@ function oracleTestFile(plan, file) { `import { ${plan.tests.some((t) => t.file === file && t.cleanupThrows) ? `afterEach, ` : ``}describe, expect, test } from 'vitest'`, ...(plan.tests.some((t) => t.file === file && t.shrinks) ? [`import fc from 'fast-check'`] : []), ...(plan.tests.some((t) => t.file === file && t.ioWait) ? [`import { spawn } from 'node:child_process'`] : []), + ...(plan.tests.some((t) => t.file === file && t.asProp) ? [`import { fc as fcv, test as fcTest } from '@fast-check/vitest'`] : []), `import * as sut from '../src/sut'`, ...(file === `b` ? [`import { strict } from './strict'`] : []), `function declareOracle(_parts: { owns: Array }): void {}`, @@ -1744,6 +1759,8 @@ function oracleTestFile(plan, file) { : `true` plan.tests.forEach((t, index) => { if (t.file !== file) return + // A property judges, slows, and hangs only in runs with a positive `n`. + const when = t.asProp ? `${gate} && n > 0` : gate // Each campaign registers its route, calls the function, observes, and // judges the unmutated output, which the model computes. const campaign = (part, i) => { @@ -1786,14 +1803,14 @@ function oracleTestFile(plan, file) { // where the output differs; it then judges as usual. ...(t.slow ? [ - ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => setTimeout(r, ${SLOW_MS}))`, + ` if (${when} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => setTimeout(r, ${SLOW_MS}))`, ] : []), // A test that waits on I/O (a child process, about 3 s) where the // output differs, then judges as usual. ...(t.ioWait ? [ - ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => spawn(process.execPath, ['-e', 'setTimeout(() => {}, ${SLOW_MS})']).on('exit', r))`, + ` if (${when} && JSON.stringify(${out}) !== '${expected}') await new Promise((r) => spawn(process.execPath, ['-e', 'setTimeout(() => {}, ${SLOW_MS})']).on('exit', r))`, ] : []), // An injected failure shrinks to its smallest counterexample. @@ -1806,17 +1823,17 @@ function oracleTestFile(plan, file) { // A flaky test never hangs: the model leaves flaky tests out. ...(t.hangs && !t.flaky ? [ - ` if (${gate} && JSON.stringify(${out}) !== '${expected}') await new Promise(() => {})`, + ` if (${when} && JSON.stringify(${out}) !== '${expected}') await new Promise(() => {})`, ] : []), ...(!t.judges ? [] : t.judgeBy !== `verdict` ? [ - ` if (${gate}) expect${t.judgeBy === `soft` ? `.soft` : ``}(${out}).toEqual(${expected})`, + ` if (${when}) expect${t.judgeBy === `soft` ? `.soft` : ``}(${out}).toEqual(${expected})`, ] : [ - ` if (${gate} && JSON.stringify(${out}) !== '${expected}') {`, + ` if (${when} && JSON.stringify(${out}) !== '${expected}') {`, ` g.__oracleVerdict?.({ property: 'fixture-${file}', law: '${t.law}', checkpoint: 'trace' })`, ` throw new Error('violation')`, ` }`, @@ -1832,7 +1849,9 @@ function oracleTestFile(plan, file) { ` })`, ] : []), - ` test('${testName(t, index)}', ${(t.hangs && !t.flaky) || t.slow || t.ioWait ? `async ` : ``}() => {`, + t.asProp + ? ` fcTest.prop([fcv.integer({ min: 0, max: 10 })], { numRuns: 20, seed: 1 })('${testName(t, index)}', async (n) => {` + : ` test('${testName(t, index)}', ${(t.hangs && !t.flaky) || t.slow || t.ioWait ? `async ` : ``}() => {`, ...partsOf(t).flatMap(campaign), ` })`, `})`, diff --git a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs index 5c551af588..6ec8b11437 100644 --- a/scripts/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-oracle.property.test.mjs @@ -605,6 +605,8 @@ async function driver(plan, observe) { states.add(`a workspace dependency's state judged fresh`) if (m.function === `w_shrink` && m.operator === `if-true` && m.class === `comparison-gap`) states.add(`a shrinking calibration passes under a mutant`) + if (m.function === `w_fcprop` && m.outcome === `killed`) + states.add(`a test.prop kill whose last run passed`) if (m.function === `w_cleanup` && m.outcome === `killed`) states.add(`a kill whose test's cleanup then threw`) if (m.function === `w_iowait` && m.outcome === `killed`) @@ -1076,6 +1078,7 @@ const required = [ `a hang cut off for idleness`, `a copy waiting on I/O killed, not cut for idleness`, `a kill whose test's cleanup then threw`, + `a test.prop kill whose last run passed`, `a shrinking calibration passes under a mutant`, `a workspace dependency's state judged fresh`, `each owner's value change killed its own mutant`, From 164d249f7cea22853a71438cbfcbc77f8cfbd3ba Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 19:43:17 -0700 Subject: [PATCH 081/168] perf(oracle-gaps): stop a hung worker at once and confirm hangs in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A copy past its first budget runs again alone at the full limit, so a slow kill is not reported as a timeout gap. Two things made that costly. The runner waited up to 5 s for a cancel, which a worker stuck in a synchronous loop never answers. And each confirmation ran on the slot whose batch was cut, after the others, while other slots sat idle. FIFO's M30 (`await this.executeTransaction()` → `await undefined`) makes the executor's loop spin on microtasks in each of its five owner files. It cost 147 s on one slot. Now a cut run is cancelled without waiting. The cancel lets the context's close stop the worker at once. A close with no cancel waits for the run and leaves the worker spinning; a trial of that used 1,294 s of CPU. Each confirmation is queued for the next free slot. A slot takes queued confirmations before its next group, and a slot with no groups left waits for more until every slot has finished its groups. The unattributed fallback now also replaces a context whose run it cut off. - Law: law 4, timeouts (unchanged): a timeout only after a run alone at the full limit, or an idle copy in a file with no I/O. - Reach: unchanged. The CLI oracle's hang witnesses (w_hang, w_hangkill, w_hangsurvive, w_slowkill, and "a batch cut off by a hang reran the rest") pass through both paths. - RED: not applicable; this is a speed change. - GREEN: the CLI oracle's fixed seed 20261105 passed in 53 s. Cold FIFO: 152 s → 49 s, all 222 classes identical. Cold db-ivm (hash.ts, murmur.ts, multiset.ts): 180 s → 117 s, all 429 classes identical. - Mutants: a confirmation that records a timeout without running failed with "src/sut.ts:w_slowkill:134:if-false#0: timeout, owed killed". A slot that does not wait for its queued confirmations failed with "sut.ts:M119: tests/a.test.ts has no unit and no earlier kill". - Limits: a mutant that hangs in several files still finds each hang through its own first budget, one after another on one slot (about 6 s each for M30), which now sets cold FIFO's floor. The unattributed fallback still has no confirmation: a timeout there at the first budget is final. The CLI oracle has no busy-loop hang, only a promise that never settles; the gain for spinning workers is measured on FIFO only. Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 155 ++++++++++++++++++++++------------ 1 file changed, 103 insertions(+), 52 deletions(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 822afc2188..d4da720833 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -415,12 +415,12 @@ export default mergeConfig(base, { limitMs, } if (result === undefined) { - const cancelled = Date.now() - await Promise.race([ - context.cancelCurrentRun(`keyboard-input`).catch(() => undefined), - sleep(5000), - ]) - span(`cancel`, cancelled, profiled) + // A run cut off is cancelled without waiting: a worker stuck in a + // synchronous loop never answers. The cancel lets the caller's + // replacement close the context at once, which stops the worker (a + // close with no cancel waits for the run). + running.catch(() => undefined) + context.cancelCurrentRun(`keyboard-input`).catch(() => undefined) span(`run`, started, { ...profiled, timedOut: true }) rmSync(capture, { force: true }) return { file, timedOut: true, seconds } @@ -603,10 +603,10 @@ export default mergeConfig(base, { const skippedUnit = (item) => ({ file: item.file, skipped: true }) try { if (result === undefined) { - await Promise.race([ - context.cancelCurrentRun(`keyboard-input`).catch(() => undefined), - sleep(5000), - ]) + // Cancelled without waiting; the caller replaces the context (see + // runUnit). + running.catch(() => undefined) + context.cancelCurrentRun(`keyboard-input`).catch(() => undefined) span(`run`, started, { file: `batch`, items: items.length, timedOut: current }) // The copies before the one that hung finished: their units come // from their capture rows. @@ -823,8 +823,9 @@ export default mergeConfig(base, { return result } // Units for many requests at once. Each is looked up first; the rest run - // as one batch (runBatch). A batch cut off by a hang records that item - // as timed out and reruns the rest in a fresh context; a batch with + // as one batch (runBatch). A batch cut off by a hang queues that item's + // confirming run, which any free slot takes, and reruns the rest in a + // fresh context; a batch with // errors outside any test runs its items alone. `needed` says, from the // units found so far, whether a request must still run; a request that // need not run, or whose copy skipped its tests, gets no unit. @@ -832,6 +833,14 @@ export default mergeConfig(base, { const index = indexOf.get(slot.context) const got = new Array(requests.length) let rest = [] + // The confirming runs of hung copies, which may run on other slots. + const confirming = [] + const settle = (request, u, at) => { + if (u.skipped) return + got[request.i] = u + remember(request.id, request.file, u, at) + evidence.computed += 1 + } requests.forEach((request, i) => { const id = idOf(request.file, request.spec, request.select, request.ignore) const valid = lookup(id, index) @@ -846,7 +855,7 @@ export default mergeConfig(base, { const batch = await runBatch(slot.context, options.out, rest, options.seed ?? null) if (batch.unattributed) { for (const r of rest) - if (needed(r, got)) + if (needed(r, got)) { got[r.i] = await unit(slot.context, r.file, r.spec, { mutant: r.mutant, select: r.select, @@ -854,6 +863,8 @@ export default mergeConfig(base, { ignore: r.ignore, limitMs: r.limitMs, }) + if (got[r.i].timedOut) await replace(slot) + } break } batch.units.forEach((u, k) => { @@ -871,48 +882,62 @@ export default mergeConfig(base, { evidence.cut += 1 if (batch.idle) evidence.idle += 1 await replace(slot) - // A copy that already had the full limit, or that sat idle in a file - // with no I/O, is a timeout as it stands: an idle copy waits on - // nothing that can come. - const alone = - hung.limitMs >= full || batch.idle - ? { timedOutAt: 0, units: [] } - : await runBatch( - slot.context, - options.out, - [{ ...hung, limitMs: full }], - options.seed ?? null, - ) - const hadFullLimit = hung.limitMs >= full || batch.idle - if (alone.unattributed) - got[hung.i] = await unit(slot.context, hung.file, hung.spec, { - mutant: hung.mutant, - select: hung.select, - stopOnKill: hung.stopOnKill, - ignore: hung.ignore, - limitMs: full, - }) - else { - const confirmed = - alone.timedOutAt === 0 - ? { file: hung.file, timedOut: true, seconds: full / 1000 } - : alone.units[0] - if (alone.timedOutAt === 0 && !hadFullLimit) { - evidence.cut += 1 - if (alone.idle) evidence.idle += 1 - await replace(slot) - } - if (!confirmed.skipped) { - got[hung.i] = confirmed - remember(hung.id, hung.file, confirmed, index) - evidence.computed += 1 - } - } + // A copy that already had the full limit, or that sat idle, is + // final; any other is confirmed on the next free slot, so a mutant + // that hangs in several files confirms them in parallel. + if (hung.limitMs >= full || batch.idle) + settle(hung, { file: hung.file, timedOut: true, seconds: full / 1000 }, index) + else + confirming.push( + new Promise((resolve, reject) => + confirm((other) => + confirmAlone(other, hung, full).then(resolve, reject), + ), + ).then(({ unit: u, at }) => + // A run with errors outside any test is not remembered. + at === undefined ? (got[hung.i] = u) : settle(hung, u, at), + ), + ) rest = rest.slice(batch.timedOutAt + 1) evidence.rerun += rest.length } + // This slot takes what no other slot has, then waits for the rest. + await confirmOn(slot) + await Promise.all(confirming) return got } + // A hung copy's confirming run: alone, in a clean context, with the full + // limit. Only a run cut off there too is a timeout. An idle copy in a + // file with no I/O is final at once (see unitsBatch): it waits on + // nothing that can come. + async function confirmAlone(slot, hung, full) { + const alone = await runBatch( + slot.context, + options.out, + [{ ...hung, limitMs: full }], + options.seed ?? null, + ) + if (alone.unattributed) { + const u = await unit(slot.context, hung.file, hung.spec, { + mutant: hung.mutant, + select: hung.select, + stopOnKill: hung.stopOnKill, + ignore: hung.ignore, + limitMs: full, + }) + if (u.timedOut) await replace(slot) + return { unit: u } + } + // The index of the context that ran it, before any replacement. + const at = indexOf.get(slot.context) + if (alone.timedOutAt === 0) { + evidence.cut += 1 + if (alone.idle) evidence.idle += 1 + await replace(slot) + return { unit: { file: hung.file, timedOut: true, seconds: full / 1000 }, at } + } + return { unit: alone.units[0], at } + } const vitest = contexts[0] const fileSpecs = new Map(contextSpecs.get(vitest)) @@ -935,7 +960,8 @@ export default mergeConfig(base, { const limitFor = (file) => options.hangLimit + 3000 * (fileSeconds.get(file) ?? 10) const firstLimitFor = (file) => Math.min(limitFor(file), options.firstLimit + 3000 * (fileSeconds.get(file) ?? 10)) - // A cancelled run can leave work behind, so its context is replaced. + // A run cut off is still running, so its context is replaced; closing + // it stops the worker. const replace = async (slot) => { const started = Date.now() await Promise.race([slot.context.close().catch(() => undefined), sleep(10_000)]) @@ -944,6 +970,21 @@ export default mergeConfig(base, { contexts[slot.index] = slot.context span(`replace context`, started, { slot: slot.index }) } + // Confirming runs of hung copies (unitsBatch), waiting for a free slot. + // A slot takes them before its next group, and a slot with no groups + // left waits for more until every slot is done with its groups. + const confirmations = [] + const waiting = [] + const wake = () => { + for (const resolve of waiting.splice(0)) resolve() + } + const confirm = (job) => { + confirmations.push(job) + wake() + } + const confirmOn = async (slot) => { + for (let job = confirmations.shift(); job; job = confirmations.shift()) await job(slot) + } // `after(index)` is more work for context `index` once its files ran. const runAcross = async (files, pass, after = async () => {}) => { const parts = contexts.map(() => []) @@ -1278,11 +1319,21 @@ export default mergeConfig(base, { ) } } + let evaluating = contexts.length await Promise.all( contexts.map(async (context, index) => { const slot = { context, index } - for (let group = queue.splice(0, GROUP); group.length > 0; group = queue.splice(0, GROUP)) + for (let group = queue.splice(0, GROUP); group.length > 0; group = queue.splice(0, GROUP)) { + await confirmOn(slot) await evaluateGroup(group, slot) + } + evaluating -= 1 + wake() + for (;;) { + await confirmOn(slot) + if (evaluating === 0) break + await new Promise((resolve) => waiting.push(resolve)) + } }), ) results.sort((a, b) => a.number - b.number || a.source.localeCompare(b.source)) From e8e0733545f2acb3d7bdf3df5866af8774fe0c98 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 19:47:30 -0700 Subject: [PATCH 082/168] fix(oracle-gaps): never take a runner copy for a test file The runner runs each mutant on copies of test files named `.oracle-gaps---`, and removes them when the batch ends. A check that dies first (an esbuild panic once killed one) leaves its copies behind, and the next check's discovery took them for test files: once 7,570 of them, from two runs. Discovery now skips every runner copy, whichever process made it. - Law: law 3, ownership. The oracle files are the package's own test files that declare an oracle (maintainer decisions v14 and v19). - Reach: every fixture state now holds a stale copy, `tests/.oracle-gaps-1-1-a.test.ts`, that declares an oracle and fails. - RED: on the parent, the CLI oracle's fixed seed 20261105 failed: "the CLI did not complete (exit 1): tests/.oracle-gaps-1-1-a.test.ts: owns entry src/sut.ts#w_kill names no function in src/sut.ts". - GREEN: the same command passed in 53 s. - Mutant: a filter that skips only this process's own copies failed the same way as the parent. - Limits: stale copies are still not removed; they only stop counting. Two checks of one package at once are still out of scope (they share `state.json` and the output directory). Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 6 +++++- scripts/oracle-gaps/gap-report-model-oracle.mjs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index d4da720833..becded34f5 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -334,8 +334,12 @@ export default mergeConfig(base, { // cleared. context.clearSpecificationsCache?.() const map = new Map() - for (const spec of await context.globTestSpecifications()) + for (const spec of await context.globTestSpecifications()) { + // A runner copy (runBatch), this process's or one a check that died + // left behind, is never a test file. + if (basename(spec.moduleId).startsWith(`.oracle-gaps-`)) continue map.set(spec.moduleId, [...(map.get(spec.moduleId) ?? []), spec]) + } contextSpecs.set(context, map) } async function closeContexts() { diff --git a/scripts/oracle-gaps/gap-report-model-oracle.mjs b/scripts/oracle-gaps/gap-report-model-oracle.mjs index 6cc93f71a3..89b3898eb4 100644 --- a/scripts/oracle-gaps/gap-report-model-oracle.mjs +++ b/scripts/oracle-gaps/gap-report-model-oracle.mjs @@ -1943,6 +1943,18 @@ function fixtureFiles(plan, state = initialState(plan)) { 'deps/state-dep/package.json': `{ "name": "state-dep", "private": true, "type": "module", "exports": "./index.js" }\n`, 'deps/state-dep/index.js': `const log = []\nexport function note(entry) {\n log.push(entry)\n}\nexport function seen() {\n return [...log]\n}\n`, 'tests/c.test.ts': ordinaryTestFile(plan), + // A copy that a check which died left behind (the runner names its + // copies `.oracle-gaps---`). It is no test file: a check + // that took it for one would refuse the run. + 'tests/.oracle-gaps-1-1-a.test.ts': [ + `import { test } from 'vitest'`, + `function declareOracle(_parts: { owns: Array }): void {}`, + `declareOracle({ owns: ['${SOURCE}#w_kill'] })`, + `test('fixed seed 1', () => {`, + ` throw new Error('a copy that a check which died left behind')`, + `})`, + ``, + ].join(`\n`), } for (const file of ORACLE_FILES) files[`tests/${file}.test.ts`] = oracleTestFile(plan, file) From 2a6e9d57f743b0426b00b1775ffe990e557563e6 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 21:50:49 -0700 Subject: [PATCH 083/168] fix(oracle-gaps): keep the runner correct and fast on Vitest 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two differences show up when the gap CLI runs on Vitest 5.0.5 (tried in a scratch worktree with Vite 8.3.5; the repository is on Vitest 4.1.12). Vitest 5 caches transformed modules on disk by default. The cache is keyed by the file, not by what the runner's plugin serves for it, so an authored mutant's patched source came back as an earlier text and the mutant survived. The runner's contexts now set `fsModuleCache: false`. Vitest 5 also stops a worker by asking it to, and waits up to a minute for an answer. A worker stuck in a synchronous loop never answers, so a cut run left its worker spinning after the context closed (cold FIFO took 118 s on Vitest 5 against 49 s on Vitest 4). A second cancel while the first is under way stops the worker by force, as a second Ctrl+C does. The runner now cancels twice. Vitest 4 already stopped it on the first. - Law: law 2, judgment (an authored mutant is judged by its own patched source), and law 4, timeouts (unchanged). - Reach: unchanged; authored mutants a and a2 and the hang witnesses. - RED: on Vitest 5 with the module cache on, the CLI oracle's fixed seed 20261105 failed: "the authored mutant a2 is survived, owed killed". - GREEN: with this commit, the same command passed on Vitest 5 (40 s) and on Vitest 4.1.12 (53 s). Cold FIFO on Vitest 5: 118 s → 57 s, all 222 classes identical to Vitest 4's. On Vitest 4, cold FIFO's classes are identical to the parent's. - Mutant: the parent itself (the cache on) fails as shown under RED. The single cancel is a speed difference only, not a class: on a stand-alone probe, Vitest 5 left the spinning worker using a full core after close (1,993 ms of CPU in 2 s); with two cancels, none. - Limits: on Vitest 5 the gap CLI is not faster than on Vitest 4 (cold FIFO 57-61 s against 49-54 s in this session's runs). Package suites run 8-25% faster on Vitest 5 (offline-transactions 6.1 s → 5.6 s, db-ivm 16.4 s → 12.3 s, db 454 s → 419 s, two workers). Under Vitest 5, `packages/db/tests/trace-runner.test-d.ts` fails as a test file ("No test suite found"); an upgrade needs a config change there. Co-authored-by: Isaac --- scripts/oracle-gaps/check.mjs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index becded34f5..5c4f624d51 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -309,6 +309,11 @@ export default mergeConfig(base, { teardownTimeout: settings.testTimeout, typecheck: { enabled: false }, coverage: { enabled: false }, + // Vitest 5 caches transformed modules on disk by default, keyed by + // the file, not by what this runner's plugin serves for it, so a + // patched or instrumented source could come back as an earlier text. + // Vitest 4 caches nothing unless asked. + fsModuleCache: false, // Every module each test file imports, which a unit records as its // inputs. experimental: { importDurations: { limit: 1_000_000 } }, @@ -375,6 +380,16 @@ export default mergeConfig(base, { } } + // Cancels a context's run so that its close stops the workers at once. + // Vitest 5 stops a worker by asking it to and waits up to a minute for + // the answer, which a worker stuck in a synchronous loop never gives; a + // second cancel while the first is under way stops it by force, as a + // second Ctrl+C does. Vitest 4 needs only the first. + function forceCancel(context) { + context.cancelCurrentRun(`keyboard-input`).catch(() => undefined) + context.cancelCurrentRun(`keyboard-input`).catch(() => undefined) + } + /** One Vitest run of one test file, as plain data. */ async function runUnit( context, @@ -424,7 +439,7 @@ export default mergeConfig(base, { // replacement close the context at once, which stops the worker (a // close with no cancel waits for the run). running.catch(() => undefined) - context.cancelCurrentRun(`keyboard-input`).catch(() => undefined) + forceCancel(context) span(`run`, started, { ...profiled, timedOut: true }) rmSync(capture, { force: true }) return { file, timedOut: true, seconds } @@ -610,7 +625,7 @@ export default mergeConfig(base, { // Cancelled without waiting; the caller replaces the context (see // runUnit). running.catch(() => undefined) - context.cancelCurrentRun(`keyboard-input`).catch(() => undefined) + forceCancel(context) span(`run`, started, { file: `batch`, items: items.length, timedOut: current }) // The copies before the one that hung finished: their units come // from their capture rows. From 9e8b4b93c9ce0022d4804ec0905eba3ee530745e Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 22:01:46 -0700 Subject: [PATCH 084/168] fix(oracle-gaps): skip runner copies in the static oracle's discovery too e8e073354 put a stale runner copy in every fixture state and taught the CLI's discovery to skip runner copies. The static oracle lists the fixture's test files itself, standing in for that discovery, and it did not skip the copy: its analysis refused the run, and the static oracle failed with "a history entered 'refused none', which the property does not declare". I did not run the static oracle before committing e8e073354. The rule is now one function, `isRunnerCopy` in analysis.mjs, which the CLI's discovery and the static driver both use. - Law: law 3, ownership (as in e8e073354). - Reach: unchanged. - RED: on the parent, `npx vitest run --config scripts/oracle-gaps/vitest.config.mjs gap-report-static-oracle` failed as quoted above (its parent, e8e073354~1, passed). - GREEN: the static and classify oracles passed (14 tests); the CLI oracle's fixed seed 20261105 passed in 53 s. - Mutant: an `isRunnerCopy` that skips nothing failed the static oracle the same way. - Limits: the static driver uses the production rule, so the static oracle alone does not judge it; the end-to-end CLI oracle does, through Vitest's own discovery. Co-authored-by: Isaac --- scripts/oracle-gaps/analysis.mjs | 9 ++++++++- scripts/oracle-gaps/check.mjs | 6 ++---- .../gap-report-static-oracle.property.test.mjs | 10 ++++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/oracle-gaps/analysis.mjs b/scripts/oracle-gaps/analysis.mjs index bbbddaecdc..be415d5761 100644 --- a/scripts/oracle-gaps/analysis.mjs +++ b/scripts/oracle-gaps/analysis.mjs @@ -5,10 +5,17 @@ // function each mutant belongs to. It reads only files, so the CLI's own // oracle checks it in-process. import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { ownership, readOwns } from './owners.mjs' import { instrument } from './schemata.mjs' +/** + * Whether a path is a runner copy of a test file (check.mjs, runBatch), this + * process's or one that a check which died left behind. Discovery skips + * copies: a copy is never a test file. + */ +export const isRunnerCopy = (path) => basename(path).startsWith(`.oracle-gaps-`) + // One compiler host per TypeScript copy. The declarations under // node_modules, such as the standard library's, are parsed once and kept, // which makes each later program several times faster to build. diff --git a/scripts/oracle-gaps/check.mjs b/scripts/oracle-gaps/check.mjs index 5c4f624d51..d1bbb6f949 100644 --- a/scripts/oracle-gaps/check.mjs +++ b/scripts/oracle-gaps/check.mjs @@ -37,7 +37,7 @@ import { basename, dirname, join, relative as relativePath } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { applyAuthored, loadAuthored } from './authored.mjs' import { assignKeys, renderReport } from './report.mjs' -import { analyze } from './analysis.mjs' +import { analyze, isRunnerCopy } from './analysis.mjs' import { baselineOf, judgeAuthored, @@ -340,9 +340,7 @@ export default mergeConfig(base, { context.clearSpecificationsCache?.() const map = new Map() for (const spec of await context.globTestSpecifications()) { - // A runner copy (runBatch), this process's or one a check that died - // left behind, is never a test file. - if (basename(spec.moduleId).startsWith(`.oracle-gaps-`)) continue + if (isRunnerCopy(spec.moduleId)) continue map.set(spec.moduleId, [...(map.get(spec.moduleId) ?? []), spec]) } contextSpecs.set(context, map) diff --git a/scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs b/scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs index 41dfc2c0ea..fbc848b327 100644 --- a/scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs +++ b/scripts/oracle-gaps/gap-report-static-oracle.property.test.mjs @@ -49,7 +49,7 @@ import { pass, violation, } from '../../packages/db/tests/oracle-property.ts' -import { analyze } from './analysis.mjs' +import { analyze, isRunnerCopy } from './analysis.mjs' import { LIB, SOURCE, @@ -72,14 +72,16 @@ const ts = createRequire(join(here, `../../package.json`))(`typescript`) // ## Production driver // // The driver writes the fixture as the end-to-end driver does, lists the -// test files the fixture's Vitest config includes (`tests/**/*.test.ts`), -// and calls `analyze` and `assignKeys`. It records a refusal's message, or +// test files the fixture's Vitest config includes (`tests/**/*.test.ts`) +// less the runner copies, as the CLI's discovery does (`isRunnerCopy`; the +// end-to-end oracle checks that discovery), and calls `analyze` and +// `assignKeys`. It records a refusal's message, or // each mutant's site, probe mark, ownership, and key. For a stability // history it rewrites the source and records the same again. function analyzeFixture(dir, files) { writeFixture(dir, files) const tests = readdirSync(join(dir, `tests`)) - .filter((name) => name.endsWith(`.test.ts`)) + .filter((name) => name.endsWith(`.test.ts`) && !isRunnerCopy(name)) .map((name) => join(dir, `tests`, name)) try { const { mutants, owned } = analyze(ts, dir, [SOURCE, LIB], tests) From 3c76e91d1ba730cb224f4eb0ae2c69210f4d23be Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 7 Nov 2026 22:29:04 -0700 Subject: [PATCH 085/168] feat(oracle-gaps): add the field-drop and arithmetic-swap operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The miss diagnosis (C0) found one reviewer finding that no operator could express: a stale retry row (M63), where `retryCount: x + 1` and the `lastError` fields had no mutant. Two operators now cover that shape: - `arithmetic-swap`: `+` ↔ `-` where one operand is a numeric literal. - `field-drop`: a property of an object literal is left out, and its value is not evaluated. Not a spread, method, accessor, `__proto__`, or a destructuring pattern. The CLI oracle's grammar gains a `record` statement (`out.push(JSON.stringify({ n: x + 1, s: 's