P1.5.5 — Test Corpus Parity Harness — Execution Packet (Step 3)

Branch: feature/p1-5-5-parity-harness Worktree: .worktrees/claude/p1-5-5-parity-harness Base SHA: 0150dcd1 Wave: R87 κ Wave 6 Author tier: T3 executor Audit: docs/audits/p1-5-5-parity-harness-audit.md (dbac7cd6) Contract: docs/contracts/p1-5-5-parity-harness-contract.md (ca2cb7b1)


§1. Plan summary

This packet locks the implementation order before Step 4 (Implement). Two new files ship:

  1. src/domains/rules/parity-harness.ts — single-purpose pure module.
  2. src/__tests__/domains/rules/parity-harness.test.ts — Jest ESM suite, 18 fixtures.

No edits to existing source files. Zero changes to package.json. Zero changes to tsconfig.json or eslint.config.js. The harness is purely additive at base 0150dcd1.


§2. File-by-file plan

§2.1. src/domains/rules/parity-harness.ts

Layout (sections in source order):

§1.  Imports
§2.  Module-level constants (EFFECT_HASH_PREFIX, EFFECT_HASH_HEX_LENGTH,
     EFFECT_HASH_TOTAL_LENGTH)
§3.  Types — EventId, EventIdPattern, ParityEvent, ParityInput,
     ParityEventDetail, ParityReport
§4.  Error class — ParityHarnessError
§5.  Pure helpers — wrapAsRegistry, anyRuleAdmitted, collapseToRuleResult,
     matchesScope, validateInput
§6.  effectHash — SHA-256 over canonicalize(mutations)
§7.  runParity — main entry
§8.  DEFAULT_CORPUS — 101 hand-curated events (frozen)
§9.  Re-exports for ergonomics

Imports (no new dependencies):

import { createHash } from 'node:crypto';

import { canonicalize } from './canonical.js';
import {
  CATEGORY_ORDER,
  executeRuleset,
} from './engine.js';
import type {
  CategorizedRule,
  Mutation,
  RuleResult,
  RuleRegistry,
  TransitionResult,
} from './engine.js';

createHash is named (NOT crypto.createHash) so the corpus self-scan does not match the crypto.<id> pattern. Same as versioning.ts:72.

Constants:

export const EFFECT_HASH_PREFIX = 'sha256:';
export const EFFECT_HASH_HEX_LENGTH = 64;
export const EFFECT_HASH_TOTAL_LENGTH = 71;  // 'sha256:' + 64 hex

Type definitions — verbatim from contract §2.

Error class:

export class ParityHarnessError extends Error {
  override readonly name = 'ParityHarnessError';
  constructor(message: string) { super(message); }
}

Pure helpers:

function wrapAsRegistry(rules: readonly CategorizedRule[]): RuleRegistry {
  return { getAll(): readonly CategorizedRule[] { return rules; } };
}

function anyRuleAdmitted(t: TransitionResult): boolean {
  for (const c of CATEGORY_ORDER) {
    const arr = t.per_category_results.get(c);
    if (arr === undefined) continue;
    for (const r of arr) {
      if (r.status === 'admitted') return true;
    }
  }
  return false;
}

function collapseToRuleResult(t: TransitionResult): RuleResult {
  // First admitted rule across CATEGORY_ORDER ⇒ admitted with all_mutations.
  for (const c of CATEGORY_ORDER) {
    const arr = t.per_category_results.get(c);
    if (arr === undefined) continue;
    for (const r of arr) {
      if (r.status === 'admitted') {
        return { status: 'admitted', mutations: t.all_mutations };
      }
    }
  }
  // No admit ⇒ first rejection reason wins. Otherwise NO_RULES.
  for (const c of CATEGORY_ORDER) {
    const arr = t.per_category_results.get(c);
    if (arr === undefined) continue;
    for (const r of arr) {
      if (r.status === 'rejected') {
        return { status: 'rejected', reason: r.reason };
      }
    }
  }
  return { status: 'rejected', reason: 'NO_RULES' };
}

export function matchesScope(
  id: EventId,
  patterns: readonly EventIdPattern[],
): boolean {
  for (const p of patterns) {
    if (typeof p === 'string') {
      if (id === p) return true;
    } else if (p instanceof RegExp) {
      if (p.test(id)) return true;
    }
  }
  return false;
}

function validateInput(input: ParityInput): void {
  // ... 8 conditions per contract §5 ...
}

effectHash (the cryptographic hash of an effect set):

export function effectHash(mutations: readonly Mutation[]): string {
  const body = canonicalize(mutations);
  const hash = createHash('sha256');
  hash.update(body, 'utf8');
  return EFFECT_HASH_PREFIX + hash.digest('hex');
}

runParity (the main entry):

export function runParity(input: ParityInput): ParityReport {
  validateInput(input);
  const oldRegistry = wrapAsRegistry(input.old_ruleset);
  const newRegistry = wrapAsRegistry(input.new_ruleset);

  const both_admit_same: EventId[] = [];
  const both_admit_diverge: EventId[] = [];
  const old_admit_new_reject: EventId[] = [];
  const old_reject_new_admit: EventId[] = [];
  const both_reject: EventId[] = [];
  const details: Map<EventId, ParityEventDetail> = new Map();

  for (const e of input.corpus) {
    const old_t = executeRuleset(
      oldRegistry, e.event, e.state, e.rule_version, e.epoch);
    const new_t = executeRuleset(
      newRegistry, e.event, e.state, e.rule_version, e.epoch);

    const old_admitted = anyRuleAdmitted(old_t);
    const new_admitted = anyRuleAdmitted(new_t);
    const old_hash = effectHash(old_t.all_mutations);
    const new_hash = effectHash(new_t.all_mutations);
    const old_result = collapseToRuleResult(old_t);
    const new_result = collapseToRuleResult(new_t);

    if (old_admitted && new_admitted) {
      if (old_hash === new_hash) both_admit_same.push(e.id);
      else                       both_admit_diverge.push(e.id);
    } else if (old_admitted && !new_admitted) {
      old_admit_new_reject.push(e.id);
    } else if (!old_admitted && new_admitted) {
      old_reject_new_admit.push(e.id);
    } else {
      both_reject.push(e.id);
    }
    details.set(e.id, { old_result, new_result, old_hash, new_hash });
  }

  // Pass decision per contract §3.5.
  let pass = both_admit_diverge.length === 0;
  if (pass) {
    for (const id of old_admit_new_reject) {
      if (!matchesScope(id, input.declared_divergence_scope)) {
        pass = false;
        break;
      }
    }
  }
  if (pass) {
    for (const id of old_reject_new_admit) {
      if (!matchesScope(id, input.declared_divergence_scope)) {
        pass = false;
        break;
      }
    }
  }

  Object.freeze(both_admit_same);
  Object.freeze(both_admit_diverge);
  Object.freeze(old_admit_new_reject);
  Object.freeze(old_reject_new_admit);
  Object.freeze(both_reject);
  return Object.freeze({
    both_admit_same,
    both_admit_diverge,
    old_admit_new_reject,
    old_reject_new_admit,
    both_reject,
    pass,
    details_by_event: details,
  });
}

DEFAULT_CORPUS layout — see §3.

§2.2. src/__tests__/domains/rules/parity-harness.test.ts

Layout:

§Test helpers — buildRule(name, admit?), buildRejectingRule(name, reason),
                buildAdmittingRuleWithSet, buildEvent, buildPair,
                expectFrozen, expectHashFormat
§F1   Identical rulesets — all-same
§F2   Old admits, new rejects — bucket population
§F3   Old rejects, new admits — bucket population
§F4   Diverging mutations — both_admit_diverge populated, pass=false
§F5   Both reject — bucket population, hashes equal effectHash([])
§F6   Empty corpus
§F7   Empty rulesets, non-empty corpus
§F8   Determinism — same input ⇒ identical report bytes (canonicalize)
§F9   Performance — 10000 events < 5000 ms
§F10  Default corpus — shape + uniqueness + freeze
§F11  Scope — string match
§F12  Scope — regex match
§F13  Scope — empty
§F14  Hash format — every hash 71 chars, 'sha256:' prefix
§F15  Determinism scanner — runParity, effectHash, matchesScope return []
§F16  Input validation — 8 ParityHarnessError paths
§F17  Cross-call independence — input arrays/maps unchanged
§F18  CollapseToRuleResult — NO_RULES path (empty rulesets, with details)

Test helpers reuse the patterns from engine.test.ts:55–157 (LOC ad-hoc rule builders).


§3. Default corpus design (§6 of contract)

§3.1. Distribution

Admission       24
StateTransition 18
Consequence     12
Promotion        8
Governance      12
Identity        12
Fork            15
─────────────────
Total          101

Total 101 ≥ 100 (AC requirement).

§3.2. Event ID format

Each event has a stable id of the form:

<category-slug>/<sub-shape>/<index>

Where:

  • <category-slug>{admit, state, conseq, promo, gov, identity, fork}
  • <sub-shape> ∈ varies by category (see below)
  • <index> is a stable 0-based ordinal within that (category, sub-shape) pair

Examples:

  • admit/commitment-create/0
  • state/settlement-complete/2
  • gov/vote-passed/1

§3.3. Sub-shape catalog

Admission (24 events) — 6 transition types × 4 shapes:

  • commitment-create, commitment-accept, dispute-open, governance-propose, identity-create, fork-create
  • shapes: clean (admit), with-effects (admit + non-trivial mutation), rejected-by-guard (rejected), no-match (no rule applies)

StateTransition (18 events) — 6 transition types × 3 shapes:

  • settlement-complete, settlement-fail, dispute-resolve, governance-vote, identity-update, fork-merge
  • shapes: clean (admit), rejected-by-guard, no-match

Consequence (12 events) — REPUTATION_DECAY × 12 numeric shapes:

  • decay shapes covering (amount, basis_points, epochs) triples spanning zero-decay, partial-decay, full-decay, with bigints up to ±2^32 to keep determinism well-behaved without hitting int64 caps

Promotion (8 events) — bare-name rules × 4 shapes:

  • shapes: admit-clean (×2), reject-by-guard (×2), no-match (×4)

Governance (12 events) — GOVERNANCE_PROPOSE/VOTE × 6 shapes:

  • shapes: propose-clean, vote-tied, vote-passed, vote-failed, withdrawn, expired

Identity (12 events) — IDENTITY_CREATE/UPDATE × 6 shapes:

  • shapes: create-new, create-duplicate, update-name, update-key, update-noop, update-stale

Fork (15 events) — FORK_CREATE/MERGE × varied shapes:

  • shapes: genesis, branch-clean, branch-rejected, merge-clean, merge-conflict, ascendant, divergent, fast-forward, criss-cross, forest, dangling, cyclic-rejection, merge-noop, cross-fork-merge, genesis-failure

§3.4. Event content

Each ParityEvent has:

{
  id: 'admit/commitment-create/0',
  event: { type: 'COMMITMENT_CREATE', actor: 'a', target: 'b', amount: 100n },
  state: { now: 1000n, supply: 10000n },
  rule_version: 'v1',
  epoch: 1n,
}

The event and state records are plain objects (per contract §3.6 canonical compatibility). Numeric values are bigint to match the engine’s arithmetic. Strings are simple ASCII to keep canonical encoding stable.

§3.5. Freeze

The corpus literal is built once at module-load time:

function buildDefaultCorpus(): readonly ParityEvent[] {
  const events: ParityEvent[] = [
    /* literal entries */
  ];
  // Deep freeze each event + its event/state records, then the array.
  for (const e of events) {
    Object.freeze(e.event);
    Object.freeze(e.state);
    Object.freeze(e);
  }
  return Object.freeze(events);
}

export const DEFAULT_CORPUS: readonly ParityEvent[] = buildDefaultCorpus();

Object.freeze is shallow. We freeze event, state, and the wrapper — that covers the 4 mutable surfaces. Bigints + strings are immutable already. Tests verify all three freezes via Object.isFrozen.


§4. Test strategy detail

§4.1. F1 — Identical rulesets

const rules = [makeAdmittingRule('R')];
const report = runParity({
  old_ruleset: rules,
  new_ruleset: rules,
  corpus: [buildEvent({ id: 'e1' })],
  declared_divergence_scope: [],
});
expect(report.both_admit_same).toEqual(['e1']);
expect(report.both_admit_diverge).toEqual([]);
expect(report.pass).toBe(true);

§4.2. F2 — Old admits, new rejects

const oldRules = [makeAdmittingRule('R')];
const newRules = [makeRejectingRule('R')];
const report = runParity({
  old_ruleset: oldRules,
  new_ruleset: newRules,
  corpus: [buildEvent({ id: 'e1' })],
  declared_divergence_scope: ['e1'],
});
expect(report.old_admit_new_reject).toEqual(['e1']);
expect(report.pass).toBe(true);  // in scope

const reportTight = runParity({
  ...same,
  declared_divergence_scope: [],
});
expect(reportTight.pass).toBe(false);  // out of scope

§4.3. F4 — Diverging mutations

Build two rules whose admit branch produces different set mutations:

const oldRules = [makeAdmittingRuleWithSet('R', 'field', 100n)];
const newRules = [makeAdmittingRuleWithSet('R', 'field', 200n)];
const report = runParity({
  old_ruleset: oldRules, new_ruleset: newRules,
  corpus: [buildEvent({ id: 'e1' })],
  declared_divergence_scope: ['e1'],  // even with full scope, diverge
                                       // is fatal
});
expect(report.both_admit_diverge).toEqual(['e1']);
expect(report.pass).toBe(false);

§4.4. F8 — Determinism

const a = runParity(input);
const b = runParity(input);
const aBytes = canonicalize({
  buckets: [a.both_admit_same, a.both_admit_diverge,
            a.old_admit_new_reject, a.old_reject_new_admit, a.both_reject],
  pass: a.pass,
  details: serializeMap(a.details_by_event),
});
const bBytes = canonicalize({ /* same shape, b */ });
expect(aBytes).toBe(bBytes);

The serialization shape is identical, so byte-equality on the JSON strings is the determinism check.

§4.5. F9 — Performance

const corpus: ParityEvent[] = [];
for (let i = 0; i < 10000; i++) {
  corpus.push(buildEvent({
    id: 'perf/' + i.toString(),
    event: { i: BigInt(i) },
    state: {},
  }));
}
const oldRules = [makeAdmittingRule('R')];
const newRules = [makeAdmittingRule('R')];
const t0 = Date.now();
const report = runParity({
  old_ruleset: oldRules, new_ruleset: newRules,
  corpus, declared_divergence_scope: [],
});
const dt = Date.now() - t0;
expect(dt).toBeLessThan(5000);
expect(report.both_admit_same).toHaveLength(10000);

Date.now() is OK in tests — the corpus self-scan only checks files in src/domains/rules/, not src/__tests__/.

§4.6. F15 — Determinism scanner

expect(inspectFunctionForbidden(runParity)).toEqual([]);
expect(inspectFunctionForbidden(effectHash)).toEqual([]);
expect(inspectFunctionForbidden(matchesScope)).toEqual([]);

§4.7. F18 — NO_RULES collapse path

The collapse function returns {status: 'rejected', reason: 'NO_RULES'} when both for-loops fall through (i.e. per_category_results has no admitted AND no rejected — only possible with empty rulesets, where every category array is empty).

const report = runParity({
  old_ruleset: [],
  new_ruleset: [],
  corpus: [buildEvent({ id: 'e1' })],
  declared_divergence_scope: [],
});
expect(report.both_reject).toEqual(['e1']);
const detail = report.details_by_event.get('e1')!;
expect(detail.old_result).toEqual({ status: 'rejected', reason: 'NO_RULES' });
expect(detail.new_result).toEqual({ status: 'rejected', reason: 'NO_RULES' });

§5. Risk register

Risk Severity Mitigation
crypto.createHash token sneaking in via JSDoc Low Stripped before scan. Verify by running determinism.test.ts §Group 12 after impl.
[native code] literal in error message Low Stripped before scan; we never write it as a literal.
await accidentally added when refactoring Med Run npm run lint + inspectFunctionForbidden(runParity).
Float literal in DEFAULT_CORPUS Med Use bigint-only for numeric values: 100n not 100.0.
Map iteration non-determinism None ECMA-262 guarantees insertion-order Map iteration.
Frozen-array TypeError from helper push Med Helper builds local arrays; only freezes at the very end. Push paths are pre-freeze.
Empty patterns array passes vacuous truth None matchesScope returns false for empty patterns; decidePass then sees a divergent event with no scope match → pass=false. Verified by F13.
10k corpus test flakes on slow CI Low 5s budget is conservative; typical Phase 0 CI runs the engine at ≪1ms/event. If flake reproduces, raise to 8s in a follow-up.
AST depth-first walking re-enters Set.has on cycles None canonicalize already cycle-detects. The harness never builds cyclic Mutation values; tests only use plain objects.

§6. Rollback plan

The harness is purely additive — two new files, zero edits to existing code. Rollback = git revert <merge-sha>. No DB migrations, no config changes, no env-var reads, no consumer touch points yet (P1.5.2 will import this in Wave 7+).


§7. Build order

  1. Step 4 commit ships both new files in a single feat(p1-5-5): commit. The test file imports from the implementation file, so they must land together to avoid a broken intermediate.
  2. Run npm run build && npm run lint && npm test in the worktree.
  3. If any test fails, fix in place and amend (only for Step 4 commit; the audit / contract / packet are immutable once committed).
  4. Step 5 commit (verify) lands the verification doc.
  5. Push branch, open PR.

§8. Test-file-imports (declared statically up-front)

To avoid drift between contract and tests:

// parity-harness.test.ts
import {
  DEFAULT_CORPUS,
  EFFECT_HASH_PREFIX,
  EFFECT_HASH_HEX_LENGTH,
  EFFECT_HASH_TOTAL_LENGTH,
  effectHash,
  matchesScope,
  ParityHarnessError,
  runParity,
} from '../../../domains/rules/parity-harness.js';
import type {
  EventId,
  EventIdPattern,
  ParityEvent,
  ParityEventDetail,
  ParityInput,
  ParityReport,
} from '../../../domains/rules/parity-harness.js';
import { canonicalize } from '../../../domains/rules/canonical.js';
import {
  inspectFunctionForbidden,
} from '../../../domains/rules/determinism.js';
import {
  CATEGORY_ORDER,
} from '../../../domains/rules/engine.js';
import type {
  CategorizedRule,
  Mutation,
  RuleNode,
} from '../../../domains/rules/parser.js';

(Note: RuleNode is imported from parser.ts, not engine.ts. The engine re-exports the type indirectly through CategorizedRule.rule, but its canonical home is the parser.)


§9. Acceptance crosswalk — packet to AC

AC# (audit §8) Packet section
AC1 — runParity exists §2.1 §7 + impl ships in §7 step 1
AC2 — SHA-256 effect hashes §2.1 effectHash
AC3 — 5 buckets §2.1 runParity
AC4 — pass condition §2.1 runParity (decide block)
AC5 — details_by_event §2.1 runParity (details map)
AC6 — DEFAULT_CORPUS ≥100 events §3 distribution table
AC7 — Determinism §4.4 F8 + scanner test §4.6 F15
AC8 — 10k <5s §4.5 F9
AC9 — Scanner clean §4.6 F15 + corpus self-scan inclusion
AC10 — gates green §7 build order

§10. References

  • Step 1 audit: docs/audits/p1-5-5-parity-harness-audit.md (dbac7cd6)
  • Step 2 contract: docs/contracts/p1-5-5-parity-harness-contract.md (ca2cb7b1)
  • Spec: docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.5.5
  • Sibling packets: docs/packets/p1-5-1-version-hash-packet.md, docs/packets/p1-5-4-canonical-packet.md, docs/packets/p1-3-1-engine-packet.md

Step 3 / 5. Execution packet complete. Step 4 (Implement) begins next.


Back to top

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

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