Contract — P1.1.2 Determinism Verification Harness (R83.A)
1. Module identity
- Path:
src/domains/rules/determinism.ts - Greek letter: κ (kappa) — Rule Engine
- Role: Reusable N-run equality harness + static function-body forbidden-op scanner. Used by κ test suites and by downstream P1.1.3 / P1.2.x / P1.3.x sub-tasks to self-assert their own helpers stay deterministic.
- Visibility: public module inside
src/; imported fromsrc/__tests__/domains/rules/**and, in later sub-tasks, from othersrc/domains/rules/**tests.
2. Public API
All functions are synchronous, pure (aside from throwing), and take / return
either bigint-safe structurally-equal values or a Function reference.
No number quantities, no Math.*, no Date.*, no async, no RNG inside the
library itself — P1.1.2 must itself pass the very forbidden-op scanner it
exposes.
2.1. Typed error
/**
* Thrown by `assertDeterministic` when two calls with the same inputs
* produce non-equal outputs, or by `assertNoForbiddenOps` when the
* regex scanner finds a forbidden token.
*/
export class DeterminismError extends Error {
override readonly name: 'DeterminismError';
constructor(message: string);
}
2.2. Core harness
/**
* Run `fn(...args)` `opts.iterations` times (default 10). On every call
* after the first, compare the output (or thrown error) against the
* reference run. Throw `DeterminismError` if any pair differs.
*
* - iterations >= 1 (default 10). iterations == 1 short-circuits to a
* single call and returns the value.
* - iterations < 1 throws DeterminismError at entry.
* - Consistent throws are treated as deterministic: if run 1 threws E
* and runs 2..N threw equivalent errors (same name + message), the
* harness re-throws the first error at the end.
* - Mixed throw/return across runs throws DeterminismError immediately.
*
* Returns the first-call result on success.
*/
export function assertDeterministic<A extends readonly unknown[], R>(
fn: (...args: A) => R,
args: A,
opts?: { iterations?: number; label?: string },
): R;
2.3. Function-body scanners
/**
* Regex-inspect `fn.toString()` for forbidden-op tokens. Returns a sorted,
* deduped list of offending substrings. Empty array means clean.
*
* Patterns are source-level; they catch textual references inside the
* function body. Arrow functions, function expressions, and class methods
* are all supported because the V8 `toString()` representation preserves
* source for all three.
*
* Native or bound functions produce a toString() starting with
* `function name() { [native code] }` — the scanner treats native-code
* bodies as a single forbidden token `"[native code]"` to force opt-in
* review. The P1.1.1 helpers are all user-defined and pass clean.
*/
export function inspectFunctionForbidden(fn: Function): readonly string[];
/**
* Throws `DeterminismError` when `inspectFunctionForbidden(fn)` is
* non-empty. Error message lists the offending tokens and `opts.label`
* when supplied.
*/
export function assertNoForbiddenOps(
fn: Function,
opts?: { label?: string },
): void;
2.4. Equality helper
/**
* Deep structural equality with bigint-awareness. Contract:
* - primitives compared with Object.is (so NaN !== NaN is honored,
* but the contract forbids producing NaN in κ).
* - bigints compared with ===.
* - arrays compared length-then-elementwise.
* - plain objects compared by Set<keys> + elementwise.
* - Error instances compared by (name, message).
* - all other object shapes (Map/Set/Date/Promise/class instances)
* are treated as opaque: they are equal iff `===`. (κ rule code
* never produces these; we fail-closed by default.)
*/
export function deepEqualDeterministic(a: unknown, b: unknown): boolean;
3. Invariants
| # | Invariant | Enforcement |
|---|---|---|
| I1 | The library itself uses no Math.* / Date.* / async / RNG / float literals |
The scanner, when turned on itself, returns [] for every export. AC#8 tests this. |
| I2 | assertDeterministic is order-insensitive across iterations ≥ 2 |
Iterate pairwise against run 1; no caller-observable ordering effects. |
| I3 | assertDeterministic produces actionable error messages |
Error includes label, iteration index, inspect-style rendering of both values truncated to 120 chars each. |
| I4 | inspectFunctionForbidden is pure (no globals read, no closures) |
Regex patterns are module-top-level readonly. The function only operates on fn.toString(). |
| I5 | Float-literal detection rejects 3.14 but not 1n, 100, or 1_000_000 |
Regex \b\d+\.\d+\b anchored on decimal dot. |
| I6 | The scanner is false-positive-tolerant but false-negative-intolerant | Matches that hit any pattern group count; silencing a match requires contract amendment. |
4. Forbidden-op pattern manifest
Module-top-level readonly array. Any addition = contract amendment.
| # | Regex | Rejects | Test case |
|---|---|---|---|
| 1 | /\bMath\.[A-Za-z_]\w*/g |
Math.random, Math.floor, Math.abs, … |
inspect(() => Math.random()) |
| 2 | /\bDate\.[A-Za-z_]\w*/g |
Date.now, Date.parse, Date.UTC |
inspect(() => Date.now()) |
| 3 | /\bnew\s+Date\b/g |
new Date() / new Date(…) |
inspect(() => new Date()) |
| 4 | /\b(?:setTimeout\|setInterval\|setImmediate)\b/g |
Timers | inspect(() => setTimeout(()=>{},0)) |
| 5 | /\b(?:fetch\|XMLHttpRequest)\b/g |
Network | inspect(() => fetch('x')) |
| 6 | /\brequire\s*\(\s*['"](?:fs\|node:fs)['"]/g |
CJS fs import | inspect(() => require('fs')) |
| 7 | /\bfrom\s+['"](?:fs\|node:fs)['"]/g |
ESM fs import | inspect(() => { import 'fs' }) |
| 8 | /\bcrypto\.[A-Za-z_]\w*/g |
crypto.randomBytes, crypto.getRandomValues |
inspect(() => crypto.randomBytes(4)) |
| 9 | /\bprocess\.(?:hrtime\|nextTick)\b/g |
Hrtime / microtask | inspect(() => process.hrtime()) |
| 10 | /\bawait\b/g |
await |
inspect(async () => await x()) |
| 11 | /\basync\s+(?:function\|\()/g |
async function / async arrow | inspect(async () => 1) |
| 12 | /\b\d+\.\d+\b/g |
Float literal 3.14 (not 1n or 100) |
inspect(() => 3.14) |
| 13 | /\[native code\]/g |
Native-code bodies (opaque — fail-closed) | inspect(Math.random.bind(null)) |
Each pattern uses the g flag so all matches in the body are captured.
Matches are deduped via Set and returned sorted by pattern index so the
output is deterministic.
5. Determinism rules for assertDeterministic
5.1. Default iterations
opts.iterations === undefined→ iterations = 10.opts.iterations >= 1→ iterations =opts.iterations.opts.iterations < 1→ throwDeterminismError("iterations must be >= 1").
5.2. Reference run
- Call
fn(...args)once. If it throws, capture{ threw: true, name, message }. Else capture{ threw: false, value }.
5.3. Verification runs (i = 2 .. iterations)
For each subsequent call:
- Call
fn(...args). Capture result. - If reference threw:
- If current did not throw → throw
DeterminismError("run 1 threw; run i returned value"). - If current threw but name/message differ → throw
DeterminismError("run 1 threw A; run i threw B").
- If current did not throw → throw
- If reference returned:
- If current threw → throw
DeterminismError("run 1 returned value; run i threw"). - If current returned but
!deepEqualDeterministic(ref.value, cur.value)→ throwDeterminismError("run 1 returned X; run i returned Y").
- If current threw → throw
5.4. Return
- If reference threw and every run produced the same typed throw → re-throw the
caller-observable error (not the
DeterminismError; the caller is asserting the fn is deterministic even in its failure). - If reference returned → return
ref.value.
5.5. Label propagation
When supplied, opts.label prefixes every error message with [${label}]. This
makes multi-function harness runs greppable by function name.
6. Acceptance criteria
- AC#1 —
src/domains/rules/determinism.tsexportsDeterminismError,assertDeterministic,inspectFunctionForbidden,assertNoForbiddenOps,deepEqualDeterministic. - AC#2 — Harness wraps all six P1.1.1 helpers (
bps_mul,bps_div,apply_bps,decay,safe_mul,safe_div) over sample inputs withiterations = 10and every call succeeds. - AC#3 — Harness fails loudly when given a non-deterministic mock (
() => Math.random()or() => Date.now()): thrownDeterminismErrorincludes the two differing outputs. - AC#4 —
inspectFunctionForbiddenreturns[]for every P1.1.1 helper. - AC#5 —
inspectFunctionForbiddenreturns a non-empty array for each of the 13 forbidden patterns (per §4 table). - AC#6 —
assertNoForbiddenOpsthrowsDeterminismErrorwheninspectresult is non-empty; is a no-op when empty. - AC#7 —
deepEqualDeterministiccorrectly compares: equal/unequal primitives, bigints, arrays, plain objects, nested structures, typed errors (name + message),null/undefined. - AC#8 — Self-scan test: every shipped file under
src/domains/rules/(excluding__tests__/, excluding the test file, excludingdeterminism.tsitself at this contract’s discretion — see §6.1) is regex-scanned from the test harness and returns an empty forbidden list. - AC#9 — 100% branch coverage on
determinism.tsper Jest lcov. - AC#10 —
npm run build && npm run lint && npm testall green (1123 baseline + ≥30 new = ≥1153 tests pass).
6.1. Self-scan scope clarification
The scanner sees its own readonly regex array at parse time: one of the
patterns in the array is the literal string "[native code]", and the float
regex /\b\d+\.\d+\b/ has the decimal literal .\d inside the source. So when
determinism.ts is textually scanned, pattern #12 can match itself (the regex
source literal contains \d+\.\d+ which after regex compilation is the
text \d+\.\d+ not 3.14, so actually it won’t match — but pattern #13 with
/\[native code\]/ will match the string-literal "[native code]" stored
in code if we include it verbatim).
Resolution: the test file excludes determinism.ts itself from the
self-scan. The scanner file is tested directly via the 13 unit tests of
§4 instead. The self-scan checks everything else in src/domains/rules/
stays clean (currently just integer-math.ts). This matches the prompt’s
statement: “grep check rejects any use of Math.* or Date.* outside tests”.
7. Test surface
Path: src/__tests__/domains/rules/determinism.test.ts
Harness: Jest 29 (ts-jest ESM). No new dev deps. No fast-check —
hand-rolled cases are sufficient for 100% branch coverage.
Imports:
../../../domains/rules/determinism.js(the new harness)../../../domains/rules/integer-math.js(the module under guardrail)node:fs/promises+node:pathfor the AC#8 corpus scannode:urlforfileURLToPath(import.meta.url)path math
Groups:
DeterminismError— typed-error surface (AC#1).assertDeterministic— happy path (AC#2), throw-path, FAIL detection (AC#3), label propagation, iterations override branches.inspectFunctionForbidden— clean functions (AC#4), every pattern (AC#5).assertNoForbiddenOps— wrapper (AC#6).deepEqualDeterministic— all branches (AC#7).rule-engine corpus self-scan— fs-side (AC#8).
8. Performance contract
- Full new suite < 5 s wall clock (task-prompts.md AC specifies < 10 s for the determinism test; we commit to half that since we run 10 iterations, not 10k fuzz tuples).
- Corpus scan opens each file sequentially; the rule-engine dir has 1 file now, will stay under 20 files through Phase 1. O(files × pattern-count) with regex scanning is well under the limit.
9. Non-goals / explicit deferrals
- No fuzz testing with seeded PRNG. The task-prompts doc lines 251–252 mention a 10k-tuple fuzz harness. This is valuable but is not how the orchestrator dispatch scoped this task: the orchestrator says “N times (configurable, e.g. 10 iterations)”. Property-style fuzz is deferred to whichever later sub-task needs it (e.g. P1.3.1’s evaluator can land a fuzz harness against pre-canned rule fixtures).
- No
fast-checkdev-dep. Not in package.json; adding it would be scope-creep. - No file-watching in the scanner. Test reads files once per run.
- No TypeScript AST parser. Regex-on-source is sufficient per the
orchestrator prompt’s “toString() regex inspection as a fast-path check,
or via runtime
Reflect/proxy spy — choose whichever is simpler”. Regex is simpler. - No MCP tool registration. Library-only, same as P1.1.1.
10. Rollback
Single-commit revert of the implementation commit. No downstream imports
of determinism.ts until P1.1.3 / P1.2.x / P1.3.x come online in later
R83 waves or R84+. Zero schema, zero ADR, zero docs-outside-quartet.