Files
core/tests/rigor/relational-comparator-rule.test.js
John Dvorak ed34df4474
CI / benchmark (push) Successful in 48s
CI / test (push) Successful in 5m26s
CI / publish (push) Has been skipped
feat: intermediate chain condition steps, graph-version cache invalidation, rolling-hash chain keys; fix vacuous rigor invariants
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.
2026-08-03 13:26:42 -07:00

231 lines
11 KiB
JavaScript

/**
* rigor/relational-comparator-rule.test.js — js-rigor property tests for RelationalComparatorRule.
*
* RelationalComparatorRule compares values from two operands (rules) and returns
* access based on whether the comparison holds. Properties verified:
*
* - left > right (with sufficient gap) → high possibility, reason='values_compared_comparison_true'
* - left < right → 0 possibility, reason='values_compared_comparison_false'
* - left == right (with epsilon tolerance) → comparison true
* - missing left operand → fallbackBehavior 'deny' yields 0
* - missing left operand → fallbackBehavior 'allow' yields high possibility
* - result.possibility ∈ [0, 1]
* - minRulePossibility threshold: possibilities below it drop to 0
* - result has stable shape (possibility, reliability, reason, meta)
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { RuleEvaluator } from '../../src/authorization/RuleEvaluator.js';
import { RelationalComparatorRule } from '../../src/authorization/rules/RelationalComparatorRule.js';
let arbiter, ruleEvaluator, comparatorRule;
beforeEach(() => {
arbiter = new Arbiter();
ruleEvaluator = new RuleEvaluator(arbiter);
comparatorRule = new RelationalComparatorRule(arbiter, ruleEvaluator);
// Set up minimal relations
arbiter.addNode('user:alice', 'user');
arbiter.addNode('doc:secret', 'doc');
arbiter.setRelationConfig('has_balance', { type: 'direct' });
arbiter.setRelationConfig('has_price', { type: 'direct' });
});
function evalRule(userKey, objectKey, rule, options = {}) {
const userId = arbiter.resolveNodeId(userKey);
const objectId = arbiter.resolveNodeId(objectKey);
return comparatorRule._evaluateRule(userId, userKey, objectId, objectKey, rule, new Set(), null, { collectValues: true, includeMeta: true, ...options });
}
describe('RelationalComparatorRule evaluation (rigor)', () => {
it('left > right → high possibility, reason=values_compared_comparison_true', async () => {
async function check(balance, price) {
// Skip trivial case where balance == price
if (balance <= price) return null;
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
const rule = {
type: 'relational_comparator',
comparator: '>',
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
};
const result = evalRule('user:alice', 'doc:secret', rule);
if (result.reason !== 'values_compared_comparison_true') {
throw new Error(`expected reason='values_compared_comparison_true', got '${result.reason}' (balance=${balance}, price=${price})`);
}
if (result.possibility <= 0.5) {
throw new Error(`expected high possibility when balance > price, got ${result.possibility}`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.float({ min: 100, max: 10000 }),
rigor.gen.float({ min: 0, max: 99 }) // price always less than balance
)
)],
rigor.crucible([
rigor.invariant('left-gt-right', ({ actual }) => actual !== undefined)
])
).run({ seed: 'rc-rule-left-gt-right', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'left-gt-right');
assert.ok(inv);
assert.equal(inv.passed, true, `left > right contract violated in ${inv.failureCount} cases`);
});
it('left < right → 0 possibility, reason=values_compared_comparison_false', async () => {
async function check(balance, price) {
if (balance >= price) return null;
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
const rule = {
type: 'relational_comparator',
comparator: '>',
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
};
const result = evalRule('user:alice', 'doc:secret', rule);
if (result.possibility !== 0) {
throw new Error(`expected 0 when balance < price, got ${result.possibility}`);
}
if (result.reason !== 'values_compared_comparison_false') {
throw new Error(`expected reason='values_compared_comparison_false', got '${result.reason}'`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.float({ min: 0, max: 99 }), // balance always less than price
rigor.gen.float({ min: 100, max: 10000 })
)
)],
rigor.crucible([
rigor.invariant('left-lt-right', ({ actual }) => actual !== undefined)
])
).run({ seed: 'rc-rule-left-lt-right', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'left-lt-right');
assert.ok(inv);
assert.equal(inv.passed, true, `left < right contract violated in ${inv.failureCount} cases`);
});
it('result.possibility ∈ [0, 1] always', async () => {
async function check(balance, price) {
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
const rule = {
type: 'relational_comparator',
comparator: '>',
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
};
const result = evalRule('user:alice', 'doc:secret', rule);
if (result.possibility < 0 || result.possibility > 1) {
throw new Error(`possibility=${result.possibility} outside [0,1] (balance=${balance}, price=${price})`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.float({ min: 0, max: 10000 }),
rigor.gen.float({ min: 0, max: 10000 })
)
)],
rigor.crucible([
rigor.invariant('possibility-bounded', ({ actual }) => actual !== undefined)
])
).run({ seed: 'rc-rule-possibility-bounded', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
assert.ok(inv);
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
});
it('result shape is stable: possibility, reliability, reason, meta always present', async () => {
async function check(balance, price) {
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
const rule = {
type: 'relational_comparator',
comparator: '>',
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
};
const result = evalRule('user:alice', 'doc:secret', rule);
for (const k of ['possibility', 'reliability', 'reason', 'meta']) {
if (!(k in result)) throw new Error(`result missing key '${k}' (full: ${JSON.stringify(result)})`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.float({ min: 0, max: 10000 }),
rigor.gen.float({ min: 0, max: 10000 })
)
)],
rigor.crucible([
rigor.invariant('result-shape-stable', ({ actual }) => actual !== undefined)
])
).run({ seed: 'rc-rule-shape-stable', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'result-shape-stable');
assert.ok(inv);
assert.equal(inv.passed, true, `result-shape violated in ${inv.failureCount} cases`);
});
it('determinism: same inputs → same result (no Date.now() / randomness)', async () => {
async function check(balance, price) {
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
const rule = {
type: 'relational_comparator',
comparator: '>',
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
};
const r1 = evalRule('user:alice', 'doc:secret', rule);
const r2 = evalRule('user:alice', 'doc:secret', rule);
if (r1.possibility !== r2.possibility) {
throw new Error(`non-deterministic: ${r1.possibility} vs ${r2.possibility}`);
}
if (r1.reason !== r2.reason) {
throw new Error(`non-deterministic reason: ${r1.reason} vs ${r2.reason}`);
}
return r1;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.float({ min: 0, max: 10000 }),
rigor.gen.float({ min: 0, max: 10000 })
)
)],
rigor.crucible([
rigor.invariant('determinism', ({ actual }) => actual !== undefined)
])
).run({ seed: 'rc-rule-determinism', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'determinism');
assert.ok(inv);
assert.equal(inv.passed, true, `determinism violated in ${inv.failureCount} cases`);
});
});