Files
evidence-dsl/tests/rigor/dsl-generative-oracle.test.js
T
John Dvorak 3ace783a59
CI / publish (push) Successful in 10s
CI / test (push) Successful in 19s
feat: intermediate chain condition steps + _subjectIsObject unary scoping
- _expandChainSteps: a logical/defeasible evidence referenced by a chain step
  is now a condition step ({ rule, conditionStep }) at ANY position. As the
  FINAL step the engine verifies it at (intermediate, object); as an
  INTERMEDIATE step the engine expands it from the current node (rule-based
  reachability: base edges' destinations filtered by the rule's
  defeaters/requirements) and continues traversal from each discovered node.
- buildPredicateRule / buildDirectRule: unary predicate calls whose subject
  entity IS the evidence's object parameter (trusted(other) inside
  peer_trusted(user, other)) are marked _subjectIsObject (was: only subject-var
  calls got _subjectAsObject). Requires @arbiter/core@^1.0.4.

Tests: ChainConditionStep intermediate expansion; oracle campaign gains a
chain_intermediate_condition construct (oracle = min(peer*(1-trusted), read)).
2026-08-03 13:36:16 -07:00

262 lines
12 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.
/**
* 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×(1defeat),
* 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 group_banned(group: Group)
fact banned(user: Employee)
fact peer(user: Employee, other: Employee)
fact trusted(other: Employee)
fact doc_read(user: Employee, doc: Doc)
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;
}
case 'chain_condition_step': {
// gated (a defeasible evidence) as the FINAL chain step → a condition
// step: the engine verifies gated at (intermediate, object). The oracle
// is the chain's min combined with the condition's base*(1-defeat).
const [pm, pv, pb] = ps;
evidence = `evidence gated(group: Group, doc: Doc) { WHEN group_perm(group, doc) UNLESS group_banned(group) }
evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { gated(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 });
edges.push({ src: 'g:1', relation: 'group_banned', dst: 'g:1', possibility: pb });
oracle = Math.min(pm, pv * (1 - pb));
break;
}
case 'chain_intermediate_condition': {
// peer_trusted (a defeasible evidence) as an INTERMEDIATE chain step:
// the engine expands it from the source (peer edges filtered by the
// trusted defeater) then continues to can_read. Oracle = min of the
// surviving peer leg and the read leg.
const [pp, pt, pr] = ps;
evidence = `evidence peer_trusted(user: Employee, other: Employee) { WHEN peer(user, other) UNLESS trusted(other) }
evidence can_via(user: Employee, doc: Doc) { peer_trusted(user, *p) { doc_read(p, doc) } }`;
edges.push({ src: 'u:1', relation: 'peer', dst: 'p:1', possibility: pp });
edges.push({ src: 'p:1', relation: 'trusted', dst: 'p:1', possibility: pt });
edges.push({ src: 'p:1', relation: 'doc_read', dst: 'doc:9', possibility: pr });
oracle = Math.min(pp * (1 - pt), pr);
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');
for (const e of edges) {
arbiter.addNode(e.src, e.dst === 'doc:9' ? 'Doc' : 'Employee');
arbiter.addNode(e.dst, e.dst === 'doc:9' ? 'Doc' : 'Employee');
}
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',
'chain_condition_step', 'chain_intermediate_condition'];
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.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 × 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) {
for (const c of P) {
const ps = kind === 'direct' ? [a] : [a, b, c];
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');
for (const e of edges) {
arbiter.addNode(e.src, e.dst === 'doc:9' ? 'Doc' : 'Employee');
arbiter.addNode(e.dst, e.dst === 'doc:9' ? 'Doc' : 'Employee');
}
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}`
);
}
}
}
}
});
});