ed34df4474
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
position is now EXPANDED from the current node — the rule's base edges'
destinations, filtered by its defeaters/requirements — and traversal
continues from each discovered node. Adds _expandRuleFromSrc / direct /
logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
entity IS the object parameter (trusted(other) inside peer_trusted(user,
other)); previously only subject-var unary calls (_subjectAsObject) were
handled, so object-var unary defeaters never fired.
Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
DecisionCache rule cache now stamp entries with the graph version and treat
any mismatch as a miss — graph mutations can no longer serve stale
chain/authorization results.
Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
JSON.stringify — no string allocation or serialization on the chain-cache
hot path. Composite keys stay structured strings because the direct-check
cache pattern-invalidates by relation ID.
Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
!error && !errorMessage) never saw fn throws — vacuous. Migrated to
({ actual }) => actual !== undefined, which fails on any thrown violation
while passing legitimate null-skips. The migration immediately surfaced
two latent bugs, now fixed:
* node-manager/graph-indices skip paths returned bare undefined (falsy
sentinel) — return { skipped: true }.
* complex-graph-values-crucible expiry section rewrote values equal to the
mutation loop's last write; the engine (by design) keeps the old
timestamp on same-value rewrites so the pre-expiry grant never
materialized. Now writes guaranteed-different values.
174 lines
7.4 KiB
JavaScript
174 lines
7.4 KiB
JavaScript
/**
|
|
* 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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).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`);
|
|
});
|
|
});
|