diff --git a/package.json b/package.json index cfa9f4f..3ba7c63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.6.0", + "version": "1.7.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/grammar/dsl.peggy b/src/grammar/dsl.peggy index 8bf560a..9e6381a 100644 --- a/src/grammar/dsl.peggy +++ b/src/grammar/dsl.peggy @@ -248,6 +248,13 @@ BehaviorAnnotation = "BEHAVES" __ "AS" __ behavior:("edge" / "transitive" / "hierarchical" / "symmetrical_graph") { return { type: "BehaviorAnnotation", behavior }; } + / "BEHAVES" __ "{" _ behavior:(TTLBehavior) _ "}" { + // Fact-level freshness: `fact balance(user, amount) BEHAVES { ttl 1h }` + // declares the relation's value-freshness window, which the runtime uses + // as the provider-result cache TTL. The behavior is wrapped like the + // `BEHAVES AS` form so consumers read `behavior.behaviorType`. + return { type: "BehaviorAnnotation", behavior }; + } FactProperty = "transitive" { return "transitive"; } diff --git a/src/parser/GeneratedParser.js b/src/parser/GeneratedParser.js index e61da22..c32418d 100644 --- a/src/parser/GeneratedParser.js +++ b/src/parser/GeneratedParser.js @@ -490,39 +490,46 @@ function peg$parse(input, options) { function peg$f29(behavior) { return { type: "BehaviorAnnotation", behavior }; } - function peg$f30() { return "transitive"; } - function peg$f31() { return "symmetrical"; } - function peg$f32(value) { return value; } - function peg$f33(b) { return b; } - function peg$f34(direction, period) { + function peg$f30(behavior) { + // Fact-level freshness: `fact balance(user, amount) BEHAVES { ttl 1h }` + // declares the relation's value-freshness window, which the runtime uses + // as the provider-result cache TTL. The behavior is wrapped like the + // `BEHAVES AS` form so consumers read `behavior.behaviorType`. + return { type: "BehaviorAnnotation", behavior }; + } + function peg$f31() { return "transitive"; } + function peg$f32() { return "symmetrical"; } + function peg$f33(value) { return value; } + function peg$f34(b) { return b; } + function peg$f35(direction, period) { return { type: "Behavior", behaviorType: "decay", direction, period }; } - function peg$f35(mode, confidence) { + function peg$f36(mode, confidence) { return { type: "Behavior", behaviorType: "blur", mode, confidence: confidence ? confidence[1] : null }; } - function peg$f36(duration) { + function peg$f37(duration) { return { type: "Behavior", behaviorType: "ttl", duration }; } - function peg$f37(directive) { return directive; } - function peg$f38(head, tail) { return buildLeftAssoc(head, tail); } + function peg$f38(directive) { return directive; } function peg$f39(head, tail) { return buildLeftAssoc(head, tail); } - function peg$f40(head, typeName) { + function peg$f40(head, tail) { return buildLeftAssoc(head, tail); } + function peg$f41(head, typeName) { return { type: "BinaryExpression", operator: "is", left: head, right: typeName }; } - function peg$f41(head, tail) { return buildLeftAssoc(head, tail); } - function peg$f42(head, right) { + function peg$f42(head, tail) { return buildLeftAssoc(head, tail); } + function peg$f43(head, right) { return { type: "BinaryExpression", operator: "within", left: head, right }; } - function peg$f43(head, tail) { return buildLeftAssoc(head, tail); } function peg$f44(head, tail) { return buildLeftAssoc(head, tail); } - function peg$f45(operator, operand) { return { type: "UnaryExpression", operator: "NOT", operand }; } - function peg$f46(primary, binding) { + function peg$f45(head, tail) { return buildLeftAssoc(head, tail); } + function peg$f46(operator, operand) { return { type: "UnaryExpression", operator: "NOT", operand }; } + function peg$f47(primary, binding) { if (binding) { return { type: "BindingAccess", expression: primary, binding }; } return primary; } - function peg$f47(head, tail) { + function peg$f48(head, tail) { return tail.reduce((obj, part) => { return { type: "AttributeAccess", @@ -532,31 +539,31 @@ function peg$parse(input, options) { }; }, head); } - function peg$f48(expr) { return expr; } - function peg$f49(name, args) { + function peg$f49(expr) { return expr; } + function peg$f50(name, args) { return { type: "PredicateCall", name, args: args || [], challenge: true }; } - function peg$f50(name, args) { + function peg$f51(name, args) { return { type: "PredicateCall", name, args: args || [] }; } - function peg$f51(name) { return { type: "Variable", name }; } - function peg$f52(head, tail) { - return [head, ...tail.map(t => t[3])]; - } + function peg$f52(name) { return { type: "Variable", name }; } function peg$f53(head, tail) { return [head, ...tail.map(t => t[3])]; } - function peg$f54(chars) { - return { type: "Literal", value: JSON.parse(text()) }; + function peg$f54(head, tail) { + return [head, ...tail.map(t => t[3])]; } function peg$f55(chars) { + return { type: "Literal", value: JSON.parse(text()) }; + } + function peg$f56(chars) { return { type: "Literal", value: JSON.parse("\"" + chars.map(c => c[0] === '\\' ? c[1] : c[1]).join('') + "\"") }; } - function peg$f56(value) { return { type: "Literal", value: parseFloat(text()) }; } - function peg$f57(value) { return { type: "Literal", value: parseInt(text(), 10) }; } - function peg$f58(value) { return { type: "Literal", value: value === "true" }; } - function peg$f59(value) { return { type: "Literal", value: text(), unit: text().slice(-1) }; } - function peg$f60(name) { return name; } + function peg$f57(value) { return { type: "Literal", value: parseFloat(text()) }; } + function peg$f58(value) { return { type: "Literal", value: parseInt(text(), 10) }; } + function peg$f59(value) { return { type: "Literal", value: value === "true" }; } + function peg$f60(value) { return { type: "Literal", value: text(), unit: text().slice(-1) }; } + function peg$f61(name) { return name; } let peg$currPos = options.peg$currPos | 0; let peg$savedPos = peg$currPos; const peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -2456,7 +2463,7 @@ function peg$parse(input, options) { } function peg$parseBehaviorAnnotation() { - let s0, s1, s2, s3, s4, s5; + let s0, s1, s2, s3, s4, s5, s6, s7; s0 = peg$currPos; if (input.substr(peg$currPos, 7) === peg$c28) { @@ -2536,6 +2543,61 @@ function peg$parse(input, options) { peg$currPos = s0; s0 = peg$FAILED; } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 7) === peg$c28) { + s1 = peg$c28; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e34); } + } + if (s1 !== peg$FAILED) { + s2 = peg$parse__(); + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 123) { + s3 = peg$c2; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + s5 = peg$parseTTLBehavior(); + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 125) { + s7 = peg$c3; + peg$currPos++; + } else { + s7 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s7 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f30(s5); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } return s0; } @@ -2553,7 +2615,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(); + s1 = peg$f31(); } s0 = s1; if (s0 === peg$FAILED) { @@ -2567,7 +2629,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(); + s1 = peg$f32(); } s0 = s1; } @@ -2592,7 +2654,7 @@ function peg$parse(input, options) { s3 = peg$parseInteger(); if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f32(s3); + s0 = peg$f33(s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2650,7 +2712,7 @@ function peg$parse(input, options) { } if (s7 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f33(s5); + s0 = peg$f34(s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2762,7 +2824,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f34(s3, s5); + s0 = peg$f35(s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2870,7 +2932,7 @@ function peg$parse(input, options) { s4 = null; } peg$savedPos = s0; - s0 = peg$f35(s3, s4); + s0 = peg$f36(s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2904,7 +2966,7 @@ function peg$parse(input, options) { s3 = peg$parseDuration(); if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f36(s3); + s0 = peg$f37(s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -2953,7 +3015,7 @@ function peg$parse(input, options) { } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f37(s3); + s0 = peg$f38(s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3027,7 +3089,7 @@ function peg$parse(input, options) { } } peg$savedPos = s0; - s0 = peg$f38(s1, s2); + s0 = peg$f39(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3093,7 +3155,7 @@ function peg$parse(input, options) { } } peg$savedPos = s0; - s0 = peg$f39(s1, s2); + s0 = peg$f40(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3122,7 +3184,7 @@ function peg$parse(input, options) { s5 = peg$parseTypeName(); if (s5 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f40(s1, s5); + s0 = peg$f41(s1, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3266,7 +3328,7 @@ function peg$parse(input, options) { } } peg$savedPos = s0; - s0 = peg$f41(s1, s2); + s0 = peg$f42(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3296,7 +3358,7 @@ function peg$parse(input, options) { s5 = peg$parseDuration(); if (s5 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f42(s1, s5); + s0 = peg$f43(s1, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3377,7 +3439,7 @@ function peg$parse(input, options) { } } peg$savedPos = s0; - s0 = peg$f43(s1, s2); + s0 = peg$f44(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3443,7 +3505,7 @@ function peg$parse(input, options) { } } peg$savedPos = s0; - s0 = peg$f44(s1, s2); + s0 = peg$f45(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3478,7 +3540,7 @@ function peg$parse(input, options) { s3 = peg$parseUnary(); if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f45(s1, s3); + s0 = peg$f46(s1, s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3512,7 +3574,7 @@ function peg$parse(input, options) { s2 = null; } peg$savedPos = s0; - s0 = peg$f46(s1, s2); + s0 = peg$f47(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3583,7 +3645,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f47(s1, s2); + s0 = peg$f48(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3630,7 +3692,7 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f48(s3); + s0 = peg$f49(s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3694,7 +3756,7 @@ function peg$parse(input, options) { } if (s8 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f49(s2, s6); + s0 = peg$f50(s2, s6); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3744,7 +3806,7 @@ function peg$parse(input, options) { } if (s6 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f50(s1, s4); + s0 = peg$f51(s1, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3768,7 +3830,7 @@ function peg$parse(input, options) { s1 = peg$parseIdentifier(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f51(s1); + s1 = peg$f52(s1); } s0 = s1; @@ -3832,7 +3894,7 @@ function peg$parse(input, options) { } } peg$savedPos = s0; - s0 = peg$f52(s1, s2); + s0 = peg$f53(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3898,7 +3960,7 @@ function peg$parse(input, options) { } } peg$savedPos = s0; - s0 = peg$f53(s1, s2); + s0 = peg$f54(s1, s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4083,7 +4145,7 @@ function peg$parse(input, options) { } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f54(s2); + s0 = peg$f55(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4245,7 +4307,7 @@ function peg$parse(input, options) { } if (s3 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f55(s2); + s0 = peg$f56(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4340,7 +4402,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f56(s1); + s1 = peg$f57(s1); } s0 = s1; peg$silentFails--; @@ -4381,7 +4443,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f57(s1); + s1 = peg$f58(s1); } s0 = s1; peg$silentFails--; @@ -4416,7 +4478,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f58(s1); + s1 = peg$f59(s1); } s0 = s1; peg$silentFails--; @@ -4477,7 +4539,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f59(s1); + s1 = peg$f60(s1); } s0 = s1; peg$silentFails--; @@ -4545,7 +4607,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f60(s2); + s0 = peg$f61(s2); } else { peg$currPos = s0; s0 = peg$FAILED; diff --git a/src/runtime/DSLRuntime.js b/src/runtime/DSLRuntime.js index 0856404..9053092 100644 --- a/src/runtime/DSLRuntime.js +++ b/src/runtime/DSLRuntime.js @@ -47,8 +47,21 @@ export class DSLRuntime { 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.relations = new Map(); // relation -> { kind: 'fact'|'evidence', params, injectable, ttlMs } this.dependsOn = new Map(); // evidence relation -> Set(fact relations) + + // Provider-result cache: relation|subject|object -> { edges, fetchedAt }. + // Provider retrieval is a data-store read (balance lookups, session + // checks, etc.) — caching results with a time expiry avoids hammering the + // underlying store on every check. The clock is injectable (default wall + // clock) and drives cache freshness, mirroring the core's unpinned-clock + // contract. + this.providerCache = new Map(); + this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now()); + // Default provider-result TTL in ms (0 disables caching). + this.defaultProviderCacheTTL = options.policy?.providerCacheTTL ?? options.providerCacheTTL ?? 30_000; + // Per-fact overrides (ms). DSL-declared ttl behaviors are indexed here too. + this.factTTLs = new Map(Object.entries(options.factTTLs || {})); } /** @@ -119,10 +132,12 @@ export class DSLRuntime { } for (const fact of this.program.facts || []) { + const ttlMs = this._ttlFromBehavior(fact.behavior); this.relations.set(fact.name, { kind: 'fact', params: (fact.params || []).map(p => ({ name: p.name, type: p.paramType, isArray: !!p.isArray })), - injectable: !!fact.injectable + injectable: !!fact.injectable, + ttlMs }); } @@ -196,12 +211,15 @@ export class DSLRuntime { throw new Error(`DSLRuntime: provider for '${relation}' must be a function`); } this.factProviders[relation] = provider; + // A new provider supersedes any cached retrieval for this fact. + this.invalidateProviderCache(relation); return this; } /** Remove a registered provider. */ unregisterFact(relation) { delete this.factProviders[relation]; + this.invalidateProviderCache(relation); return this; } @@ -210,6 +228,109 @@ export class DSLRuntime { return Object.keys(this.factProviders); } + // --------------------------------------------------------------------------- + // Provider-result caching + // --------------------------------------------------------------------------- + + /** + * Set a per-fact provider-result TTL (ms). Overrides the policy default and + * the DSL-declared ttl behavior for that fact. + */ + setFactTTL(relation, ms) { + this.factTTLs.set(relation, ms); + return this; + } + + /** The effective provider-result TTL (ms) for a fact: DSL > per-fact > policy default. */ + _ttlFor(relation) { + if (this.factTTLs.has(relation)) return this.factTTLs.get(relation); + const meta = this.relations.get(relation); + if (meta && meta.ttlMs != null) return meta.ttlMs; + return this.defaultProviderCacheTTL; + } + + /** + * Invalidate cached provider results — all, or for a single relation. + * Callers use this when the underlying data store changes out-of-band. + */ + invalidateProviderCache(relation) { + if (relation === undefined) { + this.providerCache.clear(); + return this; + } + const prefix = `${relation}\u0000`; + for (const key of [...this.providerCache.keys()]) { + if (key.startsWith(prefix)) this.providerCache.delete(key); + } + return this; + } + + _providerCacheKey(relation, subject, object) { + return `${relation}\u0000${subject}\u0000${object}`; + } + + _providerCacheGet(relation, subject, object) { + const ttl = this._ttlFor(relation); + if (ttl <= 0) return null; + const entry = this.providerCache.get(this._providerCacheKey(relation, subject, object)); + if (!entry) return null; + if (this.clock() - entry.fetchedAt >= ttl) { + this.providerCache.delete(this._providerCacheKey(relation, subject, object)); + return null; + } + return entry; + } + + _providerCacheSet(relation, subject, object, edges) { + const ttl = this._ttlFor(relation); + if (ttl <= 0) return; + this.providerCache.set(this._providerCacheKey(relation, subject, object), { + edges, + fetchedAt: this.clock() + }); + } + + /** Convert a DSL `BEHAVES { ttl }` behavior (or `BEHAVES AS`) into ms. */ + _ttlFromBehavior(behavior) { + if (!behavior || typeof behavior !== 'object') return null; + const b = behavior.behavior || behavior; + if (b && b.behaviorType === 'ttl' && b.duration) { + const n = parseInt(String(b.duration.value), 10); + const mult = { h: 3600_000, d: 86_400_000, w: 604_800_000, m: 60_000 }[b.duration.unit]; + if (!Number.isNaN(n) && mult) return n * mult; + } + return null; + } + + /** + * Normalize a provider result (boolean / number / { possibility, value } / + * array of edge objects) into an array of partial-graph edge objects. The + * destination follows the DSL fact's declared shape: unary and value-carrying + * facts are self-edges on the subject; binary entity facts go subject → object. + */ + _normalizeProviderEdges(result, factMeta, user, object) { + const edges = Array.isArray(result) ? result : [result]; + const secondParamType = factMeta.params[1] && factMeta.params[1].type; + const defaultDst = factMeta.params.length >= 2 && this._isValueType(secondParamType) + ? user + : (factMeta.params.length >= 2 ? object : user); + const out = []; + for (const edge of edges) { + const normalized = typeof edge === 'boolean' || typeof edge === 'number' + ? { src: user, dst: defaultDst, possibility: edge === true ? 1 : edge } + : { + ...(edge.relation ? { relation: edge.relation } : {}), + src: edge.src ?? user, + dst: edge.dst ?? defaultDst, + possibility: edge.possibility ?? 1, + ...(edge.value !== undefined ? { value: edge.value } : {}), + ...(edge.reliability !== undefined ? { reliability: edge.reliability } : {}) + }; + out.push(normalized); + } + return out; + } + // --------------------------------------------------------------------------- // Schema validation helpers // --------------------------------------------------------------------------- @@ -283,6 +404,8 @@ export class DSLRuntime { } else if (this.strictTypes) { throw new Error(`DSLRuntime: unknown type '${typeName}'`); } + // A graph mutation can make previously-retrieved facts stale. + this.invalidateProviderCache(); return this.arbiter.addNode(key, typeName, data); } @@ -297,11 +420,13 @@ export class DSLRuntime { if (data[name] !== undefined) this._checkFieldValue(field, data[name], `${typeName}.${name}`); } } + this.invalidateProviderCache(); return this.arbiter.updateNodeData(key, data); } /** Remove a node (passthrough to the arbiter's node manager). */ removeNode(key) { + this.invalidateProviderCache(); if (this.arbiter.nodeManager && typeof this.arbiter.nodeManager.removeNode === 'function') { return this.arbiter.nodeManager.removeNode(key); } @@ -327,6 +452,7 @@ export class DSLRuntime { if (meta) { this._validateRelationEndpoints(relation, meta, src, dst, attrs); } + this.invalidateProviderCache(); return this.arbiter.addRelation(src, relation, dst, attrs); } @@ -338,12 +464,14 @@ export class DSLRuntime { if (meta) { this._validateRelationEndpoints(relation, meta, src, dst, attrs); } + this.invalidateProviderCache(); this.arbiter.removeRelation(src, relation, dst); return this.arbiter.addRelation(src, relation, dst, attrs); } /** Remove a relation edge (passthrough to the arbiter). */ removeRelation(src, relation, dst) { + this.invalidateProviderCache(); return this.arbiter.removeRelation(src, relation, dst); } @@ -453,62 +581,60 @@ export class DSLRuntime { if (satisfied.has(fact)) continue; const factMeta = this.relations.get(fact); const provider = providers[fact]; - if (typeof provider !== 'function') { + + // Provider-result cache: reuse fresh edges without re-invoking the + // data store. A cached entry stores the NORMALIZED edges. Per-check + // provider overrides are one-off observations — they bypass the cache + // entirely (no read, no write) so a fresh override is never masked by + // a cached registered-provider result, nor does it pollute the cache. + const isPerCheckOverride = !!(options.factProviders && fact in options.factProviders); + const cacheHit = isPerCheckOverride ? null : this._providerCacheGet(fact, user, object); + let edges = null; + let fromCache = false; + if (cacheHit) { + edges = cacheHit.edges; + fromCache = true; + } else if (typeof provider === 'function') { + let result = null; + let error = null; + try { + result = await provider(user, object, { + relation: fact, + params: factMeta.params, + runtime: this, + options, + round, + alreadyInjected: [...satisfied] + }); + } catch (err) { + error = err; + } + if (error) { + missingFacts.push({ relation: fact, reason: error.message }); + satisfied.add(fact); + continue; + } + if (result === false || result === null || result === undefined) { + missingFacts.push({ relation: fact, reason: 'not_provided' }); + satisfied.add(fact); + continue; + } + edges = this._normalizeProviderEdges(result, factMeta, user, object); + if (!isPerCheckOverride) this._providerCacheSet(fact, user, object, edges); + } else { missingFacts.push({ relation: fact, reason: 'no_provider' }); satisfied.add(fact); continue; } - let result = null; - let error = null; - try { - result = await provider(user, object, { - relation: fact, - params: factMeta.params, - runtime: this, - options, - round, - alreadyInjected: [...satisfied] - }); - } catch (err) { - error = err; - } - if (error) { - missingFacts.push({ relation: fact, reason: error.message }); - satisfied.add(fact); - continue; - } - if (result === false || result === null || result === undefined) { - missingFacts.push({ relation: fact, reason: 'not_provided' }); - satisfied.add(fact); - 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._isValueType(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 } - : { - ...(edge.relation ? { relation: edge.relation } : {}), - src: edge.src ?? user, - dst: edge.dst ?? defaultDst, - possibility: edge.possibility ?? 1, - ...(edge.value !== undefined ? { value: edge.value } : {}), - ...(edge.reliability !== undefined ? { reliability: edge.reliability } : {}) - }; - // A provider may return edges for relations other than its own; the - // injected relation names satisfy those facts too (fixed point). + + // A provider may return edges for relations other than its own; the + // injected relation names satisfy those facts too (fixed point). + for (const normalized of edges) { const injectedRelation = normalized.relation ?? fact; partialRelations.push({ relation: injectedRelation, ...normalized }); satisfied.add(injectedRelation); } - injectedRelations.push({ relation: fact, edges: edges.length, round }); + injectedRelations.push({ relation: fact, edges: edges.length, round, cacheHit: fromCache }); newRelationsThisRound += edges.length; satisfied.add(fact); } diff --git a/tests/DSLRuntimeCache.test.js b/tests/DSLRuntimeCache.test.js new file mode 100644 index 0000000..330c3da --- /dev/null +++ b/tests/DSLRuntimeCache.test.js @@ -0,0 +1,176 @@ +/** + * tests/DSLRuntimeCache.test.js — provider-result caching with time expiry. + * + * Registered providers retrieve missing facts from a data store; caching the + * retrieval avoids hammering the store on repeated checks. TTL resolution: + * DSL-declared `BEHAVES { ttl }` on a fact > per-fact setFactTTL > + * policy default (30s). Per-check factProviders are cache-transparent (one-off + * observations: no cache read, no cache write). Registering a provider or + * mutating the graph invalidates the cache. + */ +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 } + definition Doc { id: string } + fact *owns(user: Employee, doc: Doc) + evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } +`; + +function makeRuntime(options = {}) { + let t = 0; + const clock = () => t; + const rt = new DSLRuntime(new Arbiter(), { clock, ...options }).compile(BASE_DSL, 'rt-cache'); + rt._test_advance = (ms) => { t += ms; }; + return rt; +} + +describe('DSLRuntime provider-result caching', () => { + it('reuses a registered provider result within the TTL', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + let calls = 0; + rt.registerFact('owns', async () => { calls++; return 0.9; }); + await rt.check('u:1', 'can_read', 'doc:9'); + await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(calls, 1, 'provider should be invoked once within TTL'); + }); + + it('re-invokes the provider after the TTL expires', async () => { + const rt = makeRuntime(); + rt.setFactTTL('owns', 100); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + let calls = 0; + let value = 0.9; + rt.registerFact('owns', async () => { calls++; return value; }); + const first = await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(first.possibility, 0.9); + rt._test_advance(50); + await rt.check('u:1', 'can_read', 'doc:9'); // within TTL -> cached + assert.equal(calls, 1); + rt._test_advance(60); // past TTL (110 total) + value = 0.4; + const after = await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(calls, 2); + assert.equal(after.possibility, 0.4); + }); + + it('per-check factProviders override the cache (fresh observation)', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + rt.registerFact('owns', async () => 0.9); + await rt.check('u:1', 'can_read', 'doc:9'); + // Per-check override is cache-transparent: it must NOT be masked by the + // cached 0.9, and it must NOT overwrite the cached value. + const over = await rt.check('u:1', 'can_read', 'doc:9', { + factProviders: { owns: async () => 0.2 } + }); + assert.equal(over.possibility, 0.2); + const next = await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(next.possibility, 0.9, 'registered provider cache untouched by per-check override'); + }); + + it('registerFact invalidates the cached result for that relation', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + rt.registerFact('owns', async () => 0.9); + await rt.check('u:1', 'can_read', 'doc:9'); + rt.registerFact('owns', async () => 0.3); // re-register -> cache invalidated + const res = await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(res.possibility, 0.3); + }); + + it('invalidates cached results on graph mutations', async () => { + const rt = makeRuntime(); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + let calls = 0; + rt.registerFact('owns', async () => { calls++; return 0.9; }); + await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(calls, 1); + rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 }); // mutation clears cache + const res = await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(calls, 2, 'graph mutation should invalidate the provider cache'); + }); + + it('invalidateProviderCache() clears all or per relation', 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-cache2'); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + let ownsCalls = 0, bannedCalls = 0; + rt.registerFact('owns', async () => { ownsCalls++; return 0.9; }); + rt.registerFact('banned', async () => { bannedCalls++; return 0; }); + await rt.check('u:1', 'can_open', 'doc:9'); + assert.equal(ownsCalls, 1); + assert.equal(bannedCalls, 1); + // Invalidate a non-dependency relation: can_open's cache (owns+banned) survives. + rt.invalidateProviderCache('does_not_exist'); + await rt.check('u:1', 'can_open', 'doc:9'); + assert.equal(ownsCalls, 1); + assert.equal(bannedCalls, 1); + // Invalidate owns only: banned survives, owns re-fetched. + rt.invalidateProviderCache('owns'); + await rt.check('u:1', 'can_open', 'doc:9'); + assert.equal(ownsCalls, 2, 'owns cache cleared by per-relation invalidation'); + assert.equal(bannedCalls, 1, 'banned cache survives per-relation invalidation'); + // Clear all. + rt.invalidateProviderCache(); + await rt.check('u:1', 'can_open', 'doc:9'); + assert.equal(bannedCalls, 2, 'full invalidation clears every relation'); + }); + + it('policy default TTL applies when no per-fact TTL is set', async () => { + const rt = makeRuntime({ policy: { providerCacheTTL: 50 } }); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + let calls = 0; + rt.registerFact('owns', async () => { calls++; return 0.9; }); + await rt.check('u:1', 'can_read', 'doc:9'); + rt._test_advance(40); + await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(calls, 1, 'within 50ms policy TTL -> cached'); + rt._test_advance(20); + await rt.check('u:1', 'can_read', 'doc:9'); + assert.equal(calls, 2, 'past 50ms policy TTL -> re-invoked'); + }); + + it('uses the DSL-declared fact TTL (BEHAVES { ttl X })', async () => { + const dsl = ` + definition Employee { id: string } + definition Doc { id: string } + fact *balance(user: Employee, amount: number) BEHAVES { ttl 1h } + evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) } + `; + let t = 0; + const rt = new DSLRuntime(new Arbiter(), { clock: () => t }).compile(dsl, 'rt-dsl-ttl'); + // The DSL declares a 1h TTL for the balance fact. + assert.equal(rt.relations.get('balance').ttlMs, 3600_000); + rt.addNode('u:1', 'Employee', {}); + rt.addNode('doc:9', 'Doc', {}); + let calls = 0; + rt.registerFact('balance', async () => { calls++; return { possibility: 1.0, value: 50 }; }); + await rt.check('u:1', 'can_spend', 'doc:9'); + assert.equal(calls, 1); + t += 60 * 60 * 1000 - 1; // just under 1h + await rt.check('u:1', 'can_spend', 'doc:9'); + assert.equal(calls, 1, 'cached within DSL-declared 1h TTL'); + t += 2; + await rt.check('u:1', 'can_spend', 'doc:9'); + assert.equal(calls, 2, 're-invoked past the DSL-declared 1h TTL'); + }); +});