Files
core/tests/rigor/rule-kind-partial-parity.test.js
T

492 lines
23 KiB
JavaScript
Raw Normal View History

/**
* 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);
});