diff --git a/src/core/arbiter/ArbiterConfig.js b/src/core/arbiter/ArbiterConfig.js index e061744..9e349ed 100644 --- a/src/core/arbiter/ArbiterConfig.js +++ b/src/core/arbiter/ArbiterConfig.js @@ -9,6 +9,11 @@ export class ArbiterConfig { } setRelationConfig(relation, config) { + if (config === null || typeof config !== 'object') { + throw new Error( + `Invalid relation config for ${String(relation)}: expected a config object, got ${config === null ? 'null' : typeof config}` + ); + } const normalized = this._normalizeOwaConfig(config); this.arbiter.relationConfigs.set(relation, normalized); this.arbiter.graphManager.setRelationConfig(relation, normalized); diff --git a/tests/rigor/fuzzer-mutation-needles.test.js b/tests/rigor/fuzzer-mutation-needles.test.js new file mode 100644 index 0000000..171aa46 --- /dev/null +++ b/tests/rigor/fuzzer-mutation-needles.test.js @@ -0,0 +1,190 @@ +/** + * 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']; +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' }] } + ] + } +}; + +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); + 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'); + }); +}); diff --git a/tests/rigor/protocol-snapshot-lifecycle.test.js b/tests/rigor/protocol-snapshot-lifecycle.test.js index f31810b..c7cb2bf 100644 --- a/tests/rigor/protocol-snapshot-lifecycle.test.js +++ b/tests/rigor/protocol-snapshot-lifecycle.test.js @@ -189,7 +189,34 @@ const result = await rigor.campaign( if (action === 'graph.restore') return objects.graph.flag !== 'live' || error !== null; return true; }), - rigor.after('graph.setConfig', ({ objects, error }) => error === null || objects.graph.flag === 'restored' ? true : false) + rigor.after('graph.setConfig', ({ objects, error }) => error === null || objects.graph.flag === 'restored' ? true : false), + rigor.beforeStep(0, ({ objects, calls }) => { + const g = objects.graph; + return g.tuples.size === 0 && g.flag === 'live' && calls.length === 0; + }), + rigor.afterStep(2, ({ objects, calls }) => { + const g = objects.graph; + if (g.flag === 'restored') return true; + const engineKeys = new Set(); + for (const r of g.engine.relations) { + const srcKey = g.engine.keyByNodeId.get(r.src); + const dstKey = g.engine.keyByNodeId.get(r.dst); + if (srcKey !== undefined && dstKey !== undefined) { + engineKeys.add(`${srcKey}|${r.rel}|${dstKey}`); + } + } + for (const k of g.tuples.keys()) { + if (!engineKeys.has(k)) return false; + } + return g.tuples.size === engineKeys.size; + }), + rigor.beforeStep(4, ({ objects, calls }) => { + const g = objects.graph; + if (g.flag === 'live' || g.flag === 'enabled') { + return g.tuples.size === g.engine.relations.length; + } + return true; + }) ]) ).run({ effort: 400, seed: 'snapshot-lifecycle-protocol', maxTraceLength: 24 });