Files
core/tests/rigor/comparator-full-path.test.js
T

174 lines
7.5 KiB
JavaScript
Raw Normal View History

/**
* 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}`);
}
// 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' , artifacts: { dir: '', persist: 'never' }});
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' , artifacts: { dir: '', persist: 'never' }});
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`);
});
});