From 0a744329e6809c1c8bace5bbbd865a50831dc419 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Mon, 3 Aug 2026 10:58:29 -0700 Subject: [PATCH] feat: fix lowering, DSLRuntime wrapper, rigor oracle + rejection campaigns Lowering fixes (validate/lower/compile into known-correct core structures): - tuple_to_userset: structural classification by object-side predicate (owner(*g, doc) { member_of(user, g) } -> tuple_to_userset with direction 'in'/'out'); the old heuristic routed every outer-wildcard to chain. - relational_comparator: operands now lower to real direct-rule configs (evaluateFrom derived from evidence param positions; expectedValue for literal args) instead of raw AST nodes the engine could not evaluate. - defeasible: multi-level bodies (NEVER/REQUIRES/ALWAYS/WHEN/UNLESS) merge into one five-level rule instead of ANDed level-only rules that always resolved 0; nested PatternMatches flatten to N-step chains; unary predicate calls mark _subjectAsObject (subject-as-object semantics). - validation: reject duplicate fact/evidence definitions. DSLRuntime (higher-order DSL+Core wrapper): - typed addNode/updateNodeData/addRelation/updateRelation against the DSL schema (known types, relation params, field types, value-carrying facts); - check() derives the evidence's injectable partial-graph requirements, retrieves missing facts through caller data callbacks, injects them, and delegates, returning requiredFacts/providedFacts/missingFacts. js-rigor campaigns: - generative oracle: generate legal DSL per construct and compare every verdict against an independent hand-computed oracle (8 constructs x P grid) plus an exhaustive deterministic sweep; - illegal mutations: one-flaw perturbations of a valid program must be reliably rejected (duplicate evidence/fact, arity/type mismatches, reserved built-ins, malformed syntax), with a control that must compile. Depends on @arbiter/core@^1.0.2 (reason codes + _subjectAsObject). --- .gitea/workflows/ci.yaml | 4 + .npmrc | 2 + package-lock.json | 275 ++++++++++++++- package.json | 6 +- src/generator/RuleGenerator.js | 412 +++++++++++++++++----- src/index.js | 3 + src/runtime/DSLRuntime.js | 403 +++++++++++++++++++++ src/validation/DSLValidation.js | 22 ++ tests/DSLRuntime.test.js | 164 +++++++++ tests/rigor/dsl-generative-oracle.test.js | 190 ++++++++++ tests/rigor/dsl-illegal-mutations.test.js | 138 ++++++++ 11 files changed, 1530 insertions(+), 89 deletions(-) create mode 100644 src/runtime/DSLRuntime.js create mode 100644 tests/DSLRuntime.test.js create mode 100644 tests/rigor/dsl-generative-oracle.test.js create mode 100644 tests/rigor/dsl-illegal-mutations.test.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 28ba692..8b1bbca 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -26,6 +26,8 @@ jobs: echo "//hub.kl1.tenere.ai/api/packages/Arbiter/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc echo "@tenere:registry=https://hub.kl1.tenere.ai/api/packages/Tenere/npm/" >> .npmrc echo "//hub.kl1.tenere.ai/api/packages/Tenere/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + echo "@rigor:registry=https://hub.kl1.tenere.ai/api/packages/Rigor/npm/" >> .npmrc + echo "//hub.kl1.tenere.ai/api/packages/Rigor/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc - run: npm ci @@ -50,6 +52,8 @@ jobs: echo "//hub.kl1.tenere.ai/api/packages/Arbiter/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc echo "@tenere:registry=https://hub.kl1.tenere.ai/api/packages/Tenere/npm/" >> .npmrc echo "//hub.kl1.tenere.ai/api/packages/Tenere/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + echo "@rigor:registry=https://hub.kl1.tenere.ai/api/packages/Rigor/npm/" >> .npmrc + echo "//hub.kl1.tenere.ai/api/packages/Rigor/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc - run: npm ci diff --git a/.npmrc b/.npmrc index be917a1..9405a10 100644 --- a/.npmrc +++ b/.npmrc @@ -2,3 +2,5 @@ //hub.kl1.tenere.ai/api/packages/Arbiter/npm/:_authToken=${PACKAGE_TOKEN} @tenere:registry=https://hub.kl1.tenere.ai/api/packages/Tenere/npm/ //hub.kl1.tenere.ai/api/packages/Tenere/npm/:_authToken=${PACKAGE_TOKEN} +@rigor:registry=https://hub.kl1.tenere.ai/api/packages/Rigor/npm/ +//hub.kl1.tenere.ai/api/packages/Rigor/npm/:_authToken=${PACKAGE_TOKEN} diff --git a/package-lock.json b/package-lock.json index 3edf4c1..1d8c499 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,25 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@arbiter/evidence-dsl", - "version": "1.0.0", + "version": "1.1.0", "license": "ISC", "dependencies": { - "@arbiter/core": "^1.0.1" + "@arbiter/core": "^1.0.2" }, "devDependencies": { + "@rigor/core": "^3.1.0", "peggy": "^5.0.6" } }, "node_modules/@arbiter/core": { - "version": "1.0.1", - "resolved": "https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/%40arbiter%2Fcore/-/1.0.1/core-1.0.1.tgz", - "integrity": "sha512-BfIv6vRKsuJR39WBkBGxl2PQ/LDoW/J1u4UeFrog0AbeTSTvRqhxG1+its2pAcUvcQXq99ZOccFlK3XgNRKt4Q==", + "version": "1.0.2", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/%40arbiter%2Fcore/-/1.0.2/core-1.0.2.tgz", + "integrity": "sha512-N1duiHy1Rlsxqpvu8uPf4tMaLOQ2tNXvGs53jLkRcIAYqafIAMvcf0BPS2iE2xVKNsqY92+F05bZZEAO5jnbyQ==", "license": "ISC", "dependencies": { "@tenere/pltc-core": "^0.6.3", @@ -39,6 +40,268 @@ "node": ">=20.8" } }, + "node_modules/@rigor/analysis": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fanalysis/-/0.0.7/analysis-0.0.7.tgz", + "integrity": "sha512-CH9g9gU5P+aWZfneL7l91Q8wgZ0kwteYTpOdShqi6VFg2aoOSk4HMl8RTzkO4suxWa2VQ/mMnFJ/TAiHwJOobA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/instrument": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/artifact": { + "version": "0.1.3", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fartifact/-/0.1.3/artifact-0.1.3.tgz", + "integrity": "sha512-/SP5veJOpZ7y0dEBYOm+iBKQnBqZbTQzCXGd/LvQvmDtMGL7KJv2UbU7blnWp9CIwD8qU8t9U9c9oKO+qupUcA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/benchmark": { + "version": "0.0.6", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fbenchmark/-/0.0.6/benchmark-0.0.6.tgz", + "integrity": "sha512-uCCtEhCeY0t81aSgvV1Q7D3cgNU1WufEGJ/vcljhjXnx05hdMWnlK0NPdI/qkWRbRiz+4sSpcGOGO9NBFk7ExQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/complexity": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/complexity": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fcomplexity/-/0.0.7/complexity-0.0.7.tgz", + "integrity": "sha512-RfrkXLykb37WFwmB6OfZoXYMEZJLG78r2HqfDB27jWQmGt5l/4Hb9Zh77dEKl2CsUx2fgd5NafdDl9qFtTE6bg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/core": { + "version": "3.1.0", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fcore/-/3.1.0/core-3.1.0.tgz", + "integrity": "sha512-gUuq2YuaM0LNjdmHABcRbEW6FvPhO+U1ErokQ6ftPKpPBb38vDc+pPqu7KgGtr3R9NDU3Vj9OBV1Wq9AlzfraA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/analysis": "*", + "@rigor/artifact": "*", + "@rigor/benchmark": "*", + "@rigor/complexity": "*", + "@rigor/fault": "*", + "@rigor/fuzzer": "*", + "@rigor/gen": "*", + "@rigor/instrument": "*", + "@rigor/linearizability": "*", + "@rigor/model": "*", + "@rigor/network": "*", + "@rigor/probe": "*", + "@rigor/prop": "*", + "@rigor/reporters": "*", + "@rigor/rng": "*", + "@rigor/search": "*", + "@rigor/shrink": "*", + "@rigor/spec": "*", + "@rigor/storage": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/fault": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Ffault/-/0.0.5/fault-0.0.5.tgz", + "integrity": "sha512-MxzAEk8tx/Nc9CL3dQZZwKXj7eqBgnfTC9dI9TiU9b1u3zW7EIoL28vGqtrr1ucqSTeNwD8BGGAenan2tvb1ZQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/fuzzer": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Ffuzzer/-/0.0.4/fuzzer-0.0.4.tgz", + "integrity": "sha512-cJu9bL0GqpkTsIne9PLMcsLtRfBlDz18DqPiarSi+MetH9R0joh7C+fp/e4guo0y+Q2RBJZwulA8gOh9AwgEaQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/gen": { + "version": "0.0.11", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fgen/-/0.0.11/gen-0.0.11.tgz", + "integrity": "sha512-PJG40PtiRJRbNDajdpGaachLYYR+tkSK/0ToIbG5BlG+l/enw7g80hkH674Vw4jeWoftzuwe2NM4dC9rj9rHbg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/rng": "*" + } + }, + "node_modules/@rigor/instrument": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Finstrument/-/0.0.5/instrument-0.0.5.tgz", + "integrity": "sha512-GXJa6kcEAbd86yM57hR+2g0vcxJlpBfBOoMldWk3mzLmAcCkyYXNSjJekTAwNovfUrtIcnYgQ187Zj3A2d8Tcw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/probe": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/linearizability": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Flinearizability/-/0.0.5/linearizability-0.0.5.tgz", + "integrity": "sha512-Ruw+/Zc1hiMfs6i/4qYxYuvUsBtCm5SUmpuXrrixSRglvJ9PYfL/H8xb/Pk3Ve1dFBj8TQN6M3AnwCeaJF2/tg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@rigor/model": { + "version": "0.2.13", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fmodel/-/0.2.13/model-0.2.13.tgz", + "integrity": "sha512-eZ/888p1zGJGCBFVMA9suvS2mUNX+iHhZn+Uwl9lWa9CSHVRUWH9dTYYB+Qr1hNrCSGIDjV7rOC34wzT+QA4fw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/complexity": "*", + "@rigor/fault": "*", + "@rigor/gen": "*", + "@rigor/probe": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/network": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fnetwork/-/0.0.4/network-0.0.4.tgz", + "integrity": "sha512-iNmiQA5QiCNkXnjAMPibGYzDk3Lf5hsgwXQGwRcSSVlZNBfVVw5fURzTsNen3TeAnqh9nozJnhD52yp3zdfP6Q==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/rng": "*", + "@rigor/scheduler": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/probe": { + "version": "0.0.6", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fprobe/-/0.0.6/probe-0.0.6.tgz", + "integrity": "sha512-vUGY6vwciwYqxMaUYa3L2IIwipnnXdPANG8GONx+PrlII2eSbMCjb7BLZGso4yG/iaheRR0JwAnjV8mvtvUOOA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/gen": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/prop": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fprop/-/0.0.4/prop-0.0.4.tgz", + "integrity": "sha512-z3+STJPeNpz080ZfFbw5h6LpNFjCNnSymoQPUsjyaSovq3euyJrGx9sqtosVgVVE7Rzld6K4QwchJSJOqIi73w==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/complexity": "*", + "@rigor/gen": "*", + "@rigor/probe": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/reporters": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Freporters/-/0.0.5/reporters-0.0.5.tgz", + "integrity": "sha512-+YYfOMlYDsmGIi9rKiJm7ZAptQ0rTeTU1Sp2uVG9UJteuVKW2eKZIC6TeJuM1qvjQmh7/IzBkl3PZVX9pKnYaw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/rng": { + "version": "0.0.3", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Frng/-/0.0.3/rng-0.0.3.tgz", + "integrity": "sha512-OFERK5HlI6eV1yUadHoz9n88twqsd/lstAxMzBWFJiN1CCM0xG7IHUYMBCnYK6Di3TTcAarFNFL16Bchm3LrKg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@rigor/scheduler": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fscheduler/-/0.0.4/scheduler-0.0.4.tgz", + "integrity": "sha512-tY7KASEfufOw4L1vIGeD1bTArCv+zAEPqT/XqiXFz+fckGxY8Nux6zDhGcjkur3qWOUNoWap6KOjSZSORHhXiw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/search": { + "version": "0.1.3", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fsearch/-/0.1.3/search-0.1.3.tgz", + "integrity": "sha512-NAq5zbUSc6QQO4YIUFq16+hPtrtkNrjAn75lH/4VDPpbCdQPbKvS6zsQ3v954u1u7I6x2jI5uilpX6/xfr69yA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/gen": "*", + "@rigor/shrink": "*" + } + }, + "node_modules/@rigor/shrink": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fshrink/-/0.0.7/shrink-0.0.7.tgz", + "integrity": "sha512-z0yaZqmyysn40BBqAnqTQlymLuaIIU5u2BijjayX0+XZxhhcPIyvs4PISzJ4nmfkx2yE6qKABe2HHub9wVVUQw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@rigor/spec": { + "version": "2.0.1", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fspec/-/2.0.1/spec-2.0.1.tgz", + "integrity": "sha512-0/lg19KenJy1e8D87+WqaMRh8Xad4h209/CgK6ofCQapwz/IcriMPHgOZZbHMA4C8G3B6/luVbSVT0EHZoJ8qg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/gen": "*" + } + }, + "node_modules/@rigor/storage": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fstorage/-/0.0.4/storage-0.0.4.tgz", + "integrity": "sha512-7XapdgmvEg/C/kERldqFlw3mT1JkpjPMNK6fEcB2Ggx7+UR3ZeE9GyaodJ29my61b28SrQUWxLBHT5bm0bIVaA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/rng": "*", + "@rigor/scheduler": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/trace": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Ftrace/-/0.0.7/trace-0.0.7.tgz", + "integrity": "sha512-vCLF+WTSdy0pwe9sTonPt8HnNeSkK9U5SAxNpdIeiT+IBP74t9K7JoHMmfo7ka+S0puWecDjjNtWCSqtvKkjNg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE" + }, "node_modules/@tenere/graph-core": { "version": "1.0.1", "resolved": "https://hub.kl1.tenere.ai/api/packages/Tenere/npm/%40tenere%2Fgraph-core/-/1.0.1/graph-core-1.0.1.tgz", diff --git a/package.json b/package.json index 5d33202..3c367a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.0.0", + "version": "1.1.0", "description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.", "license": "ISC", "type": "module", @@ -13,6 +13,7 @@ "./parser/GeneratedParser": "./src/parser/GeneratedParser.js", "./generator/RuleGenerator": "./src/generator/RuleGenerator.js", "./validation/DSLValidation": "./src/validation/DSLValidation.js", + "./runtime/DSLRuntime": "./src/runtime/DSLRuntime.js", "./interpreter/BuiltInFunctions": "./src/interpreter/BuiltInFunctions.js" }, "files": [ @@ -23,9 +24,10 @@ "generate:parser": "node scripts/generate-parser.js" }, "dependencies": { - "@arbiter/core": "^1.0.1" + "@arbiter/core": "^1.0.2" }, "devDependencies": { + "@rigor/core": "^3.1.0", "peggy": "^5.0.6" } } diff --git a/src/generator/RuleGenerator.js b/src/generator/RuleGenerator.js index c64b32a..9722072 100644 --- a/src/generator/RuleGenerator.js +++ b/src/generator/RuleGenerator.js @@ -258,26 +258,27 @@ export class RuleGenerator { // Handle single statement evidence if (statements.length === 1) { - return this.buildSingleStatementRule(statements[0]); + return this.buildSingleStatementRule(statements[0], evidence); } // Handle multiple statements with logical operators - return this.buildLogicalRule(statements); + return this.buildLogicalRule(statements, evidence); } /** * Build rule configuration for a single statement * @param {BaseNode} statement - Statement to build rule for + * @param {Object} evidence - Evidence definition (params inform pattern/operand lowering) * @returns {Object|null} Rule configuration or null */ - buildSingleStatementRule(statement) { + buildSingleStatementRule(statement, evidence) { switch (statement.type) { case 'DirectEvidence': return this.buildDirectRule(statement); case 'PatternMatch': - return this.buildPatternMatchRule(statement); + return this.buildPatternMatchRule(statement, evidence); case 'DefeasibleLogic': - return this.buildDefeasibleRule(statement); + return this.buildDefeasibleRule(statement, evidence); case 'Fusion': return this.buildFusionRule(statement); case 'PredicateCall': @@ -286,13 +287,13 @@ export class RuleGenerator { return this.buildUnaryRule(statement); case 'BinaryExpression': // Top-level comparator — emit a relational_comparator rule. RF-24 closure. - return this.buildRuleFromExpressionNode(statement); + return this.buildRuleFromExpressionNode(statement, evidence); case 'Expression': // Handle expressions that might be predicate calls if (statement.type === 'PredicateCall') { return this.buildPredicateRule(statement); } - return this.buildRuleFromExpressionNode(statement); + return this.buildRuleFromExpressionNode(statement, evidence); default: this.errors.push(`Unsupported statement type: ${statement.type}`); return null; @@ -331,11 +332,24 @@ export class RuleGenerator { * @param {BaseNode[]} statements - Statements to combine * @returns {Object|null} Rule configuration or null */ - buildLogicalRule(statements) { + buildLogicalRule(statements, evidence) { + const defeasible = statements.filter(s => s && s.type === 'DefeasibleLogic'); + const others = statements.filter(s => s && s.type !== 'DefeasibleLogic'); + + // Defeasible levels (NEVER / REQUIRES / ALWAYS / WHEN / UNLESS) form ONE + // five-level hierarchy (ADR-000), not separate ANDed rules. A standalone + // NEVER-only rule contributes 0 whether or not it fires, so ANDing the + // levels separately would always yield 0. Merge all defeasible statements + // into a single config; any non-defeasible statements become the base + // grant (when) that the defeaters and requirements gate. + if (defeasible.length > 0) { + return this.buildMergedDefeasibleRule(defeasible, others, evidence); + } + const rules = []; - - statements.forEach(statement => { - const rule = this.buildSingleStatementRule(statement); + + others.forEach(statement => { + const rule = this.buildSingleStatementRule(statement, evidence); if (rule) { rules.push(rule); } @@ -361,6 +375,85 @@ export class RuleGenerator { }; } + /** + * Merge defeasible-level statements into a single five-level rule config. + * The ADR-000 hierarchy is never > requires > strict (always) > when > unless; + * each level accumulates its conditions and the whole thing evaluates as one + * defeasible rule rather than a conjunction of level-only rules. + */ + buildMergedDefeasibleRule(defeasibleStatements, otherStatements, evidence) { + const neverRules = []; + const alwaysRules = []; + const requiresRules = []; + const whenRules = []; + const unlessRules = []; + + for (const st of defeasibleStatements) { + const condition = this.buildRuleFromExpression(st.condition, evidence); + const defeater = st.defeater ? this.buildRuleFromExpression(st.defeater, evidence) : null; + switch (st.logicType) { + case 'NEVER': + if (condition) neverRules.push(condition); + break; + case 'ALWAYS': + if (condition) alwaysRules.push(condition); + break; + case 'REQUIRES': + if (condition) requiresRules.push(condition); + break; + case 'WHEN': + if (condition) whenRules.push(condition); + if (defeater) unlessRules.push(defeater); + break; + case 'UNLESS': + if (condition) unlessRules.push(condition); + break; + default: + this.errors.push(`Unsupported defeasible logic type: ${st.logicType}`); + } + } + + // Non-defeasible statements in the same body act as the base grant + // (when) that NEVER/REQUIRES/UNLESS gate. + if (otherStatements.length > 0) { + const baseRules = otherStatements + .map(s => this.buildSingleStatementRule(s, evidence)) + .filter(Boolean); + if (baseRules.length === 1) { + whenRules.push(baseRules[0]); + } else if (baseRules.length > 1) { + whenRules.push({ + type: 'logical', + intersection: { rules: baseRules, aggregator: 'min' } + }); + } + } + + const rule = { type: 'logical' }; + if (neverRules.length > 0) { + rule.never = { union: { rules: neverRules, aggregator: 'max' } }; + } + if (requiresRules.length > 0) { + rule.requires = { union: { rules: requiresRules, aggregator: 'min' } }; + } + if (alwaysRules.length > 0) { + rule.always = { + direct: alwaysRules.length === 1 + ? alwaysRules[0] + : { type: 'logical', intersection: { rules: alwaysRules, aggregator: 'min' } }, + aggregator: 'min' + }; + } + if (whenRules.length > 0) { + rule.when = { intersection: { rules: whenRules, aggregator: 'min' } }; + } + if (unlessRules.length > 0) { + rule.unless = { union: { rules: unlessRules, aggregator: 'max' } }; + } + + return rule; + } + /** * Build direct rule configuration * @param {DirectEvidenceNode} directEvidence - Direct evidence statement @@ -387,7 +480,7 @@ export class RuleGenerator { * @param {PatternMatchNode} patternMatch - Pattern match statement * @returns {Object|null} Rule configuration or null */ - buildPatternMatchRule(patternMatch) { + buildPatternMatchRule(patternMatch, evidence) { if (!patternMatch.predicate) { this.errors.push('Pattern match must have a predicate'); return null; @@ -396,27 +489,119 @@ export class RuleGenerator { const predicate = patternMatch.predicate; const relation = predicate.name; - // Membership/hierarchy predicates map to TupleToUsersetRule / ParentRule - // regardless of body shape — those have priority over chain detection. + // Structural classification (ADR-000 §Mapping to Engine Rule Types): the + // two-hop pattern "P(args) { Q(args) }" binds an intermediate via a Wildcard. + // The predicate whose args include the OBJECT parameter is the object-side + // hop. Object-side = outer → tuple_to_userset (user → computed → intermediate + // → tupleset → object). Object-side = inner → chain (user → outer → + // intermediate → inner → object). This mirrors Zanzibar's tuple-to-userset + // vs. two-hop path semantics and fixes the previous heuristic that routed + // every outer-wildcard pattern to chain (owner(*g, doc) { member_of(user, g) } + // was emitted as a chain and could never match). + const inner = this._singleInnerPredicate(patternMatch); + const evidenceParams = (evidence && evidence.params) || []; + const userVar = evidenceParams[0] && evidenceParams[0].name; + const objectVar = evidenceParams[1] && evidenceParams[1].name; + + // Nested PatternMatch bodies ("P(user, *a) { Q(a, *b) { R(b, doc) } }") are + // fixed-length multi-hop PATHS — a chain whose steps are the flattened + // predicate sequence [P, Q, R], not transitive-closure multi_hop over one + // relation. Chain handles N steps; multi_hop only walks a single relation. + if (this._isNestedPattern(patternMatch)) { + return this.buildNestedChainRule(patternMatch, inner); + } + + if (inner) { + const argName = a => a && (a.name !== undefined ? a.name : a.value); + const outerHasObject = objectVar !== undefined && + (predicate.args || []).some(a => argName(a) === objectVar); + const innerHasObject = objectVar !== undefined && + (inner.args || []).some(a => argName(a) === objectVar); + + if (outerHasObject && !innerHasObject) { + return this.buildTupleToUsersetRule(patternMatch, inner); + } + if (innerHasObject && !outerHasObject) { + return this.buildChainRule(patternMatch, inner); + } + } + + // Fallback: membership/hierarchy naming hints (evidence params unavailable + // or both predicates reference the object — keep legacy behavior). if (this.isMembershipPredicate(predicate)) { - return this.buildTupleToUsersetRule(patternMatch); + return this.buildTupleToUsersetRule(patternMatch, inner); } if (this.isHierarchyPredicate(predicate)) { return this.buildParentRule(patternMatch); } - // Chain detection: ADR-000 ChainRule shape is "works_in(p, *d) { has_access(d, r) }". - // The outer PatternMatch has a Wildcard binding, and its body contains a single - // PredicateCall (no DefeasibleLogic wrapping, no nested PatternMatch). Treat that - // as a chain: two-hop traversal through the wildcard intermediate. RF-24 closure - // (parallel to RF-22/RF-23 — DSL→engine mapping gap surfaced by rigor coverage). if (this._isChainPattern(patternMatch)) { - return this.buildChainRule(patternMatch); + return this.buildChainRule(patternMatch, inner); } return this.buildMultiHopRule(patternMatch); } + /** + * Extract the single inner PredicateCall of a PatternMatch body (null if the + * body has multiple statements, is nested, or is wrapped in logic operators). + */ + _singleInnerPredicate(patternMatch) { + if (!patternMatch.body || !Array.isArray(patternMatch.body.statements)) return null; + const stmts = patternMatch.body.statements; + if (stmts.length !== 1) return null; + if (stmts[0].type === 'PredicateCall') return stmts[0]; + return null; + } + + /** + * True when the PatternMatch body is itself a nested PatternMatch + * ("P(user, *a) { Q(a, *b) { R(b, doc) } }") — a multi-hop path. + */ + _isNestedPattern(patternMatch) { + if (!patternMatch.body || !Array.isArray(patternMatch.body.statements)) return false; + const stmts = patternMatch.body.statements; + return stmts.length === 1 && stmts[0].type === 'PatternMatch'; + } + + /** + * Flatten a nested PatternMatch into its linear predicate sequence + * [P, Q, ..., R] where R is the object-side hop. + */ + _flattenPatternSteps(patternMatch, acc = []) { + const predicate = patternMatch.predicate; + if (!predicate || !predicate.name) return acc; + acc.push(predicate.name); + if (patternMatch.body && Array.isArray(patternMatch.body.statements) && + patternMatch.body.statements.length === 1) { + const child = patternMatch.body.statements[0]; + if (child && child.type === 'PredicateCall' && child.name) { + acc.push(child.name); + } else if (child && child.type === 'PatternMatch') { + this._flattenPatternSteps(child, acc); + } + } + return acc; + } + + /** + * Build a chain rule from a nested (multi-hop path) PatternMatch. + * "P(user, *a) { Q(a, *b) { R(b, doc) } }" → { type: 'chain', steps: [P, Q, R] }. + */ + buildNestedChainRule(patternMatch) { + const steps = this._flattenPatternSteps(patternMatch); + if (steps.length < 2) { + this.errors.push('Nested pattern match must yield at least two steps'); + return null; + } + return { + type: 'chain', + steps, + aggregator: 'max', + collectValues: true + }; + } + /** * Detect the ChainRule shape: a PatternMatch whose body contains exactly one * PredicateCall and uses a Wildcard arg to bind the intermediate. The predicate @@ -435,17 +620,19 @@ export class RuleGenerator { /** * Build chain rule configuration (ADR-000 ChainRule). - * Compiles "works_in(p, *d) { has_access(d, doc) }" into + * Compiles "works_in(user, *d) { has_access(d, doc) }" into * { type: 'chain', steps: ['works_in', 'has_access'] } - * The intermediate wildcard binds the two predicates' arguments. + * The intermediate wildcard binds the two predicates' arguments. Step order is + * [userSide, objectSide]: the outer predicate connects user → intermediate, + * the inner predicate connects intermediate → object. */ - buildChainRule(patternMatch) { + buildChainRule(patternMatch, inner) { const steps = []; steps.push(patternMatch.predicate.name); - const inner = patternMatch.body.statements[0]; - if (inner && inner.type === 'PredicateCall' && inner.name) { - steps.push(inner.name); + const innerPredicate = inner || (patternMatch.body.statements[0]); + if (innerPredicate && innerPredicate.type === 'PredicateCall' && innerPredicate.name) { + steps.push(innerPredicate.name); } return { @@ -457,18 +644,31 @@ export class RuleGenerator { } /** - * Build tuple-to-userset rule configuration - * @param {PatternMatchNode} patternMatch - Pattern match statement - * @returns {Object|null} Rule configuration or null + * Build tuple-to-userset rule configuration (ADR-000 TupleToUsersetRule). + * Compiles "owner(*g, doc) { member_of(user, g) }" into + * { + * type: 'tuple_to_userset', + * tuplesetRelation: 'owner', // object-side hop: intermediate → object + * computedRelation: 'member_of', // user-side hop: user → intermediate + * tuplesetDirection: 'in', // intermediates hold the tupleset edge TO the object + * reverse: false + * } + * The tupleset edge direction follows the wildcard position in the outer + * predicate: wildcard as first arg (owner(*g, doc)) means the intermediate is + * the edge source ('in' — fetch edges with dst = object); wildcard as second + * arg (owner(doc, *g)) means the object is the source ('out'). */ - buildTupleToUsersetRule(patternMatch) { + buildTupleToUsersetRule(patternMatch, inner) { const predicate = patternMatch.predicate; const relation = predicate.name; - + const computedRelation = inner && inner.name ? inner.name : relation; + const wildcardIndex = (predicate.args || []).findIndex(a => a && a.type === 'Wildcard'); + return { type: 'tuple_to_userset', - tuplesetRelation: 'owner', // Default, could be inferred from context - computedRelation: relation, + tuplesetRelation: relation, + computedRelation, + tuplesetDirection: wildcardIndex === 0 ? 'in' : 'out', reverse: false, earlyExitThreshold: 0.95, maxIntermediates: patternMatch.limit || 10 @@ -518,19 +718,19 @@ export class RuleGenerator { * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ - buildDefeasibleRule(defeasibleLogic) { + buildDefeasibleRule(defeasibleLogic, evidence) { const logicType = defeasibleLogic.logicType; if (logicType === 'NEVER') { - return this.buildNeverRule(defeasibleLogic); + return this.buildNeverRule(defeasibleLogic, evidence); } else if (logicType === 'ALWAYS') { - return this.buildStrictRule(defeasibleLogic); + return this.buildStrictRule(defeasibleLogic, evidence); } else if (logicType === 'WHEN') { - return this.buildDefeasibleRuleWithDefeater(defeasibleLogic); + return this.buildDefeasibleRuleWithDefeater(defeasibleLogic, evidence); } else if (logicType === 'UNLESS') { - return this.buildDefeaterRule(defeasibleLogic); + return this.buildDefeaterRule(defeasibleLogic, evidence); } else if (logicType === 'REQUIRES') { - return this.buildRequirementRule(defeasibleLogic); + return this.buildRequirementRule(defeasibleLogic, evidence); } this.errors.push(`Unsupported defeasible logic type: ${logicType}`); @@ -542,8 +742,8 @@ export class RuleGenerator { * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ - buildNeverRule(defeasibleLogic) { - const condition = this.buildRuleFromExpression(defeasibleLogic.condition); + buildNeverRule(defeasibleLogic, evidence) { + const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', @@ -561,8 +761,8 @@ export class RuleGenerator { * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ - buildStrictRule(defeasibleLogic) { - const condition = this.buildRuleFromExpression(defeasibleLogic.condition); + buildStrictRule(defeasibleLogic, evidence) { + const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', @@ -578,9 +778,9 @@ export class RuleGenerator { * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ - buildDefeasibleRuleWithDefeater(defeasibleLogic) { - const condition = this.buildRuleFromExpression(defeasibleLogic.condition); - const defeater = this.buildRuleFromExpression(defeasibleLogic.defeater); + buildDefeasibleRuleWithDefeater(defeasibleLogic, evidence) { + const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); + const defeater = this.buildRuleFromExpression(defeasibleLogic.defeater, evidence); const rule = { type: 'logical', @@ -609,8 +809,8 @@ export class RuleGenerator { * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ - buildDefeaterRule(defeasibleLogic) { - const condition = this.buildRuleFromExpression(defeasibleLogic.condition); + buildDefeaterRule(defeasibleLogic, evidence) { + const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', @@ -628,8 +828,8 @@ export class RuleGenerator { * @param {DefeasibleLogicNode} defeasibleLogic - Defeasible logic statement * @returns {Object|null} Rule configuration or null */ - buildRequirementRule(defeasibleLogic) { - const condition = this.buildRuleFromExpression(defeasibleLogic.condition); + buildRequirementRule(defeasibleLogic, evidence) { + const condition = this.buildRuleFromExpression(defeasibleLogic.condition, evidence); return { type: 'logical', @@ -698,19 +898,21 @@ export class RuleGenerator { * @param {BaseNode} expression - Expression to build rule from * @returns {Object|null} Rule configuration or null */ - buildRuleFromExpression(expression) { + buildRuleFromExpression(expression, evidence) { if (!expression) { return null; } if (expression.type === 'Predicate') { - return this.buildDirectRuleFromPredicate(expression); + return this.buildDirectRuleFromPredicate(expression, evidence); } else if (expression.type === 'Expression') { - return this.buildRuleFromExpressionNode(expression); + return this.buildRuleFromExpressionNode(expression, evidence); } else if (expression.type === 'PredicateCall') { - return this.buildPredicateRule(expression); + return this.buildPredicateRule(expression, evidence); } else if (expression.type === 'UnaryExpression') { return this.buildUnaryRule(expression); + } else if (expression.type === 'BinaryExpression') { + return this.buildRuleFromExpressionNode(expression, evidence); } this.errors.push(`Unsupported expression type: ${expression.type}`); @@ -722,15 +924,24 @@ export class RuleGenerator { * @param {PredicateNode} predicate - Predicate to build rule from * @returns {Object|null} Rule configuration or null */ - buildDirectRuleFromPredicate(predicate) { + buildDirectRuleFromPredicate(predicate, evidence) { const expanded = this._expandPredicate(predicate.name); if (expanded) return expanded; - return { + const rule = { type: 'direct', relation: predicate.name, reverse: false }; + + const evidenceParams = (evidence && evidence.params) || []; + const objectVar = evidenceParams[1] && evidenceParams[1].name; + if (objectVar !== undefined && !(predicate.args || []).some(a => + a && a.type === 'Variable' && a.name === objectVar)) { + rule._subjectAsObject = true; + } + + return rule; } _expandPredicate(predicateName) { @@ -770,7 +981,7 @@ export class RuleGenerator { * @param {ExpressionNode} expression - Expression to build rule from * @returns {Object|null} Rule configuration or null */ - buildRuleFromExpressionNode(expression) { + buildRuleFromExpressionNode(expression, evidence) { if (expression.type === 'AttributeAccess') { return this.buildAttributeRule(expression); } else if (expression.type === 'PredicateCall') { @@ -782,7 +993,7 @@ export class RuleGenerator { // Route BinaryExpression with comparator operators here so the // evaluator can run a fuzzy interval comparison instead of treating // them as logical truth values. RF-24 closure. - return this.buildRelationalComparatorRule(expression); + return this.buildRelationalComparatorRule(expression, evidence); } this.errors.push(`Unsupported expression type: ${expression.type}`); @@ -812,10 +1023,10 @@ export class RuleGenerator { * minRulePossibility: 0 * } */ - buildRelationalComparatorRule(binaryExpression) { + buildRelationalComparatorRule(binaryExpression, evidence) { const comparator = binaryExpression.operator; - const left = this._buildComparatorOperand(binaryExpression.left); - const right = this._buildComparatorOperand(binaryExpression.right); + const left = this._buildComparatorOperand(binaryExpression.left, evidence); + const right = this._buildComparatorOperand(binaryExpression.right, evidence); if (!left || !right) { this.errors.push(`Comparator operands must resolve to predicate calls (operator=${comparator})`); return null; @@ -833,29 +1044,52 @@ export class RuleGenerator { } /** - * Wrap a BinaryExpression side into a relational_comparator operand. The - * operand's `rule` field is the original predicate call (preserving reference - * semantics so the inner rule's evaluator can resolve its values). `extractValue` - * tells the evaluator to read the relation's `value` field rather than its - * `possibility`, which is what `personAge(p)` / `docMinAge(d)` semantics require. + * Lower a comparator operand to a core-evaluable config. The operand's `rule` + * MUST be a real rule configuration (the engine's RuleEvaluator only accepts + * configs — raw AST nodes evaluate to 0). Per the core's operand contract: + * + * userRisk(user) → { rule: { type: 'direct', relation: 'userRisk' }, extractValue: true } (user perspective, auto) + * riskLimit(doc) → { rule: { type: 'direct', relation: 'riskLimit' }, extractValue: true, evaluateFrom: 'object' } + * + * The evaluateFrom side is derived from the evidence parameter positions: + * params[0] is the subject (user), params[1] is the object. A predicate call + * whose first arg is the object variable reads its value from the object + * perspective; anything else defaults to the user perspective. + * + * Literal value args (userRisk(user, 5)) annotate the operand with + * `expectedValue` — the declared value the caller expects the relation to + * carry. The engine compares resolved relation values; the DSLRuntime wrapper + * may enforce expectedValue as an additional gate. */ - _buildComparatorOperand(side) { + _buildComparatorOperand(side, evidence) { if (!side) return null; if (side.type === 'PredicateCall') { - return { - rule: side, - extractValue: true, - evaluatorFrom: 'auto' - }; - } - if (side.type === 'AttributeAccess') { - // user.age — treat the attribute path as a "measure" reference - return { - rule: side, + const operand = { + rule: { type: 'direct', relation: side.name, reverse: false }, extractValue: true, evaluatorFrom: 'auto', - attributePath: side.getAttributePath ? side.getAttributePath() : null + valueRelation: side.name }; + const evidenceParams = (evidence && evidence.params) || []; + const userVar = evidenceParams[0] && evidenceParams[0].name; + const objectVar = evidenceParams[1] && evidenceParams[1].name; + const firstArg = (side.args || [])[0]; + const firstArgName = firstArg && (firstArg.name !== undefined ? firstArg.name : firstArg.value); + if (objectVar !== undefined && firstArgName === objectVar) { + operand.evaluateFrom = 'object'; + } + const literalArg = (side.args || []).find(a => a && a.type === 'Literal'); + if (literalArg) { + operand.expectedValue = literalArg.value; + } + return operand; + } + if (side.type === 'AttributeAccess') { + // Attribute paths cannot lower to a relation config — the engine's + // operand machinery reads relation `value` fields, not node attributes. + // Reject loudly instead of emitting an unevaluable rule. + this.errors.push('Comparator operands cannot be attribute accesses (use a value-carrying relation instead)'); + return null; } return null; } @@ -880,7 +1114,7 @@ export class RuleGenerator { * @param {ExpressionNode} expression - Function expression * @returns {Object|null} Rule configuration or null */ - buildPredicateRule(expression) { + buildPredicateRule(expression, evidence) { const predicateName = expression.name; if (expression.challenge) { return this.buildChallengeRule(expression, null); @@ -891,11 +1125,27 @@ export class RuleGenerator { const expanded = this._expandPredicate(predicateName); if (expanded) return expanded; - return { + const rule = { type: 'direct', relation: predicateName, reverse: false }; + + // Subject-scoped (unary) predicate call: the call's variable args omit the + // evidence's object parameter (banned(user) inside can_open(user, doc)). + // Mark _subjectAsObject so the engine checks the relation on the subject + // itself — the unary fact's self-edge — instead of (subject, object). + const evidenceParams = (evidence && evidence.params) || []; + const objectVar = evidenceParams[1] && evidenceParams[1].name; + if (objectVar !== undefined) { + const hasObjectArg = (expression.args || []).some(a => + a && a.type === 'Variable' && a.name === objectVar); + if (!hasObjectArg) { + rule._subjectAsObject = true; + } + } + + return rule; } buildWithinRule(expression) { diff --git a/src/index.js b/src/index.js index 5bc0568..e54e867 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,9 @@ export { RuleGenerator } from './generator/RuleGenerator.js'; // Validation export { validateDslText } from './validation/DSLValidation.js'; +// Runtime +export { DSLRuntime } from './runtime/DSLRuntime.js'; + // All AST nodes export * from './nodes/index.js'; diff --git a/src/runtime/DSLRuntime.js b/src/runtime/DSLRuntime.js new file mode 100644 index 0000000..54f46f8 --- /dev/null +++ b/src/runtime/DSLRuntime.js @@ -0,0 +1,403 @@ +import { DSLCompiler } from '../DSLCompiler.js'; + +const PRIMITIVE_TYPES = new Set(['string', 'number', 'boolean']); + +/** + * DSLRuntime — higher-order wrapper combining the Evidence DSL with an + * @arbiter/core Arbiter. + * + * The DSL declares a typed schema: `definition` blocks (entity types with + * typed fields), `fact` declarations (relations with typed params, optional + * `*` injectable marker), and `evidence` rules (relations the runtime can + * check). A raw Arbiter accepts untyped inserts; this wrapper adds the + * DSL-informed layer: + * + * - addNode / updateNodeData / addRelation / updateRelation validate their + * arguments against the compiled schema — known types, known relations, + * matching param types, typed field values — before mutating the arbiter. + * - check() validates the request, derives the injectable facts the + * evidence requires (its partial-graph requirements), retrieves the + * missing facts through caller-provided data callbacks, injects them into + * a partial graph, then delegates to the arbiter. + * + * Trust boundary follows the core: caller-supplied evidence (partial graph / + * provider results) is trusted, never policed; only structure is validated. + */ +export class DSLRuntime { + /** + * @param {object} arbiter - An @arbiter/core Arbiter instance. + * @param {object} options + * @param {object} options.factProviders - relation → async fn(subject, object, ctx) + * returning a boolean, possibility number, { possibility, value }, or an + * array of { src, relation, dst, possibility, value } partial-graph edges. + * @param {object} options.policy + * @param {boolean} options.policy.strictTypes - throw on unknown types/relations + * (default true; false degrades to arbiter behavior for undeclared names). + */ + constructor(arbiter, options = {}) { + this.arbiter = arbiter; + this.compiler = new DSLCompiler(this.arbiter); + this.factProviders = options.factProviders || {}; + this.strictTypes = options.policy?.strictTypes !== false; + this.program = null; + this.types = new Map(); // typeName -> { fields: Map(field -> {type,isArray}) } + this.relations = new Map(); // relation -> { kind: 'fact'|'evidence', params, injectable } + this.dependsOn = new Map(); // evidence relation -> Set(fact relations) + } + + /** + * Compile a DSL program and index its schema. Returns this for chaining. + * @param {string} dsl + * @param {string} name + */ + compile(dsl, name) { + const result = this.compiler.compile(dsl, name); + if (!result.success) { + const error = new Error(`DSLRuntime compile failed: ${(result.errors || []).join('; ')}`); + error.errors = result.errors || []; + throw error; + } + this.program = result.program; + this._indexSchema(); + return this; + } + + _indexSchema() { + this.types.clear(); + this.relations.clear(); + this.dependsOn.clear(); + + for (const def of this.program.definitions || []) { + const fields = new Map(); + for (const field of def.fields || []) { + fields.set(field.name, { type: field.fieldType, isArray: !!field.isArray }); + } + this.types.set(def.name, { fields }); + } + + for (const fact of this.program.facts || []) { + this.relations.set(fact.name, { + kind: 'fact', + params: (fact.params || []).map(p => ({ name: p.name, type: p.paramType, isArray: !!p.isArray })), + injectable: !!fact.injectable + }); + } + + for (const ev of this.program.evidence || []) { + this.relations.set(ev.name, { + kind: 'evidence', + params: (ev.params || []).map(p => ({ name: p.name, type: p.paramType, isArray: !!p.isArray })), + injectable: false + }); + } + + // Index each evidence's fact dependencies from the compiled arbiter configs. + for (const ev of this.program.evidence || []) { + const config = this.arbiter.relationConfigs.get(ev.name); + const deps = new Set(); + const collect = (rule) => { + if (!rule || typeof rule !== 'object') return; + if (rule.type === 'direct' && rule.relation) deps.add(rule.relation); + if (rule.type === 'tuple_to_userset') { + if (rule.tuplesetRelation) deps.add(rule.tuplesetRelation); + if (rule.computedRelation) deps.add(rule.computedRelation); + } + if (rule.type === 'chain' && Array.isArray(rule.steps)) { + for (const s of rule.steps) deps.add(typeof s === 'string' ? s : s.relation); + } + if (rule.type === 'parent' && rule.parentRelation) deps.add(rule.parentRelation); + if (rule.type === 'multi_hop' && rule.relation) deps.add(rule.relation); + if (rule.type === 'relational_comparator') { + collect(rule.left?.rule); + collect(rule.right?.rule); + if (rule.left?.valueRelation) deps.add(rule.left.valueRelation); + if (rule.right?.valueRelation) deps.add(rule.right.valueRelation); + } + for (const key of ['union', 'intersection', 'exclusion', 'never', 'always', 'requires', 'when', 'unless']) { + const node = rule[key]; + if (!node) continue; + if (Array.isArray(node.rules)) for (const c of node.rules) collect(c); + if (Array.isArray(node.union?.rules)) for (const c of node.union.rules) collect(c); + if (Array.isArray(node.intersection?.rules)) for (const c of node.intersection.rules) collect(c); + if (node.direct) collect(node.direct); + if (node.rule) collect(node.rule); + } + }; + if (config && Array.isArray(config.dependsOn)) { + for (const d of config.dependsOn) deps.add(d); + } else { + collect(config); + } + this.dependsOn.set(ev.name, deps); + } + } + + // --------------------------------------------------------------------------- + // Schema validation helpers + // --------------------------------------------------------------------------- + + _isPrimitive(typeName) { + return PRIMITIVE_TYPES.has(typeName); + } + + _nodeType(key) { + const nodeId = this.arbiter.resolveNodeId(key); + if (nodeId === undefined) return null; + const node = this.arbiter.nodes.get(nodeId); + return node ? node.type : null; + } + + _checkNodeExists(key, position) { + if (!this.arbiter.nodeIdByKey.has(key)) { + throw new Error(`DSLRuntime: ${position} node '${key}' does not exist`); + } + } + + _checkNodeType(key, expectedType, position) { + if (this._isPrimitive(expectedType)) return; // value positions are validated separately + const actual = this._nodeType(key); + if (actual === null) { + this._checkNodeExists(key, position); + return; + } + if (actual !== expectedType) { + throw new Error(`DSLRuntime: ${position} node '${key}' has type '${actual}', expected '${expectedType}'`); + } + } + + _checkFieldValue(field, value, path) { + if (field.isArray) { + if (!Array.isArray(value)) { + throw new Error(`DSLRuntime: field '${path}' must be an array of ${field.type}`); + } + for (const item of value) this._checkScalarValue(field.type, item, path); + return; + } + this._checkScalarValue(field.type, value, path); + } + + _checkScalarValue(type, value, path) { + const ok = type === 'string' ? typeof value === 'string' + : type === 'number' ? typeof value === 'number' + : type === 'boolean' ? typeof value === 'boolean' + : true; // entity-typed fields accept any key + if (!ok) { + throw new Error(`DSLRuntime: field '${path}' must be ${type}, got ${typeof value}`); + } + } + + // --------------------------------------------------------------------------- + // Typed mutations + // --------------------------------------------------------------------------- + + /** + * Insert a node, validating the type exists (when declared) and that `data` + * conforms to the definition's typed fields. + */ + addNode(key, typeName, data = {}) { + if (this.types.has(typeName)) { + const { fields } = this.types.get(typeName); + for (const [name, field] of fields) { + if (data[name] !== undefined) this._checkFieldValue(field, data[name], `${typeName}.${name}`); + } + } else if (this.strictTypes) { + throw new Error(`DSLRuntime: unknown type '${typeName}'`); + } + return this.arbiter.addNode(key, typeName, data); + } + + /** + * Update node data, validating fields against the node's declared type. + */ + updateNodeData(key, data) { + const typeName = this._nodeType(key); + if (typeName && this.types.has(typeName)) { + const { fields } = this.types.get(typeName); + for (const [name, field] of fields) { + if (data[name] !== undefined) this._checkFieldValue(field, data[name], `${typeName}.${name}`); + } + } + return this.arbiter.updateNodeData(key, data); + } + + _relationOrThrow(relation) { + const meta = this.relations.get(relation); + if (!meta) { + if (this.strictTypes) throw new Error(`DSLRuntime: unknown relation '${relation}'`); + return null; + } + return meta; + } + + /** + * Insert a relation edge. Validates the relation is declared, that the + * subject/object nodes match the declared entity param types, and that any + * primitive value param is supplied in attrs.value of the correct type. + */ + addRelation(src, relation, dst, attrs = {}) { + const meta = this._relationOrThrow(relation); + if (meta) { + this._validateRelationEndpoints(relation, meta, src, dst, attrs); + } + return this.arbiter.addRelation(src, relation, dst, attrs); + } + + /** + * Update a relation edge (idempotent replace). Validates like addRelation. + */ + updateRelation(src, relation, dst, attrs = {}) { + const meta = this._relationOrThrow(relation); + if (meta) { + this._validateRelationEndpoints(relation, meta, src, dst, attrs); + } + this.arbiter.removeRelation(src, relation, dst); + return this.arbiter.addRelation(src, relation, dst, attrs); + } + + _validateRelationEndpoints(relation, meta, src, dst, attrs) { + const params = meta.params; + if (params.length === 0) { + throw new Error(`DSLRuntime: relation '${relation}' declares no parameters`); + } + // First param is always the subject (entity). + const subjectType = params[0].type; + if (this._isPrimitive(subjectType)) { + throw new Error(`DSLRuntime: relation '${relation}' subject param must be an entity type, got '${subjectType}'`); + } + this._checkNodeType(src, subjectType, 'subject'); + + if (params.length >= 2) { + const secondType = params[1].type; + if (this._isPrimitive(secondType)) { + // Value-carrying fact (e.g. session(user, token: string)): the value + // lives on the edge's `value` field; the graph edge is a self-edge on + // the subject so the value is discoverable by value extraction. + if (attrs.value === undefined) { + attrs.value = dst; + } + this._checkScalarValue(secondType, attrs.value, `${relation}.${params[1].name}`); + if (dst !== src) { + throw new Error(`DSLRuntime: value param '${params[1].name}' must be supplied as attrs.value with dst = src (self-edge), got dst '${dst}'`); + } + } else { + this._checkNodeType(dst, secondType, 'object'); + } + } + } + + /** + * The partial-graph requirements of an evidence relation: the declared + * injectable facts it depends on. + */ + requiredFacts(relation) { + const deps = this.dependsOn.get(relation); + if (!deps) return []; + const required = []; + for (const dep of deps) { + const meta = this.relations.get(dep); + if (meta && meta.kind === 'fact' && meta.injectable) required.push(dep); + } + return required; + } + + // --------------------------------------------------------------------------- + // DSL-informed check + // --------------------------------------------------------------------------- + + /** + * Validate a check request against the DSL schema, derive and retrieve the + * evidence's injectable facts, inject them into a partial graph, and delegate + * to the arbiter. + * + * @param {string} user - subject key + * @param {string} relation - evidence (or fact) relation name + * @param {string} object - object key + * @param {object} options + * @param {object} options.partialGraph - caller-supplied partial graph edges + * ({ relations: [{ src, relation, dst, possibility, value }], nodes, challenges }) + * @param {object} options.factProviders - per-call provider overrides + * @returns {object} core check result extended with { requiredFacts, providedFacts, missingFacts } + */ + async check(user, relation, object, options = {}) { + const meta = this.relations.get(relation); + if (!meta) { + if (this.strictTypes) throw new Error(`DSLRuntime: unknown relation '${relation}'`); + } else if (meta.kind === 'evidence') { + if (meta.params.length === 2) { + this._checkNodeType(user, meta.params[0].type, 'subject'); + this._checkNodeType(object, meta.params[1].type, 'object'); + } + } + + const required = this.requiredFacts(relation); + const providers = options.factProviders || this.factProviders; + const injectedRelations = []; + const missingFacts = []; + const partialRelations = []; + + if (options.partialGraph && Array.isArray(options.partialGraph.relations)) { + partialRelations.push(...options.partialGraph.relations); + } + + for (const fact of required) { + const factMeta = this.relations.get(fact); + const provider = providers[fact]; + let result = null; + let error = null; + if (typeof provider === 'function') { + try { + result = await provider(user, object, { relation: fact, params: factMeta.params, runtime: this, options }); + } catch (err) { + error = err; + } + } + if (error) { + missingFacts.push({ relation: fact, reason: error.message }); + continue; + } + if (result === false || result === null || result === undefined) { + missingFacts.push({ relation: fact, reason: 'not_provided' }); + continue; + } + const edges = Array.isArray(result) ? result : [result]; + // Resolve the edge destination the same way the DSL declares the fact: + // - unary fact (1 param) -> self-edge on the subject + // - value fact (2nd param value) -> self-edge on the subject carrying the value + // - binary entity fact -> subject → object + const secondParamType = factMeta.params[1] && factMeta.params[1].type; + const defaultDst = factMeta.params.length >= 2 && this._isPrimitive(secondParamType) + ? user + : (factMeta.params.length >= 2 ? object : user); + for (const edge of edges) { + const normalized = typeof edge === 'boolean' || typeof edge === 'number' + ? { src: user, dst: defaultDst, possibility: edge === true ? 1 : edge } + : { + src: edge.src ?? user, + dst: edge.dst ?? defaultDst, + possibility: edge.possibility ?? 1, + ...(edge.value !== undefined ? { value: edge.value } : {}), + ...(edge.reliability !== undefined ? { reliability: edge.reliability } : {}) + }; + partialRelations.push({ relation: fact, ...normalized }); + } + injectedRelations.push({ relation: fact, edges: edges.length }); + } + + const checkOptions = { ...options }; + if (partialRelations.length > 0) { + checkOptions.partialGraph = { + ...(options.partialGraph || {}), + relations: partialRelations + }; + } + + const result = this.arbiter.check(user, relation, object, checkOptions); + + return { + ...result, + requiredFacts: required, + providedFacts: injectedRelations.map(r => r.relation), + missingFacts + }; + } +} diff --git a/src/validation/DSLValidation.js b/src/validation/DSLValidation.js index 465feee..2ddcc32 100644 --- a/src/validation/DSLValidation.js +++ b/src/validation/DSLValidation.js @@ -144,6 +144,7 @@ function validateDefinitions(program, tables, errors, warnings, source) { } function validateFacts(program, tables, errors, warnings, source) { + const seen = new Map(); for (const fact of program.facts || []) { if (tables.builtins?.facts?.has(fact.name)) { errors.push(createError({ @@ -154,6 +155,16 @@ function validateFacts(program, tables, errors, warnings, source) { context: formatContext(source, findLocation(source, fact.name)) })); } + if (seen.has(fact.name)) { + errors.push(createError({ + message: `Duplicate fact definition '${fact.name}'.`, + rule: 'Each fact name must be unique within a program.', + fix: 'Rename one of the fact definitions to a unique name.', + location: findLocation(source, `fact ${fact.name}`), + context: formatContext(source, findLocation(source, fact.name)) + })); + } + seen.set(fact.name, fact); const arity = fact.params ? fact.params.length : 0; if (!fact.params || arity === 0) { warnings.push(createError({ @@ -285,6 +296,7 @@ function validateMeasures(program, tables, errors, warnings, source) { } function validateEvidence(program, tables, errors, warnings, source) { + const seen = new Map(); for (const ev of program.evidence || []) { if (tables.builtins?.evidence?.has(ev.name)) { errors.push(createError({ @@ -295,6 +307,16 @@ function validateEvidence(program, tables, errors, warnings, source) { context: formatContext(source, findLocation(source, ev.name)) })); } + if (seen.has(ev.name)) { + errors.push(createError({ + message: `Duplicate evidence definition '${ev.name}'.`, + rule: 'Each evidence name must be unique within a program.', + fix: 'Rename one of the evidence definitions to a unique name.', + location: findLocation(source, `evidence ${ev.name}`), + context: formatContext(source, findLocation(source, ev.name)) + })); + } + seen.set(ev.name, ev); const returnType = ev.provides || DEFAULT_EVIDENCE_RETURN; if (returnType !== DEFAULT_EVIDENCE_RETURN && !isTypeKnown(returnType, tables)) { errors.push(createError({ diff --git a/tests/DSLRuntime.test.js b/tests/DSLRuntime.test.js new file mode 100644 index 0000000..16215a4 --- /dev/null +++ b/tests/DSLRuntime.test.js @@ -0,0 +1,164 @@ +/** + * tests/DSLRuntime.test.js — higher-order DSL+Core wrapper. + * + * Covers: + * - schema indexing (types, relations, injectable facts, dependency graph) + * - typed inserts/updates (addNode / updateNodeData / addRelation / updateRelation) + * reject unknown types, wrong node types, and mistyped field values + * - DSL-informed check: derives partial-graph requirements, retrieves missing + * injectable facts through providers, injects them, and delegates + * - missing-fact reporting + * + * NOTE: referencing a derived evidence relation as a sub-rule of another rule + * (e.g. `WHEN can_read(user, doc)` where can_read is an evidence) lowers to a + * direct edge lookup and does NOT re-derive the evidence's config. Evidence + * composition across rules is a documented gap (use fusion or facts). + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Arbiter } from '@arbiter/core'; +import { DSLRuntime } from '../src/runtime/DSLRuntime.js'; + +const BASE_DSL = ` + definition Employee { id: string level: number active: boolean } + definition Group { id: string } + definition Doc { id: string } + fact member_of(user: Employee, group: Group) + fact *owns(user: Employee, doc: Doc) + fact *user_score(user: Employee, value: number) + fact *granted(user: Employee, doc: Doc) + fact can_access(group: Group, doc: Doc) + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } + evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } } + evidence can_borrow(user: Employee, doc: Doc) { WHEN granted(user, doc) UNLESS user_score(user, 1) } +`; + +function makeRuntime() { + return new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-test'); +} + +describe('DSLRuntime', () => { + it('indexes the DSL schema', () => { + const rt = makeRuntime(); + assert.ok(rt.types.has('Employee')); + assert.equal(rt.types.get('Employee').fields.get('level').type, 'number'); + assert.equal(rt.relations.get('owns').kind, 'fact'); + assert.equal(rt.relations.get('owns').injectable, true); + assert.equal(rt.relations.get('member_of').injectable, false); + assert.equal(rt.relations.get('can_read').kind, 'evidence'); + assert.deepEqual(rt.requiredFacts('can_read'), ['owns']); + }); + + it('validates typed node inserts', () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', { level: 3, active: true }); + assert.throws(() => rt.addNode('g:1', 'Ghost', {}), /unknown type/); + assert.throws(() => rt.addNode('u:2', 'Employee', { level: 'high' }), /must be number/); + assert.throws(() => rt.addNode('u:3', 'Employee', { active: 'yes' }), /must be boolean/); + }); + + it('validates node updates against the declared type', () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', { level: 3, active: true }); + rt.updateNodeData('u:1', { level: 5 }); + assert.throws(() => rt.updateNodeData('u:1', { level: 'x' }), /must be number/); + }); + + it('validates relation endpoints against declared param types', () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('g:1', 'Group', {}); + rt.addNode('doc:9', 'Doc', {}); + rt.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); + assert.throws(() => rt.addRelation('u:1', 'member_of', 'doc:9', {}), /expected 'Group'/); + assert.throws(() => rt.addRelation('u:1', 'ghost_relation', 'g:1', {}), /unknown relation/); + // value-param fact: second param is a number value, dst must be the subject + rt.addRelation('u:1', 'user_score', 'u:1', { possibility: 1.0, value: 5 }); + assert.throws(() => rt.addRelation('u:1', 'user_score', 'g:1', { possibility: 1.0, value: 5 }), /self-edge/); + assert.throws(() => rt.addRelation('u:1', 'user_score', 'u:1', { possibility: 1.0, value: 'high' }), /must be number/); + }); + + it('updateRelation validates and replaces', () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('g:1', 'Group', {}); + rt.addNode('doc:9', 'Doc', {}); + rt.addRelation('u:1', 'member_of', 'g:1', { possibility: 0.5 }); + rt.updateRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); + assert.equal(rt.arbiter.check('u:1', 'member_of', 'g:1').possibility, 1.0); + assert.throws(() => rt.updateRelation('u:1', 'member_of', 'doc:9', {}), /expected 'Group'/); + }); + + it('DSL-informed check retrieves injectable facts via providers', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + const res = await rt.check('u:1', 'can_read', 'doc:9', { + factProviders: { owns: async () => 0.8 } + }); + assert.equal(res.possibility, 0.8); + assert.equal(res.reason, 'allow_rule_matched'); + assert.deepEqual(res.requiredFacts, ['owns']); + assert.deepEqual(res.providedFacts, ['owns']); + assert.deepEqual(res.missingFacts, []); + }); + + it('reports missing facts when a provider declines', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + const res = await rt.check('u:1', 'can_read', 'doc:9', { + factProviders: { owns: async () => null } + }); + assert.equal(res.possibility, 0); + assert.deepEqual(res.missingFacts, [{ relation: 'owns', reason: 'not_provided' }]); + }); + + it('merges caller-supplied partial graphs with provider results', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + const res = await rt.check('u:1', 'can_read', 'doc:9', { + partialGraph: { relations: [{ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: 1.0 }] }, + factProviders: { owns: async () => null } + }); + assert.equal(res.possibility, 1.0); + }); + + it('unary condition inside binary evidence (subject-as-object) defeats the grant', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + // can_borrow: WHEN granted(user, doc) UNLESS user_score(user, 1). + // user_score is injectable+unary; the provider injects a user self-edge + // with value 1 -> the unless fires and defeats the grant. + const res = await rt.check('u:1', 'can_borrow', 'doc:9', { + factProviders: { + granted: async () => 0.9, + user_score: async () => ({ possibility: 1.0, value: 1 }) + } + }); + assert.equal(res.possibility, 0); + assert.equal(res.reason, 'defeated_by_unless'); + assert.deepEqual(res.requiredFacts, ['granted', 'user_score']); + }); + + it('chain evidence across an intermediate validates and checks', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('g:1', 'Group', {}); + rt.addNode('doc:9', 'Doc', {}); + rt.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); + rt.addRelation('g:1', 'can_access', 'doc:9', { possibility: 0.7 }); + // can_enter: member_of(user, *g) { can_access(g, doc) } — chain [member_of, can_access] + const res = await rt.check('u:1', 'can_enter', 'doc:9', {}); + assert.equal(res.possibility, 0.7); + }); + + it('rejects checks against unknown relations in strict mode', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + await assert.rejects(() => rt.check('u:1', 'does_not_exist', 'doc:9'), /unknown relation/); + }); +}); diff --git a/tests/rigor/dsl-generative-oracle.test.js b/tests/rigor/dsl-generative-oracle.test.js new file mode 100644 index 0000000..bcc63eb --- /dev/null +++ b/tests/rigor/dsl-generative-oracle.test.js @@ -0,0 +1,190 @@ +/** + * 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 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; + } + default: + throw new Error(`unknown construct: ${kind}`); + } + + return { dsl: FACTS + evidence, edges, oracle, relation: evidence.match(/evidence (\w+)/)[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']; + +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}` + ); + } + } + } + }); +}); diff --git a/tests/rigor/dsl-illegal-mutations.test.js b/tests/rigor/dsl-illegal-mutations.test.js new file mode 100644 index 0000000..4671422 --- /dev/null +++ b/tests/rigor/dsl-illegal-mutations.test.js @@ -0,0 +1,138 @@ +/** + * tests/rigor/dsl-illegal-mutations.test.js — js-rigor campaign that takes a + * valid Evidence DSL program and applies ONE subtle flaw to produce illegal + * DSL, asserting the compiler reliably REJECTS each mutation. + * + * Each mutation perturbs a single construct (swapped arg types, unknown fact, + * arity mismatch, reserved built-in type, duplicate evidence, unterminated + * block, malformed parameter list, type mismatch across params). A lowering or + * validation bug that silently accepted structurally-broken DSL would fail the + * invariant. + * + * Anti-vacuity: the `valid` mutation is the untouched DSL and MUST compile — + * proving the harness is not trivially rejecting everything. + */ +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 VALID_DSL = ` + definition Employee { id: string } + definition Group { id: string } + definition Doc { id: string } + fact owns(user: Employee, doc: Doc) + fact member_of(user: Employee, group: Group) + fact can_access(group: Group, doc: Doc) + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } + evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } } +`; + +// Each mutation transforms the valid DSL into an illegal variant. +// `mustFail: false` marks the control mutation (untouched DSL — must compile). +const MUTATIONS = { + valid: { + desc: 'control (untouched DSL must compile)', + mustFail: false, + apply: () => VALID_DSL + }, + swapped_arg_types: { + desc: 'swapped subject/object argument types', + mustFail: true, + apply: () => VALID_DSL.replace('fact owns(user: Employee, doc: Doc)', 'fact owns(doc: Doc, user: Employee)') + }, + undefined_fact: { + desc: 'references an undeclared fact', + mustFail: true, + apply: () => VALID_DSL.replace('{ owns(user, doc) }', '{ ghost(user, doc) }') + }, + arity_mismatch: { + desc: 'wrong argument arity on a binary fact', + mustFail: true, + apply: () => VALID_DSL.replace('{ owns(user, doc) }', '{ owns(user) }') + }, + reserved_builtin_type: { + desc: 'redefines a reserved built-in type', + mustFail: true, + apply: () => VALID_DSL.replace('definition Employee { id: string }', 'definition User { id: string }') + }, + duplicate_evidence: { + desc: 'duplicate evidence relation name', + mustFail: true, + apply: () => VALID_DSL + `\n evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }` + }, + unterminated_block: { + desc: 'missing closing brace', + mustFail: true, + apply: () => VALID_DSL.replace('{ owns(user, doc) }', '{ owns(user, doc)') + }, + malformed_params: { + desc: 'malformed parameter list (missing comma)', + mustFail: true, + apply: () => VALID_DSL.replace('owns(user: Employee, doc: Doc)', 'owns(user: Employee doc: Doc)') + }, + type_mismatch_arg: { + desc: 'passes an Employee where a Group is required', + mustFail: true, + apply: () => VALID_DSL.replace('{ can_access(g, doc) }', '{ can_access(user, doc) }') + }, + wrong_evidence_arity: { + 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) }') + } +}; + +function checkMutation(mutationName) { + const mutation = MUTATIONS[mutationName]; + if (!mutation) throw new Error(`unknown mutation name: ${JSON.stringify(mutationName)}`); + const dsl = mutation.apply(); + const arbiter = new Arbiter(); + const compiler = new DSLCompiler(arbiter); + const result = compiler.compile(dsl, `mut-${mutationName}`); + const success = result.success; + const errors = result.errors || []; + if (mutation.mustFail) { + if (success || errors.length === 0) { + throw new Error(`mutation '${mutationName}' was NOT rejected (${mutation.desc}). ` + + `success=${success}, errors=${JSON.stringify(errors)}`); + } + } else if (!success) { + throw new Error(`control mutation '${mutationName}' should compile but failed: ${JSON.stringify(errors)}`); + } + return { mutationName, ok: true }; +} + +describe('DSL illegal-mutation rejection (rigor)', () => { + it('every subtle one-flaw mutation is reliably rejected; the control compiles', async () => { + const report = await rigor.campaign( + [ + rigor.fn('reject-mutation', checkMutation, rigor.args( + rigor.gen.oneOf(Object.keys(MUTATIONS)) + )) + ], + rigor.crucible([ + // `actual` is the fn's return; a contract violation (a must-fail + // mutation that compiled, a control that failed, or an unknown name) + // throws → actual undefined → this invariant fails. + rigor.invariant('rejection-contract', ({ actual }) => + !!actual && actual.ok === true) + ]) + ).run({ seed: 'dsl-illegal-mutations', effort: 400, artifacts: { dir: '', persist: 'never' } }); + + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'rejection-contract'); + assert.ok(inv, 'crucible invariant missing'); + assert.equal(inv.passed, true, `rejection contract violated in ${inv.failureCount} cases`); + }); + + it('every mutation kind is exercised (no vacuous pass)', () => { + const seen = new Set(); + for (const name of Object.keys(MUTATIONS)) { + // deterministic probe of each kind + seen.add(name); + checkMutation(name); + } + assert.equal(seen.size, Object.keys(MUTATIONS).length); + }); +});