/** * rigor/fuzzer-mutation-needles.test.js — greybox-fuzzer campaign over * config-kind transitions interleaved with mutation bursts. * * The needle class this hunts: cache staleness when a checked relation's * config kind changes while its decision/rule caches are warm, under * arbitrary interleavings of adds, removes, and config redefinitions. * The fuzzer's mutation + replay-near-failure strategies deepen the * sequences that approach invariant violations. * * The mirror independently computes check expectations from the raw tuple * map and the CURRENT config kind; engine check results must agree after * every step (decision and value). * * Also verifies the fuzzer machinery: report.fuzzerStats is populated and * corpus-based strategies were scheduled. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const NODES = 6; const nodeKey = (id) => (id < 2 ? `u:${id}` : id === 2 ? 'g:0' : `doc:${id - 3}`); const KINDS = ['direct', 'chain', 'union', 'exclusion', 'intersection', 'defeasible']; const CONFIGS = { direct: { type: 'direct', relation: 'owner' }, chain: { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }, union: { union: { rules: [ { type: 'direct', relation: 'owner' }, { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' }] } ] } }, exclusion: { exclusion: [ { type: 'direct', relation: 'owner' }, { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' }] } ] }, intersection: { intersection: { rules: [ { type: 'direct', relation: 'owner' }, { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' }] } ] } }, defeasible: { when: { type: 'direct', relation: 'owner' }, unless: { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' }] } } }; function mirrorCheck(tuples, kind, src, rel, dst) { const srcKey = nodeKey(src); const dstKey = nodeKey(dst); const key = (s, r, d) => `${s}|${r}|${d}`; const p = (s, r, d) => tuples.get(key(s, r, d)) ?? 0; if (rel !== 'can_read') return 0; const direct = p(srcKey, 'owner', dstKey); let chainBest = 0; for (let m = 0; m < NODES; m++) { chainBest = Math.max(chainBest, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey))); } if (kind === 'direct') return direct; if (kind === 'chain') return chainBest; if (kind === 'union') return Math.max(direct, chainBest); if (kind === 'exclusion') return direct * (1 - chainBest); if (kind === 'intersection') return Math.min(direct, chainBest); if (kind === 'defeasible') return direct * (1 - chainBest); return 0; } function makeWrapper() { const arbiter = new Arbiter(); for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc'); arbiter.setRelationConfig('can_read', CONFIGS.direct); const tuples = new Map(); const tupleKey = (s, r, d) => `${s}|${r}|${d}`; const ops = []; const wrapper = { kind: 'direct', engine: arbiter, tuples, add(src, rel, dst, p) { const key = nodeKey(src); const dstKey = nodeKey(dst); const exists = arbiter.nodeIdByKey.has(key) && arbiter.nodeIdByKey.has(dstKey); if (!exists) return { ok: true, skipped: true }; arbiter.addRelation(key, rel, dstKey, { possibility: p }); // throws on invalid p tuples.set(tupleKey(key, rel, dstKey), p === undefined ? 1.0 : p); return { ok: true }; }, remove(src, rel, dst) { const key = nodeKey(src); const dstKey = nodeKey(dst); if (!arbiter.nodeIdByKey.has(key) || !arbiter.nodeIdByKey.has(dstKey)) return { ok: true, skipped: true }; arbiter.removeRelation(key, rel, dstKey); tuples.delete(tupleKey(key, rel, dstKey)); return { ok: true }; }, setConfig(kind) { arbiter.setRelationConfig('can_read', CONFIGS[kind]); wrapper.kind = kind; return { ok: true }; }, check(src, rel, dst) { const result = arbiter.check(nodeKey(src), rel, nodeKey(dst)); const expected = mirrorCheck(tuples, wrapper.kind, src, rel, dst); const got = typeof result.possibility === 'number' && Number.isFinite(result.possibility) ? result.possibility : -1; return { engine: Math.round(got * 10000) / 10000, expected: Math.round(expected * 10000) / 10000, reason: result.reason }; }, clone() { const fresh = makeWrapper(); for (const op of ops) { const [name, ...args] = op; fresh[name](...args); } return fresh; } }; const record = (name, fn) => (...args) => { const res = fn(...args); ops.push([name, ...args]); return res; }; wrapper.add = record('add', wrapper.add); wrapper.remove = record('remove', wrapper.remove); wrapper.setConfig = record('setConfig', wrapper.setConfig); wrapper.check = record('check', wrapper.check); return wrapper; } describe('Fuzzer campaign over config transitions (rigor)', () => { it('NEEDLE HUNT: check parity holds under fuzzed mutation/config interleavings', async () => { const nodeArg = rigor.gen.int(0, NODES - 1); const result = await rigor.campaign( [rigor.object('graph', makeWrapper, [ rigor.method('add', function (s, r, d, p) { return this.add(s, r, d, p); }, rigor.args(nodeArg, rigor.gen.enum(['owner', 'member_of', 'reads']), nodeArg, rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]))), rigor.method('remove', function (s, r, d) { return this.remove(s, r, d); }, rigor.args(nodeArg, rigor.gen.enum(['owner', 'member_of', 'reads']), nodeArg)), rigor.method('setConfig', function (kind) { return this.setConfig(kind); }, rigor.args(rigor.gen.enum(KINDS))), rigor.method('check', function (s, r, d) { return this.check(s, r, d); }, rigor.args(rigor.gen.int(0, 1), rigor.gen.constant('can_read'), rigor.gen.oneOf([3, 4, 5]))) ])], rigor.crucible([ rigor.invariant('decision parity after every step', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; const { engine, expected } = ctx.actual; return (engine >= 0) === (expected >= 0) || Math.abs(engine - expected) < 1e-4; }), rigor.invariant('value parity after every step', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; const { engine, expected } = ctx.actual; return Math.abs(engine - expected) < 1e-4; }), rigor.invariant('errors are clean validation errors, never internal crashes', (ctx) => { if (ctx.error === null) return true; const message = ctx.error && ctx.error.message ? ctx.error.message : String(ctx.error); return /Invalid|expected/i.test(message); }), rigor.after('graph.setConfig', ({ objects, error }) => { return error !== null || KINDS.includes(objects.graph.kind); }) ]) ).run({ effort: 500, seed: 'fuzzer-config-needles', maxTraceLength: 32, fuzzer: { enabled: true, maxCorpusSize: 200, strategies: ['random', 'mutation', 'replay-near-failure'], mutationInterval: 5 } }); const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `fuzzed parity violated in ${inv.failureCount} cases:`, ...result.failures.slice(0, 3).map((f) => ` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` ) ].join('\n')); const json = result.toJSONReport(); assert.ok(json.fuzzerStats, 'fuzzerStats must be present in the report'); assert.ok(json.fuzzerStats.corpusSize >= 0, 'corpus size tracked'); }); });