/** * 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`); }); });