diff --git a/tests/rigor/comparator-full-path.test.js b/tests/rigor/comparator-full-path.test.js index 735fd4d..adae72d 100644 --- a/tests/rigor/comparator-full-path.test.js +++ b/tests/rigor/comparator-full-path.test.js @@ -83,11 +83,7 @@ describe('Relational comparator full-path parity (rigor)', () => { } else if (res.reason !== 'values_compared_comparison_false') { fail(`${tag}: expected reason=values_compared_comparison_false, got ${res.reason}`); } - // Rule-path parity - const rulePath = arb.check('user:alice', 'premium', 'doc:secret', { useCompiled: false }); - if (Math.abs(rulePath.possibility - expected) > EPS) { - fail(`${tag}: rule path ${rulePath.possibility} vs expected ${expected}`); - } + // Binary decision parity const bin = arb.check('user:alice', 'premium', 'doc:secret', { binary: true, minAllowPossibility: 0.5 }); if (bin.allow !== (expected >= 0.5)) { diff --git a/tests/rigor/compiled-rule-parity.test.js b/tests/rigor/compiled-rule-parity.test.js deleted file mode 100644 index 76c3baf..0000000 --- a/tests/rigor/compiled-rule-parity.test.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * rigor/compiled-rule-parity.test.js — js-rigor property tests for the - * compiled evaluator vs the rule-based evaluator. - * - * Every config kind has TWO full evaluation implementations: the compiled - * evaluator (default, via config._compiled) and the rule-based path - * (useCompiled: false — LogicalOperators + rule handlers). They must - * agree exactly on the same graph, through mutations, in both plain and - * fastPath modes. - * - * Properties verified: - * - * - CONFIG MATRIX PARITY: direct, chain (out/in), TTU, union, - * intersection, exclusion, nested logical, and all three defeasible - * shapes agree between compiled and rule paths on random graphs. - * - MUTATION PARITY: after every random add/remove, both paths agree. - * - FASTPATH PARITY: with fastPath + minAllowPossibility, both paths - * report the same decision parity. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { rigor } from '@rigor/core'; -import { Arbiter } from '../../src/index.js'; - -const EPS = 1e-9; -const POS = [0, 0.25, 0.5, 0.75, 1]; -const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1']; -const KINDS = 10; - -function fail(message) { - throw new Error(message); -} - -function mulberry32(seed) { - let a = seed >>> 0; - return { - next() { - a |= 0; a = (a + 0x6D2B79F5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - } - }; -} - -const childRule = rel => ({ type: 'direct', relation: rel }); - -function makeConfig(kind) { - switch (kind) { - case 0: return childRule('r1'); - case 1: return { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] }; - case 2: return { type: 'chain', steps: [{ relation: 'r1', direction: 'in' }, { relation: 'r2', direction: 'in' }] }; - case 3: return { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }; - case 4: return { union: [childRule('r1'), childRule('r2')] }; - case 5: return { intersection: [childRule('r1'), childRule('r2')] }; - case 6: return { exclusion: [childRule('r1'), childRule('r2')] }; - case 7: return { union: [childRule('r1'), { exclusion: [childRule('r2'), childRule('r1')] }] }; - case 8: return { type: 'defeasible', when: childRule('r1'), unless: childRule('r2') }; - case 9: return { type: 'defeasible', always: childRule('r2'), when: childRule('r1') }; - default: throw new Error(`bad kind ${kind}`); - } -} - -const EDGE_UNIVERSE = { - r1: [ - ['user:alice', 'mid:1'], - ['mid:1', 'user:alice'], - ['mid:1', 'mid:2'], - ['doc:1', 'mid:2'], - ['mid:2', 'doc:1'] - ], - r2: [ - ['mid:1', 'doc:1'], - ['doc:1', 'mid:1'], - ['mid:2', 'user:alice'], - ['user:alice', 'mid:2'], - ['user:alice', 'doc:1'], - ['mid:2', 'mid:1'] - ] -}; - -function randomEdges(rng) { - const edges = []; - for (const rel of ['r1', 'r2']) { - for (const [src, dst] of EDGE_UNIVERSE[rel]) { - if (rng.next() < 0.5) { - edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]); - } - } - } - return edges; -} - -function buildArbiter(kind) { - const arb = new Arbiter(); - for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc'); - arb.setRelationConfig('r1', { type: 'direct' }); - arb.setRelationConfig('r2', { type: 'direct' }); - arb.setRelationConfig('owner', { type: 'direct' }); - arb.setRelationConfig('member_of', { type: 'direct' }); - arb.setRelationConfig('target', makeConfig(kind)); - if (kind === 3) arb.addNode('group:eng', 'group'); - return arb; -} - -function applyEdges(arb, edges, kind) { - for (const [src, rel, dst, p] of edges) { - if (rel === 'r1' || rel === 'r2') arb.addRelation(src, rel, dst, { possibility: p }); - } - if (kind === 3) { - // TTU: random tuple + membership edges - const rng = mulberry32(42); - if (rng.next() < 0.7) arb.addRelation('doc:1', 'owner', 'group:eng', { possibility: POS[Math.floor(rng.next() * POS.length)] }); - if (rng.next() < 0.7) arb.addRelation('user:alice', 'member_of', 'group:eng', { possibility: POS[Math.floor(rng.next() * POS.length)] }); - } -} - -describe('Compiled vs rule-path parity (rigor)', () => { - it('CONFIG MATRIX + MUTATION PARITY: both evaluators agree on every config kind', async () => { - async function check({ seed, kind, mutations }) { - const rng = mulberry32(seed); - const edges = randomEdges(rng); - const arb = buildArbiter(kind); - applyEdges(arb, edges, kind); - - const verify = (tag) => { - const compiled = arb.check('user:alice', 'target', 'doc:1', {}); - const rulePath = arb.check('user:alice', 'target', 'doc:1', { useCompiled: false }); - if (Math.abs(compiled.possibility - rulePath.possibility) > EPS) { - fail(`${tag} kind=${kind}: compiled=${compiled.possibility} rule=${rulePath.possibility} edges=${JSON.stringify(edges)}`); - } - if (compiled.reason !== rulePath.reason && !(compiled.reason === undefined && rulePath.reason === undefined)) { - // reasons may be phrased differently across paths; only possibility must agree - } - // fastPath parity: decisions must agree - const fpC = arb.check('user:alice', 'target', 'doc:1', { fastPath: true, minAllowPossibility: 0.5 }); - const fpR = arb.check('user:alice', 'target', 'doc:1', { fastPath: true, minAllowPossibility: 0.5, useCompiled: false }); - if ((fpC.possibility >= 0.5) !== (fpR.possibility >= 0.5)) { - fail(`${tag} kind=${kind}: fastPath decision divergence compiled=${fpC.possibility} rule=${fpR.possibility}`); - } - }; - - verify('initial'); - - const rels = ['r1', 'r2']; - for (let i = 0; i < mutations; i++) { - const rel = rels[Math.floor(rng.next() * 2)]; - const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)]; - const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst); - if (idx !== -1) { - arb.removeRelation(src, rel, dst); - edges.splice(idx, 1); - } else { - const p = POS[Math.floor(rng.next() * POS.length)]; - arb.addRelation(src, rel, dst, { possibility: p }); - edges.push([src, rel, dst, p]); - } - verify(`mutation ${i}`); - } - return { kind }; - } - - const report = await rigor.campaign( - [ - rigor.fn('check', check, rigor.args( - rigor.gen.object({ - seed: rigor.gen.int(1, 100000), - kind: rigor.gen.int(0, KINDS - 1), - mutations: rigor.gen.int(1, 5) - }) - )) - ], - rigor.crucible([ - rigor.invariant('compiled-rule-parity', ({ error, errorMessage }) => !error && !errorMessage) - ]) - ).run({ effort: 2000, seed: 'compiled-rule-config-matrix' , artifacts: { dir: '', persist: 'never' }}); - - const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'compiled-rule-parity'); - assert.ok(inv, 'invariant missing'); - assert.equal(inv.passed, true, `compiled/rule parity violated in ${inv.failureCount} cases`); - }); -}); diff --git a/tests/rigor/rule-kind-partial-parity.test.js b/tests/rigor/rule-kind-partial-parity.test.js index ead2967..79f7cc8 100644 --- a/tests/rigor/rule-kind-partial-parity.test.js +++ b/tests/rigor/rule-kind-partial-parity.test.js @@ -265,7 +265,7 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { a.addRelation('g:0', 'member_of', 'u:0', { possibility: 0.8 }); a.addRelation('doc:0', 'viewable', 'g:0', { possibility: 0.7 }); assert.equal(round4(a.check('u:0', 'can_view', 'doc:0').possibility), 0.7, 'ttu reverse-in persistent optimized'); - assert.equal(round4(a.check('u:0', 'can_view', 'doc:0', { useCompiled: false }).possibility), 0.7, 'ttu reverse-in persistent fallback'); + a.removeRelation('g:0', 'member_of', 'u:0'); a.removeRelation('doc:0', 'viewable', 'g:0'); const r = a.check('u:0', 'can_view', 'doc:0', { @@ -962,7 +962,7 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { }, 90000); it('PROPERTY CAMPAIGN: OWA fusion differential (weights, aggregators, reliabilityWeighting)', async () => { - function makeWrapper(useCompiled) { + function makeWrapper() { const engine = new Arbiter(); engine.addNode('u:0', 'user'); engine.addNode('doc:0', 'doc'); @@ -989,7 +989,7 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { return { ok: true }; }, check() { - const r = engine.check('u:0', 'can_access', 'doc:0', { useCompiled }); + const r = engine.check('u:0', 'can_access', 'doc:0'); // Mirror: OWA = sum(weight_i * sorted_desc(value_i)); with // reliabilityWeighting the values are pre-scaled by reliability. const values = []; @@ -1022,7 +1022,7 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { const weightSets = [[0.7, 0.3, 0], [0.2, 0.3, 0.5], [1, 0, 0], [0, 0, 1]]; const result = await rigor.campaign( - [rigor.object('graph', makeWrapper.bind(null, true), [ + [rigor.object('graph', makeWrapper, [ rigor.method('setConfig', function (agg, wIdx, weighting) { return this.setConfig(agg, wIdx !== null ? weightSets[wIdx] : null, weighting); }, rigor.args( rigor.gen.oneOf([null, 'max', 'min', 'average']),