js-rigor: transitive dependency-index invalidation; multi_hop/parent usage tracking
- invalidateRuleResultCacheByRelation now walks the transitive closure of the dependency index (owner -> is_owner -> computed can_read): mutating a base relation left computed/multi_hop results stale - _collectRelationUsages registers multi_hop and parent rule relations (parentRelation + subject relation) so their caches invalidate on base-relation mutations - advanced-rule-kinds.test.js: multi_hop (min-over-path, depth-limited), computed (userset alias), parent (subject relation on the target's parent) through the full check() path under random mutations
This commit is contained in:
+28
-3
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user