2dc478f5a3
A chain step that references a derived evidence is now expanded at compile
time, keeping the engine a flat edge-traversal evaluator:
- DIRECT evidence step -> renamed to its underlying relation
(member_of(user,*g){ group_read(g,doc) } where group_read = can_view
becomes step 'can_view').
- CHAIN evidence step -> its steps are spliced into the parent chain
(a sub-path flattens into the linear source->...->object traversal).
- Any other evidence type (defeasible/logical/comparator) as a step is a
compile-time error: it is a condition, not an edge traversal.
- Cycles and self-references through chain steps are compile-time errors
(the existing composition cycle guard now covers steps).
Rigor: oracle campaign gains a chain_step_composition construct; illegal
mutations gain a non-lowerable-chain-step case. Fixture suites updated to
retarget the self-recursive 'canRead/canAccess/...' terminals (an unsupported
recursion pattern that now fails loudly) to an any-typed 'reachable' fact,
preserving the nested-pattern parsing intent.
220 lines
9.6 KiB
JavaScript
220 lines
9.6 KiB
JavaScript
/**
|
||
* 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)
|
||
fact group_perm(group: Group, doc: Doc)
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
default:
|
||
throw new Error(`unknown construct: ${kind}`);
|
||
}
|
||
|
||
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]
|
||
};
|
||
}
|
||
|
||
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',
|
||
'when_unless', 'never_always', 'requires_when', 'composition', 'chain_step_composition'];
|
||
|
||
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}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|