Files
core/tests/rigor/zanzibar-defeasible-dsl-comparator.test.js
T
John Dvorak 4fd4e20bd0 js-rigor: reliability flows through every rule kind; multi_hop value collection fixed
Systemic reliability gap found by the probe sweep: the compiled evaluation
paths never emitted the reliability the engine computes.

- Compiled _evaluateDirect omitted the relation's reliability, and the
  chain/multi_hop rules hardcoded reliability: 1.0 — so check() results
  reported 1.0 for any rule whose decision came through a chain, multi_hop,
  union, intersection, exclusion, or defeasible combination.
- The chain and multi_hop traversals now track per-path reliability (product
  of edge reliabilities) and report the winning path's value; the compiled
  and fallback logical operators (union/intersection/exclusion, direct_list
  fast path, early exits) report the selected child's reliability
  (max/min child or OWA trace index; exclusion multiplies both legs), and
  normal-mode defeasible combines base x requires x defeater reliabilities.
- The checker's logical fast path dropped collectedValues from union/
  intersection/exclusion results; it now passes them through.
- MultiHopRule.valueManager was read off relationManager where the real
  arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule
  with a value-carrying edge crashed the evaluation (error result, silent
  denial). Now resolved at the arbiter level with a relationManager
  fallback for stubs.

Campaign pins: reliability per kind (chain/multi_hop product, union/intersection
selected child, exclusion/defeasible product), and multi_hop value collection
through persistent and partial contexts.
2026-08-01 09:52:31 -07:00

275 lines
11 KiB
JavaScript
Raw 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 '../../src/ast/DSLCompiler.js';
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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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`);
});
});