/** * rigor/comparator-full-path.test.js — js-rigor property tests for * RelationalComparatorRule through the FULL check() pipeline (compiled * evaluator, rule collector, checker wiring) — the existing * relational-comparator-rule.test.js only exercises the rule directly. * * Properties verified: * * - COMPARISON PARITY: value comparisons through check() agree with a * direct oracle (left > right with epsilon => high possibility + * values_compared_comparison_true; otherwise 0 + _comparison_false). * - VALUE FLOW: edge values reach the comparator from direct relations * on both the user and object perspectives (evaluateFrom auto/user/object). * - MUTATION FRESHNESS: value updates flip comparisons immediately * (with warm caches). * - PATH PARITY: compiled and rule-based paths agree exactly. * - BINARY DECISION: binary allow iff normal possibility >= threshold. */ 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 VALUES = [0, 10, 50, 100, 1000]; 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; } }; } function buildArbiter() { const arb = new Arbiter(); arb.addNode('user:alice', 'user'); arb.addNode('doc:secret', 'doc'); arb.setRelationConfig('has_balance', { type: 'direct' }); arb.setRelationConfig('has_price', { type: 'direct' }); arb.setRelationConfig('premium', { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }); return arb; } describe('Relational comparator full-path parity (rigor)', () => { it('COMPARISON + MUTATION PARITY through check()', async () => { async function check({ seed }) { const rng = mulberry32(seed); const arb = buildArbiter(); let balance = VALUES[Math.floor(rng.next() * VALUES.length)]; let price = VALUES[Math.floor(rng.next() * VALUES.length)]; arb.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0 }); arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 }); const verify = (tag) => { const res = arb.check('user:alice', 'premium', 'doc:secret', {}); const expected = balance > price ? 1 : 0; if (Math.abs(res.possibility - expected) > EPS) { fail(`${tag}: balance=${balance} price=${price} expected=${expected} got=${res.possibility} reason=${res.reason}`); } // Reason contract: the false outcome surfaces the comparator reason; // the true outcome carries it inside meta.allow (outer reason is // the generic allow_rule_matched). if (balance > price) { const metaRes = arb.check('user:alice', 'premium', 'doc:secret', { includeMeta: true }); if (metaRes.meta?.allow?.reason !== 'values_compared_comparison_true') { fail(`${tag}: expected meta.allow.reason=values_compared_comparison_true, got ${metaRes.meta?.allow?.reason}`); } } 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)) { fail(`${tag}: binary allow=${bin.allow} expected=${expected >= 0.5}`); } }; verify('initial'); for (let i = 0; i < 4; i++) { if (rng.next() < 0.5) { balance = VALUES[Math.floor(rng.next() * VALUES.length)]; arb.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0 }); } else { price = VALUES[Math.floor(rng.next() * VALUES.length)]; arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 }); } verify(`mutation ${i}`); } return { balance, price }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 80000) }) )) ], rigor.crucible([ rigor.invariant('comparator-full-path', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 1200, seed: 'comparator-full-path-parity' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'comparator-full-path'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `comparator full-path parity violated in ${inv.failureCount} cases`); }); it('AGGREGATION: multiple value-carrying edges aggregate by max for the operand', async () => { async function check({ seed }) { const rng = mulberry32(seed); const arb = buildArbiter(); arb.addNode('mid:1', 'mid'); // Two balance edges (user -> mid1 -> doc via r1), values 100 and 40 arb.setRelationConfig('r1', { type: 'direct' }); arb.addRelation('user:alice', 'r1', 'mid:1', { value: 100, possibility: 1.0 }); arb.addRelation('mid:1', 'r1', 'doc:secret', { value: 40, possibility: 1.0 }); const price = 50; arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 }); // Operand over a chain: values collected along the chain aggregate arb.setRelationConfig('balance_chain', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r1', direction: 'out' }] }); arb.setRelationConfig('premium_chain', { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r1', direction: 'out' }] }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }); const res = arb.check('user:alice', 'premium_chain', 'doc:secret', { includeMeta: true }); // Values along the chain: 100 and 40; max aggregator -> 100 > 50 -> true if (res.meta?.allow?.reason !== 'values_compared_comparison_true' || Math.abs(res.possibility - 1) > EPS) { fail(`chain operand comparison: got reason=${res.reason} p=${res.possibility} allowReason=${res.meta?.allow?.reason}`); } return { res: res.possibility }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 80000) }) )) ], rigor.crucible([ rigor.invariant('comparator-aggregation', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 500, seed: 'comparator-aggregation' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'comparator-aggregation'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `comparator aggregation violated in ${inv.failureCount} cases`); }); });