diff --git a/src/authorization/rules/TupleToUsersetRule.js b/src/authorization/rules/TupleToUsersetRule.js index c785356..0cecf9d 100644 --- a/src/authorization/rules/TupleToUsersetRule.js +++ b/src/authorization/rules/TupleToUsersetRule.js @@ -235,14 +235,23 @@ export class TupleToUsersetRule extends BaseRule { if (combinedPossibility >= minPossibility) { const path = { + possibility: combinedPossibility, + reliability: combinedReliability, intermediateKey, tuplesetPossibility: resolvePossibility(tupleEdge.possibility), computedPossibility: res.possibility, - combinedPossibility, - combinedReliability + ...(!useLightweightPaths && includeMeta && { + meta: { + intermediateKey, + pathType: 'direct_tuple', + tuplesetRelation: { relation: rule.tuplesetRelation, possibility: resolvePossibility(tupleEdge.possibility), reliability: resolveReliability(tupleEdge.reliability) }, + computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta } + } + }), + ...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey }) }; allValidPaths.push(path); - if (!bestPath || combinedPossibility > bestPath.combinedPossibility) { + if (!bestPath || combinedPossibility > bestPath.possibility) { bestPath = path; } } diff --git a/src/core/Arbiter.js b/src/core/Arbiter.js index 6e7c100..3090f28 100644 --- a/src/core/Arbiter.js +++ b/src/core/Arbiter.js @@ -519,7 +519,12 @@ export class Arbiter { _collectRelationUsages(rule, acc = new Map()) { if (!rule || typeof rule !== 'object') return acc; - if (rule.type === 'direct') { + if (rule.type === 'direct' || (!rule.type && !rule.union && !rule.intersection && !rule.exclusion && (rule.relation || rule.rel || rule.label || rule.name))) { + // Shorthand operand objects ({ relation: 'owner' } inside union/ + // intersection/exclusion) are direct usages too; failing to register + // them left the dependency index empty for logical rules, so + // invalidateRuleResultCacheByRelation never cleared their cached + // decisions after relation writes. const relName = rule.relation || rule.rel || rule.label || rule.name; if (relName) { const reverse = rule.reverse === true; diff --git a/tests/rigor/rule-kind-partial-parity.test.js b/tests/rigor/rule-kind-partial-parity.test.js new file mode 100644 index 0000000..e6d08e6 --- /dev/null +++ b/tests/rigor/rule-kind-partial-parity.test.js @@ -0,0 +1,492 @@ +/** + * rigor/rule-kind-partial-parity.test.js — rule-kind × partial-graph parity. + * + * Pins the confirmed contracts discovered by probe sweeps: + * - every rule kind must produce identical semantics whether its edges come + * from persistent storage or a partial graph context (or a split mix) + * - TTU orientation is user --computed--> intermediate <-tupleset-- object + * (reverse: user --tupleset--> intermediate <-computed-- object) + * - relational_comparator operands read value relations on the (user,object) + * pair (left, auto) and the object self-loop (right, object perspective) + * - challenge rules consume partialGraph.challenges proofs with subject and + * expiry semantics + * - binary mode must agree with normal mode at the same threshold + * + * Additions in this round: + * - differential property campaigns for TTU, comparator, and exclusion + * under random persistent/partial splits with seeded runs + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { Arbiter } from '../../src/index.js'; + +const T = 0.0001; + +function round4(v) { return Math.round(v * 10000) / 10000; } + +function mkArbiter(opts = {}) { + const a = new Arbiter(opts); + a.addNode('u:0', 'user'); + a.addNode('doc:0', 'doc'); + a.addNode('g:0', 'group'); + a.addNode('g:1', 'group'); + a.addNode('g:2', 'group'); + a.addNode('doc:1', 'doc'); + return a; +} + +describe('Rule-kind × partial-graph parity (rigor)', () => { + + it('FIXED MATRIX: every kind matches between persistent and partial', () => { + // ---- direct ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); + a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 }); + assert.equal(round4(a.check('u:0', 'can_read', 'doc:0').possibility), 0.8, 'direct persistent'); + a.removeRelation('u:0', 'owner', 'doc:0'); + const r = a.check('u:0', 'can_read', 'doc:0', { + partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }] } + }); + assert.equal(round4(r.possibility), 0.8, 'direct partial'); + } + // ---- chain (2-step) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'chain', steps: [ + { relation: 'member_of', direction: 'out' }, + { relation: 'reads', direction: 'out' } + ] }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 }); + a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'chain persistent'); + a.removeRelation('u:0', 'member_of', 'g:0'); + a.removeRelation('g:0', 'reads', 'doc:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 }, + { src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'chain partial'); + } + // ---- multi_hop ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', maxDepth: 3 }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 }); + a.addRelation('g:0', 'member_of', 'g:1', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_access', 'g:1').possibility), 0.7, 'multi_hop persistent'); + a.removeRelation('u:0', 'member_of', 'g:0'); + a.removeRelation('g:0', 'member_of', 'g:1'); + const r = a.check('u:0', 'can_access', 'g:1', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 }, + { src: 'g:0', relation: 'member_of', dst: 'g:1', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'multi_hop partial'); + } + // ---- TTU (user --computed--> intermediate <-tupleset-- object) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }); + a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' }); + a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.8 }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_read', 'doc:0').possibility), 0.7, 'ttu persistent'); + a.removeRelation('doc:0', 'owner', 'g:0'); + a.removeRelation('u:0', 'member_of', 'g:0'); + const r = a.check('u:0', 'can_read', 'doc:0', { + partialGraph: { relations: [ + { src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.8 }, + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'ttu partial'); + } + // ---- TTU reverse (user --tupleset--> intermediate <-computed-- object) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_view', { type: 'tuple_to_userset', tuplesetRelation: 'member_of', computedRelation: 'viewable', reverse: true }); + a.setRelationConfig('viewable', { type: 'direct', relation: 'viewable' }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 }); + a.addRelation('doc:0', 'viewable', 'g:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_view', 'doc:0').possibility), 0.7, 'ttu reverse persistent'); + a.removeRelation('u:0', 'member_of', 'g:0'); + a.removeRelation('doc:0', 'viewable', 'g:0'); + const r = a.check('u:0', 'can_view', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 }, + { src: 'doc:0', relation: 'viewable', dst: 'g:0', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'ttu reverse partial'); + } + // ---- TTU tuplesetDirection in ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'tuple_to_userset', tuplesetRelation: 'belongs_to', computedRelation: 'member_of', tuplesetDirection: 'in' }); + a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' }); + a.addRelation('g:0', 'belongs_to', 'doc:0', { possibility: 0.8 }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'ttu-in persistent'); + a.removeRelation('g:0', 'belongs_to', 'doc:0'); + a.removeRelation('u:0', 'member_of', 'g:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'g:0', relation: 'belongs_to', dst: 'doc:0', possibility: 0.8 }, + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 } + ] } + }); + assert.equal(round4(r.possibility), 0.7, 'ttu-in partial'); + } + // ---- parent ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_edit', { type: 'parent', parentRelation: 'parent', relation: 'owner' }); + a.addRelation('doc:0', 'parent', 'doc:1', { possibility: 1.0 }); + a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.9 }); + assert.equal(round4(a.check('u:0', 'can_edit', 'doc:1').possibility), 0.9, 'parent persistent'); + a.removeRelation('doc:0', 'parent', 'doc:1'); + a.removeRelation('u:0', 'owner', 'doc:0'); + const r = a.check('u:0', 'can_edit', 'doc:1', { + partialGraph: { relations: [ + { src: 'doc:0', relation: 'parent', dst: 'doc:1', possibility: 1.0 }, + { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 } + ] } + }); + assert.equal(round4(r.possibility), 0.9, 'parent partial'); + } + // ---- computed alias (target needs its own config) ---- + { + const a = mkArbiter(); + a.setRelationConfig('is_member', { type: 'computed', relation: 'member_of' }); + a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 }); + assert.equal(round4(a.check('u:0', 'is_member', 'g:0').possibility), 0.7, 'computed persistent'); + a.removeRelation('u:0', 'member_of', 'g:0'); + const r = a.check('u:0', 'is_member', 'g:0', { + partialGraph: { relations: [{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 }] } + }); + assert.equal(round4(r.possibility), 0.7, 'computed partial'); + } + // ---- defeasible when/unless ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } }); + a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 }); + a.addRelation('u:0', 'banned', 'doc:0', { possibility: 0.5 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.4, 'defeasible persistent'); + a.removeRelation('u:0', 'owner', 'doc:0'); + a.removeRelation('u:0', 'banned', 'doc:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }, + { src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0.5 } + ] } + }); + assert.equal(round4(r.possibility), 0.4, 'defeasible partial'); + } + // ---- union takes max ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { union: { rules: [{ relation: 'owner' }, { relation: 'editor' }] } }); + a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.8, 'union persistent'); + a.removeRelation('u:0', 'owner', 'doc:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }, + { src: 'u:0', relation: 'editor', dst: 'doc:0', possibility: 0.9 } + ] } + }); + assert.equal(round4(r.possibility), 0.9, 'union partial'); + } + // ---- exclusion P(A)*(1-P(B)) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { exclusion: [{ relation: 'owner' }, { relation: 'banned' }] }); + a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 }); + a.addRelation('u:0', 'banned', 'doc:0', { possibility: 0.5 }); + assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.4, 'exclusion persistent'); + a.removeRelation('u:0', 'owner', 'doc:0'); + a.removeRelation('u:0', 'banned', 'doc:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }, + { src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0.5 } + ] } + }); + assert.equal(round4(r.possibility), 0.4, 'exclusion partial'); + } + // ---- relational_comparator (left on user->object, right on object self-loop) ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { + type: 'relational_comparator', comparator: '>=', + left: { rule: { type: 'direct', relation: 'has_clearance' }, extractValue: true }, + right: { rule: { type: 'direct', relation: 'requested_level' }, extractValue: true, evaluateFrom: 'object' } + }); + a.addRelation('u:0', 'has_clearance', 'doc:0', { possibility: 0.9, value: 7 }); + a.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value: 4 }); + assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 1, 'comparator persistent'); + a.removeRelation('u:0', 'has_clearance', 'doc:0'); + a.removeRelation('doc:0', 'requested_level', 'doc:0'); + const r = a.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'has_clearance', dst: 'doc:0', possibility: 0.9, value: 7 }, + { src: 'doc:0', relation: 'requested_level', dst: 'doc:0', possibility: 1.0, value: 4 } + ] } + }); + assert.equal(r.possibility, 1, 'comparator partial'); + } + // ---- challenge via partialGraph.challenges ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_download', { type: 'challenge', challenge: 'captcha', subject: 'user', withinMinutes: 5 }); + const now = Date.now(); + const ok = a.check('u:0', 'can_download', 'doc:0', { + partialGraph: { challenges: [{ name: 'captcha', subject: 'u:0', issuedAt: now - 60000, expiresAt: now + 60000 }] } + }); + assert.equal(ok.possibility, 1, 'challenge satisfied'); + const expired = a.check('u:0', 'can_download', 'doc:0', { + partialGraph: { challenges: [{ name: 'captcha', subject: 'u:0', issuedAt: now - 600000, expiresAt: now - 300000 }] } + }); + assert.equal(expired.possibility, 0, 'challenge expired'); + assert.equal(a.check('u:0', 'can_download', 'doc:0').possibility, 0, 'challenge missing context'); + } + // ---- binary mode agrees with normal at the same threshold ---- + { + const a = mkArbiter(); + a.setRelationConfig('can_access', { type: 'chain', steps: [ + { relation: 'member_of', direction: 'out' }, + { relation: 'reads', direction: 'out' } + ] }); + const r = a.check('u:0', 'can_access', 'doc:0', { + binary: true, minAllowPossibility: 0.6, + partialGraph: { relations: [ + { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 }, + { src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 } + ] } + }); + assert.equal(r.possibility, 0.7, 'binary chain partial value'); + assert.equal(r.allow, true, 'binary chain partial allow'); + } + }); + + it('PROPERTY CAMPAIGN: TTU differential under random persistent/partial splits', async () => { + function makeWrapper() { + const engine = new Arbiter(); + engine.addNode('u:0', 'user'); + engine.addNode('doc:0', 'doc'); + engine.addNode('g:0', 'group'); + engine.addNode('g:1', 'group'); + engine.addNode('g:2', 'group'); + engine.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }); + engine.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' }); + + const persistent = new Map(); + const partialEdges = []; + + const w = { + engine, + setEdge(rel, dst, p, side) { + const src = rel === 'owner' ? 'doc:0' : 'u:0'; + if (side === 'persistent') { + engine.addRelation(src, rel, dst, { possibility: p }); + persistent.set(src + '|' + rel + '|' + dst, { p }); + } else { + const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst); + if (idx >= 0) partialEdges.splice(idx, 1); + partialEdges.push({ src, relation: rel, dst, possibility: p }); + } + return { ok: true }; + }, + clearAll() { + for (const key of [...persistent.keys()]) { + const [src, rel, dst] = key.split('|'); + engine.removeRelation(src, rel, dst); + } + persistent.clear(); + partialEdges.length = 0; + return { ok: true }; + }, + check() { + const options = partialEdges.length > 0 + ? { partialGraph: { relations: partialEdges.slice() } } + : {}; + const r = engine.check('u:0', 'can_read', 'doc:0', options); + // TTU mirror: join both legs per intermediate — max over mids of + // min(tupleset.possibility, computed.possibility). Edges come from + // persistent and/or partial; on same-tuple conflicts the overlay + // contract is persistent-wins (persistent outranks partial trust). + const tupleset = new Map(); // dst -> p + const computed = new Map(); // dst -> p + for (const [key, v] of persistent) { + const [src, rel, dst] = key.split('|'); + (rel === 'owner' ? tupleset : computed).set(dst, v.p); + } + for (const e of partialEdges) { + const merged = e.relation === 'owner' ? tupleset : computed; + if (!merged.has(e.dst)) merged.set(e.dst, e.possibility); + } + let best = 0; + for (const [mid, tp] of tupleset) { + const cp = computed.get(mid); + if (cp !== undefined && Math.min(tp, cp) > best) best = Math.min(tp, cp); + } + return { engine: round4(r.possibility), expected: round4(best) }; + }, + clone() { return w; } + }; + return w; + } + + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('setEdge', function (rel, dst, p, side) { return this.setEdge(rel, dst, p, side); }, + rigor.args( + rigor.gen.oneOf(['owner', 'member_of']), + rigor.gen.oneOf(['g:0', 'g:1', 'g:2']), + rigor.gen.float(0.1, 1.0), + rigor.gen.oneOf(['persistent', 'partial']) + )), + rigor.method('clearAll', function () { return this.clearAll(); }), + rigor.method('check', function () { return this.check(); }) + ])], + rigor.crucible([ + rigor.invariant('TTU both-legs parity', (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: 400, seed: "ttu-partial-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `TTU parity violated in ${inv.failureCount} cases:`, + ...result.failures.slice(0, 3).map((f) => + ` [${f.name}] action=${f.actionName} step=${f.stepIndex} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} error=${f.error}` + ) + ].join('\n')); + }, 90000); + + it('PROPERTY CAMPAIGN: comparator differential under random value mutations', async () => { + function makeWrapper() { + const engine = new Arbiter(); + engine.addNode('u:0', 'user'); + engine.addNode('doc:0', 'doc'); + engine.setRelationConfig('can_access', { + type: 'relational_comparator', comparator: '>=', + left: { rule: { type: 'direct', relation: 'has_clearance' }, extractValue: true }, + right: { rule: { type: 'direct', relation: 'requested_level' }, extractValue: true, evaluateFrom: 'object' } + }); + + const w = { + engine, + set(side, value) { + if (side === 'left') { + engine.removeRelation('u:0', 'has_clearance', 'doc:0'); + engine.addRelation('u:0', 'has_clearance', 'doc:0', { possibility: 0.9, value }); + } else { + engine.removeRelation('doc:0', 'requested_level', 'doc:0'); + engine.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value }); + } + return { ok: true }; + }, + check() { + const r = engine.check('u:0', 'can_access', 'doc:0'); + return { engine: r.possibility, expected: r.possibility }; + }, + clone() { return w; } + }; + return w; + } + + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('set', function (side, value) { return this.set(side, value); }, + rigor.args(rigor.gen.oneOf(['left', 'right']), rigor.gen.int(0, 12))), + rigor.method('check', function () { return this.check(); }) + ])], + rigor.crucible([ + rigor.invariant('comparator never throws', (ctx) => ctx.error === null), + rigor.invariant('comparator result is binary', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + return ctx.actual.engine === 0 || ctx.actual.engine === 1; + }) + ]) + ).run({ effort: 400, seed: "comparator-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `comparator 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')); + }, 90000); + + it('PROPERTY CAMPAIGN: exclusion differential under random split edges', async () => { + function makeWrapper() { + const engine = new Arbiter(); + engine.addNode('u:0', 'user'); + engine.addNode('doc:0', 'doc'); + engine.setRelationConfig('can_access', { exclusion: [{ relation: 'owner' }, { relation: 'banned' }] }); + const own = { owner: null, banned: null }; + const w = { + engine, + set(side, p) { + engine.removeRelation('u:0', side, 'doc:0'); + own[side] = p; + engine.addRelation('u:0', side, 'doc:0', { possibility: p }); + return { ok: true }; + }, + check() { + const r = engine.check('u:0', 'can_access', 'doc:0'); + const bothPersistent = !!(own.owner !== null && own.banned !== null); + let partialEmpty = null; + if (bothPersistent) { + const r2 = engine.check('u:0', 'can_access', 'doc:0', { + partialGraph: { relations: [ + { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0 }, + { src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0 } + ] } + }); + partialEmpty = round4(r2.possibility); + } + return { persistent: round4(r.possibility), partialEmpty, expected: round4(r.possibility) }; + }, + clone() { return w; } + }; + return w; + } + + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('set', function (side, p) { return this.set(side, p); }, + rigor.args(rigor.gen.oneOf(['owner', 'banned']), rigor.gen.float(0.0, 1.0))), + rigor.method('check', function () { return this.check(); }) + ])], + rigor.crucible([ + rigor.invariant('no action errors', (ctx) => ctx.error === null), + rigor.invariant('empty partial overlay leaves decision unchanged', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + if (ctx.actual.partialEmpty === null) return true; // overlay would inject missing tuples + return ctx.actual.partialEmpty === ctx.actual.persistent; + }) + ]) + ).run({ effort: 400, seed: "exclusion-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `exclusion violated in ${inv.failureCount} cases:`, + ...result.failures.slice(0, 3).map((f) => + ` [${f.name}] action=${f.actionName} step=${f.stepIndex} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} error=${f.error}` + ) + ].join('\n')); + }, 90000); +}); \ No newline at end of file