diff --git a/src/core/Arbiter.js b/src/core/Arbiter.js index 0e77271..d08a466 100644 --- a/src/core/Arbiter.js +++ b/src/core/Arbiter.js @@ -471,10 +471,22 @@ export class Arbiter { invalidateRuleResultCacheByRelation(relation) { if (!this.ruleResultCache) return; + // Transitive closure over the dependency index: a mutation of a base + // relation (e.g. owner) must invalidate cached results of every rule + // that depends on it through any chain (owner -> is_owner -> + // can_read/computed), not just direct dependents. const affected = new Set([relation]); - const entry = this.dependencyIndex.get(relation); - if (entry?.all) { - for (const rel of entry.all) affected.add(rel); + const queue = [relation]; + while (queue.length) { + const rel = queue.pop(); + const entry = this.dependencyIndex.get(rel); + if (!entry || !entry.all) continue; + for (const dep of entry.all) { + if (!affected.has(dep)) { + affected.add(dep); + queue.push(dep); + } + } } for (const rel of affected) { @@ -529,6 +541,19 @@ export class Arbiter { acc.set(`${rule.relation}|false`, { relation: rule.relation, reverse: false }); } + if (rule.type === 'multi_hop' && rule.relation) { + acc.set(`${rule.relation}|false`, { relation: rule.relation, reverse: false }); + } + + if (rule.type === 'parent') { + if (rule.parentRelation) { + acc.set(`${rule.parentRelation}|false`, { relation: rule.parentRelation, reverse: false }); + } + if (rule.relation && rule.relation !== rule.parentRelation) { + acc.set(`${rule.relation}|false`, { relation: rule.relation, reverse: false }); + } + } + const unionRules = Array.isArray(rule.union) ? rule.union : rule.union?.rules; if (Array.isArray(unionRules)) { for (const child of unionRules) this._collectRelationUsages(child, acc); diff --git a/tests/rigor/advanced-rule-kinds.test.js b/tests/rigor/advanced-rule-kinds.test.js new file mode 100644 index 0000000..6efef7a --- /dev/null +++ b/tests/rigor/advanced-rule-kinds.test.js @@ -0,0 +1,178 @@ +/** + * rigor/advanced-rule-kinds.test.js — multi_hop, computed, and parent rule + * kinds through the full check() path under random mutations. + * + * Semantics pinned (from engine probes): + * - multi_hop: traverse ONE relation up to maxDepth; value = max over + * paths of min over edges (chain semantics with a depth limit). + * - computed: alias of the userset relation (evaluate `relation` on the + * subject/object pair). + * - parent: the subject must hold the rule's `relation` DIRECTLY on an + * object that is the target's parent via `parentRelation` edges; + * value = the direct edge strength; direct-on-object grants nothing. + * + * The mirror recomputes each kind from the raw tuple map; engine and + * mirror must agree after every mutation. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { Arbiter } from '../../src/index.js'; + +const USERS = 2; +const GROUPS = 2; +const DOCS = 3; +const NODES = USERS + GROUPS + DOCS; +const nodeKey = (id) => { + if (id < USERS) return `u:${id}`; + if (id < USERS + GROUPS) return `g:${id - USERS}`; + return `doc:${id - USERS - GROUPS}`; +}; +const MAX_DEPTH = 3; + +function mirrorCheck(tuples, src, rel, dst) { + const srcKey = nodeKey(src); + const dstKey = nodeKey(dst); + const key = (s, r, d) => `${s}|${r}|${d}`; + const p = (s, r, d) => tuples.get(key(s, r, d)) ?? 0; + const exists = (s, r, d) => tuples.has(key(s, r, d)); + + if (rel === 'can_access') { + // multi_hop member_of, maxDepth 3: BFS over member_of edges. + let best = 0; + const frontier = [[srcKey, 1.0, 0]]; + const seen = new Set(); + while (frontier.length) { + const [cur, pathMin, depth] = frontier.shift(); + if (seen.has(`${cur}|${depth}`)) continue; + seen.add(`${cur}|${depth}`); + if (cur === dstKey && depth > 0) best = Math.max(best, pathMin); + if (depth >= MAX_DEPTH) continue; + for (let m = 0; m < NODES; m++) { + const w = p(cur, 'member_of', nodeKey(m)); + if (w > 0) frontier.push([nodeKey(m), Math.min(pathMin, w), depth + 1]); + } + } + return best; + } + if (rel === 'can_read') { + // computed -> is_owner -> direct owner + return p(srcKey, 'owner', dstKey); + } + if (rel === 'can_edit') { + // parent: the (parent, 'parent', object) edge points FROM the parent + // TO the object; the subject must hold owner directly on the parent. + let best = 0; + for (let a = 0; a < NODES; a++) { + if (exists(nodeKey(a), 'parent', dstKey)) { + best = Math.max(best, p(srcKey, 'owner', nodeKey(a))); + } + } + return best; + } + return 0; +} + +function makeWrapper() { + const arbiter = new Arbiter(); + for (let i = 0; i < NODES; i++) { + arbiter.addNode(nodeKey(i), i < USERS ? 'user' : i < USERS + GROUPS ? 'group' : 'doc'); + } + arbiter.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', maxDepth: MAX_DEPTH }); + arbiter.setRelationConfig('is_owner', { type: 'direct', relation: 'owner' }); + arbiter.setRelationConfig('can_read', { type: 'computed', relation: 'is_owner' }); + arbiter.setRelationConfig('can_edit', { type: 'parent', parentRelation: 'parent', relation: 'owner' }); + const tuples = new Map(); + const tupleKey = (s, r, d) => `${s}|${r}|${d}`; + const ops = []; + + const wrapper = { + engine: arbiter, + tuples, + add(src, rel, dst, p) { + arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p }); + tuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p); + return { ok: true }; + }, + remove(src, rel, dst) { + arbiter.removeRelation(nodeKey(src), rel, nodeKey(dst)); + tuples.delete(tupleKey(nodeKey(src), rel, nodeKey(dst))); + return { ok: true }; + }, + check(src, rel, dst) { + const result = arbiter.check(nodeKey(src), rel, nodeKey(dst)); + const expected = mirrorCheck(tuples, src, rel, dst); + return { + engine: Math.round(result.possibility * 10000) / 10000, + expected: Math.round(expected * 10000) / 10000, + reason: result.reason + }; + }, + clone() { + const fresh = makeWrapper(); + for (const op of ops) { + const [name, ...args] = op; + fresh[name](...args); + } + return fresh; + } + }; + + const record = (name, fn) => (...args) => { + const res = fn(...args); + ops.push([name, ...args]); + return res; + }; + wrapper.add = record('add', wrapper.add); + wrapper.remove = record('remove', wrapper.remove); + wrapper.check = record('check', wrapper.check); + return wrapper; +} + +const relArg = rigor.gen.enum(['member_of', 'parent', 'owner', 'reads']); +const checkRelArg = rigor.gen.enum(['can_access', 'can_read', 'can_edit']); + +describe('Advanced rule kinds through check() (rigor)', () => { + it('FIXED MATRIX: canonical semantics for each kind', () => { + const w = makeWrapper(); + w.add(0, 'member_of', 2, 0.8); // u:0 -> g:0 + w.add(2, 'member_of', 3, 0.7); // g:0 -> g:1 + w.add(0, 'owner', 5, 0.9); // u:0 owns doc:1 + w.add(5, 'parent', 6, 1.0); // doc:1 is doc:2's parent + assert.equal(w.check(0, 'can_access', 3).engine, 0.7, 'multi_hop 2-hop = min(0.8, 0.7)'); + assert.equal(w.check(0, 'can_access', 2).engine, 0.8, 'multi_hop direct = 0.8'); + assert.equal(w.check(0, 'can_access', 4).engine, 0, 'multi_hop no path'); + assert.equal(w.check(0, 'can_read', 5).engine, 0.9, 'computed = owner value'); + assert.equal(w.check(1, 'can_read', 5).engine, 0, 'computed no edge'); + assert.equal(w.check(0, 'can_edit', 6).engine, 0.9, 'parent via doc:1'); + assert.equal(w.check(0, 'can_edit', 5).engine, 0, 'parent grants nothing directly'); + }); + + it('PROPERTY CAMPAIGN: mirrors agree under random mutations', async () => { + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('add', function (s, r, d, p) { return this.add(s, r, d, p); }, + rigor.args(rigor.gen.int(0, NODES - 1), relArg, rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]))), + rigor.method('remove', function (s, r, d) { return this.remove(s, r, d); }, + rigor.args(rigor.gen.int(0, NODES - 1), relArg, rigor.gen.int(0, NODES - 1))), + rigor.method('check', function (s, r, d) { return this.check(s, r, d); }, + rigor.args(rigor.gen.int(0, USERS - 1), checkRelArg, rigor.gen.oneOf([3, 4, 5, 6]))) + ])], + rigor.crucible([ + rigor.invariant('kind parity after every mutation', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + return ctx.actual.engine === ctx.actual.expected; + }), + rigor.invariant('no action errors', (ctx) => ctx.error === null) + ]) + ).run({ effort: 500, seed: 'advanced-rule-kinds', maxTraceLength: 30 }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `rule-kind parity violated in ${inv.failureCount} cases:`, + ...result.failures.slice(0, 3).map((f) => + ` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` + ) + ].join('\n')); + }); +});