2026-08-03 10:58:29 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* tests/rigor/dsl-generative-oracle.test.js — js-rigor campaign that GENERATES
|
|
|
|
|
|
* legal Evidence DSL programs, compiles them to @arbiter/core configs, runs
|
|
|
|
|
|
* checks, and compares every verdict against an independent ORACLE (a hand-
|
|
|
|
|
|
* computed reference implementation of the DSL semantics).
|
|
|
|
|
|
*
|
|
|
|
|
|
* The oracle is deliberately independent of the engine: it computes the
|
|
|
|
|
|
* expected possibility from the generated fact graph using the ADR-000
|
|
|
|
|
|
* semantics (direct = edge, chain = min over steps, tuple_to_userset = min of
|
|
|
|
|
|
* the two legs, fusion = min/max over operands, when-unless = base×(1−defeat),
|
|
|
|
|
|
* never = 0 when ≥0.5 else base, requires = base×requirement).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Anti-vacuity: the oracle is NOT a constant — each construct maps distinct
|
|
|
|
|
|
* edge possibilities, so a trivial 0-or-1 lowering would be caught.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { describe, it } from 'node:test';
|
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
|
import { rigor } from '@rigor/core';
|
|
|
|
|
|
import { Arbiter } from '@arbiter/core';
|
|
|
|
|
|
import { DSLCompiler } from '../../src/DSLCompiler.js';
|
|
|
|
|
|
|
|
|
|
|
|
const EPS = 1e-9;
|
|
|
|
|
|
const P = [0, 0.25, 0.5, 0.75, 1];
|
|
|
|
|
|
|
|
|
|
|
|
const FACTS = `
|
|
|
|
|
|
definition Employee { id: string }
|
|
|
|
|
|
definition Group { id: string }
|
|
|
|
|
|
definition Doc { id: string }
|
|
|
|
|
|
fact owns(user: Employee, doc: Doc)
|
|
|
|
|
|
fact shares(user: Employee, doc: Doc)
|
|
|
|
|
|
fact member_of(user: Employee, group: Group)
|
|
|
|
|
|
fact can_access(group: Group, doc: Doc)
|
|
|
|
|
|
fact owner(group: Group, doc: Doc)
|
|
|
|
|
|
fact granted(user: Employee, doc: Doc)
|
2026-08-03 11:34:52 -07:00
|
|
|
|
fact group_perm(group: Group, doc: Doc)
|
2026-08-03 10:58:29 -07:00
|
|
|
|
fact banned(user: Employee)
|
|
|
|
|
|
fact mfa(user: Employee)
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
// Each construct: how to build the DSL evidence + which edges to add + the oracle.
|
|
|
|
|
|
function buildProgram(kind, ps) {
|
|
|
|
|
|
let evidence = '';
|
|
|
|
|
|
const edges = [];
|
|
|
|
|
|
let oracle = 0;
|
|
|
|
|
|
|
|
|
|
|
|
switch (kind) {
|
|
|
|
|
|
case 'direct': {
|
|
|
|
|
|
const [pOwn] = ps;
|
|
|
|
|
|
evidence = `evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: pOwn });
|
|
|
|
|
|
oracle = pOwn;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'chain': {
|
|
|
|
|
|
const [pm, pa] = ps;
|
|
|
|
|
|
evidence = `evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'member_of', dst: 'g:1', possibility: pm });
|
|
|
|
|
|
edges.push({ src: 'g:1', relation: 'can_access', dst: 'doc:9', possibility: pa });
|
|
|
|
|
|
oracle = Math.min(pm, pa);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'tuple_to_userset': {
|
|
|
|
|
|
const [po, pm] = ps;
|
|
|
|
|
|
evidence = `evidence can_view(user: Employee, doc: Doc) { owner(*g, doc) { member_of(user, g) } }`;
|
|
|
|
|
|
edges.push({ src: 'g:1', relation: 'owner', dst: 'doc:9', possibility: po });
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'member_of', dst: 'g:1', possibility: pm });
|
|
|
|
|
|
oracle = Math.min(pm, po);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'fusion_min': {
|
|
|
|
|
|
const [p1, p2] = ps;
|
|
|
|
|
|
evidence = `evidence can_fuse(user: Employee, doc: Doc) { fusion min { owns(user, doc), shares(user, doc) } }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: p1 });
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'shares', dst: 'doc:9', possibility: p2 });
|
|
|
|
|
|
oracle = Math.min(p1, p2);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'fusion_max': {
|
|
|
|
|
|
const [p1, p2] = ps;
|
|
|
|
|
|
evidence = `evidence can_fuse(user: Employee, doc: Doc) { fusion max { owns(user, doc), shares(user, doc) } }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: p1 });
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'shares', dst: 'doc:9', possibility: p2 });
|
|
|
|
|
|
oracle = Math.max(p1, p2);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'when_unless': {
|
|
|
|
|
|
const [pG, pB] = ps;
|
|
|
|
|
|
evidence = `evidence can_borrow(user: Employee, doc: Doc) { WHEN granted(user, doc) UNLESS banned(user) }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'granted', dst: 'doc:9', possibility: pG });
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'banned', dst: 'u:1', possibility: pB });
|
|
|
|
|
|
oracle = pG * (1 - pB);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'never_always': {
|
|
|
|
|
|
const [pG, pB] = ps;
|
|
|
|
|
|
evidence = `evidence can_open(user: Employee, doc: Doc) { NEVER banned(user) ALWAYS granted(user, doc) }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'granted', dst: 'doc:9', possibility: pG });
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'banned', dst: 'u:1', possibility: pB });
|
|
|
|
|
|
oracle = pB >= 0.5 ? 0 : pG;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'requires_when': {
|
|
|
|
|
|
const [pG, pM] = ps;
|
|
|
|
|
|
evidence = `evidence can_pay(user: Employee, doc: Doc) { REQUIRES mfa(user) WHEN granted(user, doc) }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'granted', dst: 'doc:9', possibility: pG });
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'mfa', dst: 'u:1', possibility: pM });
|
|
|
|
|
|
oracle = pG * pM;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-08-03 11:17:30 -07:00
|
|
|
|
case 'composition': {
|
|
|
|
|
|
// can_via composes the direct evidence can_read, which reads the owns
|
|
|
|
|
|
// edge — an evidence-in-evidence reference resolved at compile time.
|
|
|
|
|
|
const [pOwn] = ps;
|
|
|
|
|
|
evidence = `evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
|
|
|
|
|
|
evidence can_via(user: Employee, doc: Doc) { can_read(user, doc) }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: pOwn });
|
|
|
|
|
|
oracle = pOwn;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-08-03 11:34:52 -07:00
|
|
|
|
case 'chain_step_composition': {
|
|
|
|
|
|
// group_read (a direct evidence) used as a CHAIN STEP inside can_via:
|
|
|
|
|
|
// the step is expanded at compile time to the underlying can_view edge.
|
|
|
|
|
|
const [pm, pv] = ps;
|
|
|
|
|
|
evidence = `evidence group_read(group: Group, doc: Doc) { group_perm(group, doc) }
|
|
|
|
|
|
evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } }`;
|
|
|
|
|
|
edges.push({ src: 'u:1', relation: 'member_of', dst: 'g:1', possibility: pm });
|
|
|
|
|
|
edges.push({ src: 'g:1', relation: 'group_perm', dst: 'doc:9', possibility: pv });
|
|
|
|
|
|
oracle = Math.min(pm, pv);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-08-03 10:58:29 -07:00
|
|
|
|
default:
|
|
|
|
|
|
throw new Error(`unknown construct: ${kind}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 11:17:30 -07:00
|
|
|
|
return {
|
|
|
|
|
|
dsl: FACTS + evidence,
|
|
|
|
|
|
edges,
|
|
|
|
|
|
oracle,
|
|
|
|
|
|
// Check the LAST evidence declaration: the composition construct declares
|
|
|
|
|
|
// two evidences (can_read + can_via), and the composed one is the target.
|
|
|
|
|
|
relation: [...evidence.matchAll(/evidence\s+(\w+)/g)].at(-1)[1]
|
|
|
|
|
|
};
|
2026-08-03 10:58:29 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runCheck({ kind, ps }) {
|
|
|
|
|
|
const { dsl, edges, oracle, relation } = buildProgram(kind, ps);
|
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
|
arbiter.addNode('u:1', 'Employee');
|
|
|
|
|
|
arbiter.addNode('g:1', 'Group');
|
|
|
|
|
|
arbiter.addNode('doc:9', 'Doc');
|
|
|
|
|
|
const compiler = new DSLCompiler(arbiter);
|
|
|
|
|
|
const compiled = compiler.compile(dsl, 'oracle');
|
|
|
|
|
|
if (!compiled.success) {
|
|
|
|
|
|
throw new Error(`compile failed for ${kind}: ${compiled.errors.join('; ')}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const e of edges) arbiter.addRelation(e.src, e.relation, e.dst, { possibility: e.possibility });
|
|
|
|
|
|
const result = arbiter.check('u:1', relation, 'doc:9');
|
|
|
|
|
|
if (Math.abs(result.possibility - oracle) > EPS) {
|
|
|
|
|
|
throw new Error(`oracle mismatch for ${kind} (edges=${JSON.stringify(edges)}): ` +
|
|
|
|
|
|
`check=${result.possibility} (${result.reason}) vs oracle=${oracle}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { kind, possibility: result.possibility, oracle };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CONSTRUCTS = ['direct', 'chain', 'tuple_to_userset', 'fusion_min', 'fusion_max',
|
2026-08-03 11:34:52 -07:00
|
|
|
|
'when_unless', 'never_always', 'requires_when', 'composition', 'chain_step_composition'];
|
2026-08-03 10:58:29 -07:00
|
|
|
|
|
|
|
|
|
|
describe('DSL generative oracle parity (rigor)', () => {
|
|
|
|
|
|
it('generated legal DSL compiles and every check matches the oracle', async () => {
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
|
[
|
|
|
|
|
|
rigor.fn('oracle-parity', runCheck, rigor.args(
|
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
|
kind: rigor.gen.oneOf(CONSTRUCTS),
|
|
|
|
|
|
// exactly two edge possibilities (direct uses only the first);
|
|
|
|
|
|
// a shorter array would leave pB undefined and produce a NaN oracle
|
|
|
|
|
|
ps: rigor.gen.tuple(rigor.gen.oneOf(P), rigor.gen.oneOf(P))
|
|
|
|
|
|
})
|
|
|
|
|
|
))
|
|
|
|
|
|
],
|
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
|
// `actual` is the fn's return value; a thrown error (compile failure or
|
|
|
|
|
|
// oracle mismatch) yields actual === undefined, failing this invariant.
|
|
|
|
|
|
rigor.invariant('oracle-parity', ({ actual }) =>
|
|
|
|
|
|
!!actual && Math.abs(actual.possibility - actual.oracle) <= EPS)
|
|
|
|
|
|
])
|
|
|
|
|
|
).run({ seed: 'dsl-oracle-parity', effort: 600, artifacts: { dir: '', persist: 'never' } });
|
|
|
|
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'oracle-parity');
|
|
|
|
|
|
assert.ok(inv, 'crucible invariant missing');
|
|
|
|
|
|
assert.equal(inv.passed, true, `oracle parity violated in ${inv.failureCount} cases`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('exhaustive deterministic sweep: every construct x every possibility value', () => {
|
|
|
|
|
|
// Anti-vacuity complement to the campaign: sweep the full P × P grid per
|
|
|
|
|
|
// construct without any RNG, so a construct the campaign skipped would
|
|
|
|
|
|
// still be caught here.
|
|
|
|
|
|
for (const kind of CONSTRUCTS) {
|
|
|
|
|
|
for (const a of P) {
|
|
|
|
|
|
for (const b of P) {
|
|
|
|
|
|
const ps = kind === 'direct' ? [a] : [a, b];
|
|
|
|
|
|
const { dsl, edges, oracle, relation } = buildProgram(kind, ps);
|
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
|
arbiter.addNode('u:1', 'Employee');
|
|
|
|
|
|
arbiter.addNode('g:1', 'Group');
|
|
|
|
|
|
arbiter.addNode('doc:9', 'Doc');
|
|
|
|
|
|
const compiled = new DSLCompiler(arbiter).compile(dsl, 'sweep');
|
|
|
|
|
|
assert.ok(compiled.success, `${kind} compile failed: ${(compiled.errors || []).join('; ')}`);
|
|
|
|
|
|
for (const e of edges) arbiter.addRelation(e.src, e.relation, e.dst, { possibility: e.possibility });
|
|
|
|
|
|
const result = arbiter.check('u:1', relation, 'doc:9');
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
Math.abs(result.possibility - oracle) <= EPS,
|
|
|
|
|
|
`${kind} ps=[${ps}] check=${result.possibility}(${result.reason}) vs oracle=${oracle}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|