Files
core/tests/rigor/zanzibar-defeasible-dsl-comparator.test.js
T
John Dvorak f446750ff3
CI / test (push) Successful in 5m23s
CI / benchmark (push) Successful in 47s
CI / publish (push) Has been skipped
refactor: extract Evidence DSL to @arbiter/evidence-dsl package
The Evidence DSL (ADR-000) compiles a natural DSL into core rule
configurations — it is a separate concern from the engine. The AST had
zero runtime coupling to the core (DSLCompiler takes the arbiter as a
duck-typed argument; ip-utils were the only shared code, now local to
the DSL package). This extraction removes the DSL surface from the core
artifact entirely:

- src/ast/ (748K, ~60 files) moved to @arbiter/evidence-dsl@1.0.0
- ip-utils moved with it (only the DSL consumed them)
- generate-parser script + peggy devDep moved to the DSL package
- the 8 DSL-consuming tests now import from @arbiter/evidence-dsl
  (deep-path exports: DSLCompiler, parser/*, generator/*, validation/*,
  interpreter/*)
- package.json gains the devDependency, drops build:ast/generate:parser

Tarball: AST-free. Rigor 251/251, full suite 838/776/0.
2026-08-03 09:17:33 -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 '@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', ({ 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`);
});
});