feat: evidence composition — compile-time reference resolution for evidence sub-rules
CI / publish (push) Successful in 11s
CI / test (push) Successful in 15s

An evidence may now reference another derived evidence as a sub-rule
(WHEN can_read(user, doc) where can_read is itself an evidence). Resolution
is a compile-time linker pass: after every evidence config is generated, each
direct reference to an evidence is inlined with that evidence's own (resolved)
config, so the engine evaluates a fully-resolved, acyclic config tree.

- resolveEvidenceReferences(): post-generation pass over evidence configs,
  recursing into logical/defeasible containers (when/unless/never/always/
  requires/union/intersection), always.direct nests, and comparator operands.
- Forward references resolve (all configs exist before the pass runs).
- Cycles and self-references are compile-time errors.
- _subjectAsObject scoping is preserved through inlining.
- dependsOn is recomputed after resolution, so partial-graph requirements
  reach transitively through composed evidence.
- buildDirectRule/buildPredicateRule now apply subject-scoping to top-level
  PredicateCall evidence bodies (latent gap, previously missed).
- validation: reject relation names shared across facts/sources/evidence/
  measures (a collision silently overwrote configs and read as a false cycle).

Tests: EvidenceComposition (9), DSLRuntime transitive requiredFacts, oracle
campaign composition construct, illegal-mutations cycle + cross-kind cases.
This commit is contained in:
John Dvorak
2026-08-03 11:17:30 -07:00
parent 0a744329e6
commit 88f10f9db4
7 changed files with 371 additions and 42 deletions
+32
View File
@@ -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) {