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 from src/__tests__/domains/rules/** and, in later sub-tasks, from other src/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 → throw DeterminismError("iterations must be >= 1").

5.2. Reference run

  1. 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:

  1. Call fn(...args). Capture result.
  2. 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").
  3. If reference returned:
    • If current threw → throw DeterminismError("run 1 returned value; run i threw").
    • If current returned but !deepEqualDeterministic(ref.value, cur.value) → throw DeterminismError("run 1 returned X; run i returned Y").

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#1src/domains/rules/determinism.ts exports DeterminismError, 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 with iterations = 10 and every call succeeds.
  • AC#3 — Harness fails loudly when given a non-deterministic mock (() => Math.random() or () => Date.now()): thrown DeterminismError includes the two differing outputs.
  • AC#4inspectFunctionForbidden returns [] for every P1.1.1 helper.
  • AC#5inspectFunctionForbidden returns a non-empty array for each of the 13 forbidden patterns (per §4 table).
  • AC#6assertNoForbiddenOps throws DeterminismError when inspect result is non-empty; is a no-op when empty.
  • AC#7deepEqualDeterministic correctly compares: equal/unequal primitives, bigints, arrays, plain objects, nested structures, typed errors (name + message), null/undefined.
  • AC#8Self-scan test: every shipped file under src/domains/rules/ (excluding __tests__/, excluding the test file, excluding determinism.ts itself 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.ts per Jest lcov.
  • AC#10npm run build && npm run lint && npm test all 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:path for the AC#8 corpus scan
  • node:url for fileURLToPath(import.meta.url) path math

Groups:

  1. DeterminismError — typed-error surface (AC#1).
  2. assertDeterministic — happy path (AC#2), throw-path, FAIL detection (AC#3), label propagation, iterations override branches.
  3. inspectFunctionForbidden — clean functions (AC#4), every pattern (AC#5).
  4. assertNoForbiddenOps — wrapper (AC#6).
  5. deepEqualDeterministic — all branches (AC#7).
  6. 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-check dev-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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.