diff --git a/package.json b/package.json index 3c367a2..35d08cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.1.0", + "version": "1.2.0", "description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.", "license": "ISC", "type": "module", diff --git a/src/generator/RuleGenerator.js b/src/generator/RuleGenerator.js index 9722072..eb11e76 100644 --- a/src/generator/RuleGenerator.js +++ b/src/generator/RuleGenerator.js @@ -10,6 +10,7 @@ export class RuleGenerator { this.generatedRules = new Map(); this.errors = []; this.dependencyIndex = new Map(); + this.evidenceNames = new Set(); } /** @@ -38,6 +39,14 @@ export class RuleGenerator { this.generateFactConfig(fact); }); + // Resolve evidence composition: a rule that references another derived + // evidence (WHEN can_read(user, doc) where can_read is an evidence) is + // lowered in place to that evidence's own config — compile-time inlining + // (a linker pass), so the engine evaluates a fully-resolved config tree + // and never needs a sub-query traversal mechanism. Forward references are + // handled because every evidence config is built before this pass runs. + this.resolveEvidenceReferences(); + // Apply generated rules to arbiter this.applyRulesToArbiter(); @@ -62,6 +71,7 @@ export class RuleGenerator { */ generateEvidenceRules(evidence) { const relationName = evidence.name; + this.evidenceNames.add(relationName); const ruleConfig = this.buildRuleConfig(evidence); if (ruleConfig) { @@ -162,6 +172,7 @@ export class RuleGenerator { for (const child of ruleList) collect(child, targetSet); } if (node?.rule) collect(node.rule, targetSet); + if (node?.direct) collect(node.direct, targetSet); } }; @@ -274,7 +285,7 @@ export class RuleGenerator { buildSingleStatementRule(statement, evidence) { switch (statement.type) { case 'DirectEvidence': - return this.buildDirectRule(statement); + return this.buildDirectRule(statement, evidence); case 'PatternMatch': return this.buildPatternMatchRule(statement, evidence); case 'DefeasibleLogic': @@ -282,7 +293,7 @@ export class RuleGenerator { case 'Fusion': return this.buildFusionRule(statement); case 'PredicateCall': - return this.buildPredicateRule(statement); + return this.buildPredicateRule(statement, evidence); case 'UnaryExpression': return this.buildUnaryRule(statement); case 'BinaryExpression': @@ -291,7 +302,7 @@ export class RuleGenerator { case 'Expression': // Handle expressions that might be predicate calls if (statement.type === 'PredicateCall') { - return this.buildPredicateRule(statement); + return this.buildPredicateRule(statement, evidence); } return this.buildRuleFromExpressionNode(statement, evidence); default: @@ -459,7 +470,7 @@ export class RuleGenerator { * @param {DirectEvidenceNode} directEvidence - Direct evidence statement * @returns {Object|null} Rule configuration or null */ - buildDirectRule(directEvidence) { + buildDirectRule(directEvidence, evidence) { if (!directEvidence.predicate) { this.errors.push('Direct evidence must have a predicate'); return null; @@ -467,12 +478,24 @@ export class RuleGenerator { const predicate = directEvidence.predicate; const relation = predicate.name; - - return { + + const rule = { type: 'direct', relation: relation, reverse: false }; + + // Subject-scoped (unary) call: the predicate call's args omit the + // evidence's object parameter (user_risk(user, 1) inside a binary + // evidence) → check the relation on the subject itself. + const evidenceParams = (evidence && evidence.params) || []; + const objectVar = evidenceParams[1] && evidenceParams[1].name; + const argName = a => a && (a.name !== undefined ? a.name : a.value); + if (objectVar !== undefined && !(predicate.arguments || []).some(a => argName(a) === objectVar)) { + rule._subjectAsObject = true; + } + + return rule; } /** @@ -925,9 +948,6 @@ export class RuleGenerator { * @returns {Object|null} Rule configuration or null */ buildDirectRuleFromPredicate(predicate, evidence) { - const expanded = this._expandPredicate(predicate.name); - if (expanded) return expanded; - const rule = { type: 'direct', relation: predicate.name, @@ -944,36 +964,105 @@ export class RuleGenerator { return rule; } - _expandPredicate(predicateName) { - const existingConfig = this.generatedRules.get(predicateName) || this.arbiter?.relationConfigs?.get(predicateName); - if (!existingConfig) return null; - if (!existingConfig.union && !existingConfig.intersection && !existingConfig.exclusion) return null; + _expandPredicate() { + // Replaced by resolveEvidenceReferences() (the compile-time evidence + // composition pass). Predicate references are now emitted as direct rules + // and inlined during resolution, which also handles forward references and + // preserves the correct _subjectAsObject scoping. + } - const logicalKey = existingConfig.union ? 'union' : existingConfig.intersection ? 'intersection' : 'exclusion'; - const subRules = Array.isArray(existingConfig[logicalKey]?.rules) - ? existingConfig[logicalKey].rules - : Array.isArray(existingConfig[logicalKey]) ? existingConfig[logicalKey] : []; + /** + * Evidence composition pass. Every rule that references a DERIVED evidence + * (e.g. `WHEN can_read(user, doc)` where can_read is itself an evidence) is + * rewritten to inline that evidence's own config. This runs after all + * evidence configs are generated, so forward references resolve; cycles are + * detected and reported. The engine therefore evaluates a fully-resolved, + * acyclic config tree — no runtime sub-query traversal is needed. + */ + resolveEvidenceReferences() { + for (const name of this.evidenceNames) { + if (!this.generatedRules.has(name)) continue; + const stack = new Set([name]); + const resolved = this._resolveRule(this.generatedRules.get(name), stack); + this.generatedRules.set(name, resolved); + this._annotateDependencies(name, resolved); + } + } - if (subRules.length === 0) return null; + /** + * Recursively rewrite a rule tree, inlining references to derived evidence + * configs. `stack` holds the evidence names currently being expanded so a + * cyclic reference (A → B → A) is detected and reported. + */ + _resolveRule(rule, stack) { + if (!rule || typeof rule !== 'object') return rule; + if (Array.isArray(rule)) return rule.map(r => this._resolveRule(r, stack)); - const expandedRules = subRules.map(r => { - if (r && r.type === 'direct') return { type: 'direct', relation: r.relation, reverse: !!r.reverse }; - if (typeof r === 'string') return { type: 'direct', relation: r, reverse: false }; - return null; - }).filter(Boolean); + // Direct rule referencing a derived evidence → inline its resolved config. + if (rule.type === 'direct' && rule.relation) { + const ref = rule.relation; + if (this.evidenceNames.has(ref)) { + const referencedConfig = this.generatedRules.get(ref); + if (referencedConfig) { + if (stack.has(ref)) { + this.errors.push(`Cyclic evidence reference involving '${ref}'. Evidence composition must be acyclic.`); + return rule; + } + const refStack = new Set(stack); + refStack.add(ref); + const resolvedRef = this._resolveRule(referencedConfig, refStack); + if (resolvedRef) { + const clone = this._deepCloneRule(resolvedRef); + if (rule._subjectAsObject) clone._subjectAsObject = true; + return clone; + } + } + } + return rule; + } - if (expandedRules.length === 0) return null; + // Recurse into logical / defeasible / nested containers: rule-lists + // (union/intersection/exclusion/never/requires/when/unless .rules) and + // single nested rules (always.direct, comparator operands). + const out = { ...rule }; + for (const key of ['union', 'intersection', 'exclusion', 'never', 'always', 'requires', 'when', 'unless', 'direct', 'rule']) { + const node = out[key]; + if (!node || typeof node !== 'object') continue; + if (Array.isArray(node)) { + out[key] = node.map(r => this._resolveRule(r, stack)); + continue; + } + const next = { ...node }; + if (Array.isArray(next.rules)) { + next.rules = next.rules.map(r => this._resolveRule(r, stack)); + } + if (next.union && Array.isArray(next.union.rules)) { + next.union = { ...next.union, rules: next.union.rules.map(r => this._resolveRule(r, stack)) }; + } + if (next.intersection && Array.isArray(next.intersection.rules)) { + next.intersection = { ...next.intersection, rules: next.intersection.rules.map(r => this._resolveRule(r, stack)) }; + } + if (next.direct && typeof next.direct === 'object') { + next.direct = this._resolveRule(next.direct, stack); + } + if (next.rule && typeof next.rule === 'object') { + next.rule = this._resolveRule(next.rule, stack); + } + out[key] = next; + } + if (out.type === 'relational_comparator') { + if (out.left?.rule) out.left = { ...out.left, rule: this._resolveRule(out.left.rule, stack) }; + if (out.right?.rule) out.right = { ...out.right, rule: this._resolveRule(out.right.rule, stack) }; + } + return out; + } - return { - type: 'logical', - [logicalKey]: { - rules: expandedRules, - aggregator: existingConfig[logicalKey]?.aggregator || 'min' - }, - // Flag to tell the evaluator: this expanded sub-predicate is unary — - // use the subject as the object instead of inheriting the parent's object. - _subjectAsObject: true - }; + _deepCloneRule(rule) { + try { + return structuredClone(rule); + } catch { + return JSON.parse(JSON.stringify(rule)); + } } /** @@ -1120,11 +1209,6 @@ export class RuleGenerator { return this.buildChallengeRule(expression, null); } - // Expand composite (logical) predicate references into their direct - // leaf components so the optimizer can flatten to a correct direct_list. - const expanded = this._expandPredicate(predicateName); - if (expanded) return expanded; - const rule = { type: 'direct', relation: predicateName, diff --git a/src/validation/DSLValidation.js b/src/validation/DSLValidation.js index 2ddcc32..b01e834 100644 --- a/src/validation/DSLValidation.js +++ b/src/validation/DSLValidation.js @@ -41,6 +41,7 @@ export function validateDslText(dslText, options = {}) { validateSources(program, tables, errors, warnings, dslText); validateMeasures(program, tables, errors, warnings, dslText); validateEvidence(program, tables, errors, warnings, dslText); + validateCrossKindRelationNames(program, errors, warnings, dslText); return { success: errors.length === 0, @@ -335,6 +336,37 @@ function validateEvidence(program, tables, errors, warnings, source) { } } +/** + * Relation names must be unique across facts, sources, evidence, and measures. + * A fact and an evidence sharing a name would silently overwrite each other's + * relation config during generation (and read as a false cyclic reference). + */ +function validateCrossKindRelationNames(program, errors, warnings, source) { + const seen = new Map(); + const kinds = [ + ['fact', program.facts], + ['source', program.sources], + ['evidence', program.evidence], + ['measure', program.measures] + ]; + for (const [kind, items] of kinds) { + for (const item of items || []) { + const prev = seen.get(item.name); + if (prev) { + errors.push(createError({ + message: `Name '${item.name}' is already used by a ${prev} declaration.`, + rule: 'Relation names must be unique across facts, sources, evidence, and measures.', + fix: `Rename the ${kind} or the ${prev} to a unique name.`, + location: findLocation(source, `${kind} ${item.name}`), + context: formatContext(source, findLocation(source, item.name)) + })); + } else { + seen.set(item.name, kind); + } + } + } +} + function validateEvidenceBody(body, scope, tables, errors, warnings, source, parent) { for (const stmt of body.statements || []) { switch (stmt.type) { diff --git a/tests/DSLRuntime.test.js b/tests/DSLRuntime.test.js index 16215a4..d842bcb 100644 --- a/tests/DSLRuntime.test.js +++ b/tests/DSLRuntime.test.js @@ -161,4 +161,31 @@ describe('DSLRuntime', () => { rt.addNode('doc:9', 'Doc', {}); await assert.rejects(() => rt.check('u:1', 'does_not_exist', 'doc:9'), /unknown relation/); }); + + it('derives transitive required facts through evidence composition', async () => { + const dsl = ` + definition Employee { id: string } + definition Doc { id: string } + fact *owns(user: Employee, doc: Doc) + fact *banned(user: Employee) + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } + evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS banned(user) } + `; + const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-comp'); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + // can_open composes can_read, so its requirements reach through to owns. + assert.deepEqual(rt.requiredFacts('can_open'), ['owns', 'banned']); + const granted = await rt.check('u:1', 'can_open', 'doc:9', { + factProviders: { owns: async () => 0.9, banned: async () => 0 } + }); + assert.equal(granted.possibility, 0.9); + assert.equal(granted.reason, 'allow_rule_matched'); + assert.deepEqual(granted.providedFacts, ['owns', 'banned']); + const denied = await rt.check('u:1', 'can_open', 'doc:9', { + factProviders: { owns: async () => 0.9, banned: async () => 1 } + }); + assert.equal(denied.possibility, 0); + assert.equal(denied.reason, 'defeated_by_unless'); + }); }); diff --git a/tests/EvidenceComposition.test.js b/tests/EvidenceComposition.test.js new file mode 100644 index 0000000..b994f2e --- /dev/null +++ b/tests/EvidenceComposition.test.js @@ -0,0 +1,154 @@ +/** + * tests/EvidenceComposition.test.js — referencing a derived evidence as a + * sub-rule of another evidence (WHEN can_read(user, doc) where can_read is + * itself an evidence). + * + * Composition is resolved at COMPILE time: the generator inlines each + * evidence reference with the referenced evidence's own config (a linker + * pass that handles forward references and rejects cycles), so the engine + * evaluates a fully-resolved, acyclic config tree. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Arbiter } from '@arbiter/core'; +import { DSLCompiler } from '../src/DSLCompiler.js'; + +const DEFS = ` + definition Employee { id: string } + definition Group { id: string } + definition Doc { id: string } + fact owns(user: Employee, doc: Doc) + fact *trusted(user: Employee) + fact member_of(user: Employee, group: Group) + fact can_access(group: Group, doc: Doc) +`; + +function compile(dsl, name = 'compose') { + const arb = new Arbiter(); + const compiler = new DSLCompiler(arb); + const result = compiler.compile(dsl, name); + return { arb, result }; +} + +describe('Evidence composition', () => { + it('composes a direct evidence into another evidence', () => { + const { arb, result } = compile(` + ${DEFS} + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } + evidence can_browse(user: Employee, doc: Doc) { can_read(user, doc) } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.8 }); + const res = arb.check('u:1', 'can_browse', 'doc:9'); + assert.equal(res.possibility, 0.8); + // The reference is inlined to the underlying fact config. + assert.equal(arb.relationConfigs.get('can_browse').type, 'direct'); + assert.equal(arb.relationConfigs.get('can_browse').relation, 'owns'); + }); + + it('composes an evidence inside a defeasible WHEN/UNLESS', () => { + const { arb, result } = compile(` + ${DEFS} + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } + evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS trusted(user) } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.9 }); + assert.equal(arb.check('u:1', 'can_open', 'doc:9').possibility, 0.9); + arb.addRelation('u:1', 'trusted', 'u:1', { possibility: 1.0 }); + const denied = arb.check('u:1', 'can_open', 'doc:9'); + assert.equal(denied.possibility, 0); + assert.equal(denied.reason, 'defeated_by_unless'); + }); + + it('composes a chain evidence into another evidence', () => { + const { arb, result } = compile(` + ${DEFS} + evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } } + evidence can_work(user: Employee, doc: Doc) { can_enter(user, doc) } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + arb.addNode('u:1', 'Employee'); arb.addNode('g:1', 'Group'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); + arb.addRelation('g:1', 'can_access', 'doc:9', { possibility: 0.7 }); + const res = arb.check('u:1', 'can_work', 'doc:9'); + assert.equal(res.possibility, 0.7); + assert.equal(arb.relationConfigs.get('can_work').type, 'chain'); + }); + + it('composes transitively (A → B → fact) and re-derives dependencies', () => { + const { arb, result } = compile(` + ${DEFS} + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } + evidence can_browse(user: Employee, doc: Doc) { can_read(user, doc) } + evidence can_open(user: Employee, doc: Doc) { can_browse(user, doc) } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.6 }); + assert.equal(arb.check('u:1', 'can_open', 'doc:9').possibility, 0.6); + assert.deepEqual(arb.relationConfigs.get('can_open').dependsOn, ['owns']); + }); + + it('composes a value-carrying evidence and preserves subject-as-object scope', () => { + const { arb, result } = compile(` + ${DEFS} + fact *user_risk(user: Employee, value: number) + evidence risk_ok(user: Employee, doc: Doc) { user_risk(user, 1) } + evidence can_proceed(user: Employee, doc: Doc) { risk_ok(user, doc) } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'user_risk', 'u:1', { possibility: 1.0, value: 1 }); + const res = arb.check('u:1', 'can_proceed', 'doc:9'); + assert.equal(res.possibility, 1); + }); + + it('composes evidence inside a comparator operand', () => { + const { arb, result } = compile(` + ${DEFS} + fact *user_risk(user: Employee, value: number) + fact *risk_limit(doc: Doc, value: number) + evidence user_risk_ok(user: Employee, doc: Doc) { user_risk(user, 1) } + evidence can_proceed(user: Employee, doc: Doc) { user_risk_ok(user, doc) } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'user_risk', 'u:1', { possibility: 1.0, value: 1 }); + assert.equal(arb.check('u:1', 'can_proceed', 'doc:9').possibility, 1); + }); + + it('rejects cyclic evidence references at compile time', () => { + const { result } = compile(` + ${DEFS} + evidence a(user: Employee, doc: Doc) { b(user, doc) } + evidence b(user: Employee, doc: Doc) { a(user, doc) } + `); + assert.equal(result.success, false); + assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); + }); + + it('rejects self-referencing evidence at compile time', () => { + const { result } = compile(` + ${DEFS} + evidence a(user: Employee, doc: Doc) { a(user, doc) } + `); + assert.equal(result.success, false); + assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); + }); + + it('keeps the referenced evidence checkable in its own right', () => { + const { arb, result } = compile(` + ${DEFS} + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } + evidence can_browse(user: Employee, doc: Doc) { can_read(user, doc) } + `); + assert.ok(result.success); + arb.addNode('u:1', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.5 }); + assert.equal(arb.check('u:1', 'can_read', 'doc:9').possibility, 0.5); + assert.equal(arb.check('u:1', 'can_browse', 'doc:9').possibility, 0.5); + }); +}); diff --git a/tests/rigor/dsl-generative-oracle.test.js b/tests/rigor/dsl-generative-oracle.test.js index bcc63eb..47131aa 100644 --- a/tests/rigor/dsl-generative-oracle.test.js +++ b/tests/rigor/dsl-generative-oracle.test.js @@ -106,11 +106,28 @@ function buildProgram(kind, ps) { 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; + } default: throw new Error(`unknown construct: ${kind}`); } - return { dsl: FACTS + evidence, edges, oracle, relation: evidence.match(/evidence (\w+)/)[1] }; + 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 }) { @@ -134,7 +151,7 @@ function runCheck({ kind, ps }) { } const CONSTRUCTS = ['direct', 'chain', 'tuple_to_userset', 'fusion_min', 'fusion_max', - 'when_unless', 'never_always', 'requires_when']; + 'when_unless', 'never_always', 'requires_when', 'composition']; describe('DSL generative oracle parity (rigor)', () => { it('generated legal DSL compiles and every check matches the oracle', async () => { diff --git a/tests/rigor/dsl-illegal-mutations.test.js b/tests/rigor/dsl-illegal-mutations.test.js index 4671422..96c444e 100644 --- a/tests/rigor/dsl-illegal-mutations.test.js +++ b/tests/rigor/dsl-illegal-mutations.test.js @@ -81,6 +81,21 @@ const MUTATIONS = { desc: 'evidence declared with mismatched parameter arity', mustFail: true, apply: () => VALID_DSL.replace('evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }', 'evidence can_read(user: Employee) { owns(user, doc) }') + }, + cyclic_evidence_ref: { + desc: 'two evidences referencing each other (cycle)', + mustFail: true, + apply: () => VALID_DSL + ` + evidence can_cyc_a(user: Employee, doc: Doc) { can_cyc_b(user, doc) } + evidence can_cyc_b(user: Employee, doc: Doc) { can_cyc_a(user, doc) }` + }, + cross_kind_collision: { + desc: 'fact and evidence sharing a relation name', + mustFail: true, + apply: () => VALID_DSL.replace( + 'evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }', + 'fact can_read(user: Employee, doc: Doc)\n evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }' + ) } };