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:
src/domains/rules/parity-harness.ts— single-purpose pure module.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/0state/settlement-complete/2gov/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
- 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. - Run
npm run build && npm run lint && npm testin the worktree. - If any test fails, fix in place and amend (only for Step 4 commit; the audit / contract / packet are immutable once committed).
- Step 5 commit (verify) lands the verification doc.
- 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.