Files
core/tests/rigor/zanzibar-defeasible-dsl-comparator.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

275 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* rigor/zanzibar-defeasible-dsl-comparator.test.js — js-rigor property tests
* for defeasible logic, DSL→runtime parity, and value aggregation.
*
* Properties verified:
*
* - NEVER: an absolute denial overrides every positive rule (binary).
* - UNLESS (defeater): a triggered defeater blocks the defeasible grant.
* - ALWAYS (strict): strict grants survive unless NEVER fires.
* - WHEN: a defeasible rule grants iff its conditions hold and no
* defeater fires.
* - DSL→RUNTIME PARITY: evidence compiled from DSL behaves identically
* to the equivalent hand-written relation configs on identical graphs,
* across generated edge possibilities.
* - AGGREGATION: a relational-comparator sum over N parallel chain paths
* totals ALL path values (regression — the dedup-before-collect bug
* dropped weaker paths' contributions).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { DSLCompiler } from '@arbiter/evidence-dsl/DSLCompiler';
const EPS = 1e-9;
const POSSIBILITIES = [0, 0.25, 0.5, 0.75, 1];
function fail(message) {
throw new Error(message);
}
describe('Defeasible logic, DSL parity, aggregation (rigor)', () => {
it('DEFEASIBLE: NEVER denies, UNLESS defeats, ALWAYS survives, WHEN grants', async () => {
async function check({ shape, pAllow, pBlock }) {
const arbiter = new Arbiter();
['user:u', 'doc:1'].forEach((k) =>
arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
arbiter.setRelationConfig('can_access', { type: 'direct' });
arbiter.setRelationConfig('is_blocked', { type: 'direct' });
arbiter.setRelationConfig('is_emergency', { type: 'direct' });
const configs = {
when: {
when: { intersection: [{ type: 'direct', relation: 'can_access' }] }
},
unless: {
when: { intersection: [{ type: 'direct', relation: 'can_access' }] },
unless: { union: [{ type: 'direct', relation: 'is_blocked' }] }
},
never: {
never: { union: [{ type: 'direct', relation: 'is_blocked' }] },
when: { intersection: [{ type: 'direct', relation: 'can_access' }] }
},
always: {
always: { type: 'direct', relation: 'is_emergency' },
when: { intersection: [{ type: 'direct', relation: 'can_access' }] }
}
};
arbiter.setRelationConfig('viewer', configs[shape]);
arbiter.addRelation('user:u', 'can_access', 'doc:1', { possibility: pAllow });
if (shape !== 'always') {
arbiter.addRelation('user:u', 'is_blocked', 'doc:1', { possibility: pBlock });
} else {
arbiter.addRelation('user:u', 'is_emergency', 'doc:1', { possibility: pBlock });
}
const result = arbiter.check('user:u', 'viewer', 'doc:1');
// Normal-mode defeasible semantics (continuous possibility):
// - when: base = when-part possibility
// - unless: result *= (1 - defeater possibility)
// - never: result = 0 when never possibility >= 0.5
// - always: result = max(base, strict possibility)
let expected;
if (shape === 'when') {
expected = pAllow;
} else if (shape === 'unless') {
expected = pAllow * (1 - pBlock);
} else if (shape === 'never') {
expected = pBlock >= 0.5 ? 0 : pAllow;
} else {
expected = Math.max(pAllow, pBlock);
}
if (Math.abs(result.possibility - expected) > EPS) {
fail(`${shape}: expected ${expected}, got ${result.possibility} (pAllow=${pAllow}, pBlock=${pBlock})`);
}
return result;
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({
shape: rigor.gen.oneOf(['when', 'unless', 'never', 'always']),
pAllow: rigor.gen.oneOf(POSSIBILITIES),
pBlock: rigor.gen.oneOf(POSSIBILITIES)
})
))
],
rigor.crucible([
rigor.invariant('defeasible-semantics', ({ actual }) => actual !== undefined)
])
).run({ effort: 600, seed: 'defeasible-semantics' , artifacts: { dir: '', persist: 'never' }});
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'defeasible-semantics');
assert.ok(inv);
assert.equal(inv.passed, true, `DEFEASIBLE semantics violated in ${inv.failureCount} cases`);
});
it('DSL→RUNTIME PARITY: compiled evidence matches hand-written configs on identical graphs', async () => {
const DSL = `
definition Doc { id: string }
definition Dept { id: string }
fact owns(user: User, doc: Doc)
fact works_in(user: User, dept: Dept)
fact has_access(dept: Dept, doc: Doc)
evidence can_read(user: User, doc: Doc) { owns(user, doc) }
evidence can_access(user: User, doc: Doc) { works_in(user, *d) { has_access(d, doc) } }
`;
async function check({ pOwn, pMember, pReads }) {
// Compiled arbiter: DSL -> generated configs
const compiled = new Arbiter();
compiled.addNode('user:alice', 'user');
compiled.addNode('group:eng', 'group');
compiled.addNode('doc:1', 'doc');
const compiler = new DSLCompiler(compiled);
const result = compiler.compile(DSL, 'parity');
if (!result.success) {
fail(`DSL compile failed: ${result.errors.join('; ')}`);
}
// Hand-written arbiter: equivalent configs by hand
const manual = new Arbiter();
manual.addNode('user:alice', 'user');
manual.addNode('group:eng', 'group');
manual.addNode('doc:1', 'doc');
manual.setRelationConfig('can_read', { type: 'direct', relation: 'owns' });
manual.setRelationConfig('can_access', {
type: 'chain',
steps: [
{ relation: 'works_in', direction: 'out' },
{ relation: 'has_access', direction: 'out' }
]
});
// Identical graph on both
for (const arb of [compiled, manual]) {
arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: pOwn });
arb.addRelation('user:alice', 'works_in', 'group:eng', { possibility: pMember });
arb.addRelation('group:eng', 'has_access', 'doc:1', { possibility: pReads });
}
for (const rel of ['can_read', 'can_access']) {
const compiledResult = compiled.check('user:alice', rel, 'doc:1');
const manualResult = manual.check('user:alice', rel, 'doc:1');
if (Math.abs(compiledResult.possibility - manualResult.possibility) > EPS) {
fail(`${rel}: compiled=${compiledResult.possibility} vs manual=${manualResult.possibility}`);
}
}
return { canRead: compiled.check('user:alice', 'can_read', 'doc:1').possibility };
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({
pOwn: rigor.gen.oneOf(POSSIBILITIES),
pMember: rigor.gen.oneOf(POSSIBILITIES),
pReads: rigor.gen.oneOf(POSSIBILITIES)
})
))
],
rigor.crucible([
rigor.invariant('dsl-parity', ({ actual }) => actual !== undefined)
])
).run({ effort: 400, seed: 'dsl-runtime-parity' , artifacts: { dir: '', persist: 'never' }});
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'dsl-parity');
assert.ok(inv);
assert.equal(inv.passed, true, `DSL→RUNTIME parity violated in ${inv.failureCount} cases`);
});
it('AGGREGATION: relational-comparator sum totals ALL parallel path values', async () => {
async function check({ paths, value, price }) {
const arbiter = new Arbiter();
arbiter.addNode('user:alice', 'user');
arbiter.addNode('feature:premium', 'feature');
const mids = [];
for (let i = 0; i < paths; i++) {
const key = `mid:${i}`;
mids.push(key);
arbiter.addNode(key, 'account');
}
arbiter.setRelationConfig('can_debit', { type: 'direct' });
arbiter.setRelationConfig('has_balance', { type: 'direct' });
arbiter.setRelationConfig('has_price', { type: 'direct' });
arbiter.setRelationConfig('authorized_balance_check', {
type: 'relational_comparator',
left: {
rule: {
type: 'chain',
steps: [
{ relation: 'can_debit', direction: 'out' },
{ relation: 'has_balance', direction: 'out' }
],
extractValues: true,
extractFrom: 1,
extractRelation: 'has_balance',
valueAggregation: 'sum',
evaluateFrom: 'user'
},
extractValue: true,
aggregator: 'sum',
evaluateFrom: 'user',
decayRate: 0,
decayFunction: 'rational'
},
right: {
rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' },
extractValue: true,
evaluateFrom: 'object',
decayRate: 0,
decayFunction: 'rational'
},
comparator: '>=',
fallbackBehavior: 'deny'
});
// N parallel paths, weakening possibilities (the old dedup bug dropped
// the weaker paths' values, under-reporting the authorized total).
for (let i = 0; i < paths; i++) {
const p = 1 - i * 0.15;
arbiter.addRelation('user:alice', 'can_debit', mids[i], { possibility: p });
arbiter.addRelation(mids[i], 'has_balance', 'feature:premium', { possibility: p, value });
}
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: price });
const result = arbiter.check('user:alice', 'authorized_balance_check', 'feature:premium', {
includeMeta: true
});
const expectedTotal = paths * value;
const leftValue = result.meta?.allow?.leftValue ?? result.meta?.deny?.leftValue;
if (Math.abs(leftValue - expectedTotal) > EPS) {
fail(`sum: expected ${expectedTotal}, got ${leftValue} (${paths} paths × ${value})`);
}
const expectedGrant = expectedTotal >= price;
if ((result.possibility > 0) !== expectedGrant) {
fail(`decision: expected grant=${expectedGrant} (${expectedTotal} >= ${price}), got ${result.possibility}`);
}
return { leftValue, granted: result.possibility > 0 };
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({
paths: rigor.gen.int(2, 5),
value: rigor.gen.int(50, 300),
price: rigor.gen.int(100, 1000)
})
))
],
rigor.crucible([
rigor.invariant('aggregation-complete', ({ actual }) => actual !== undefined)
])
).run({ effort: 500, seed: 'aggregation-completeness' , artifacts: { dir: '', persist: 'never' }});
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'aggregation-complete');
assert.ok(inv);
assert.equal(inv.passed, true, `AGGREGATION violated in ${inv.failureCount} cases`);
});
});