2026-07-31 15:31:34 -07:00
|
|
|
/**
|
|
|
|
|
* rigor/binary-partial-parity.test.js — binary-mode decisions with
|
|
|
|
|
* partial graphs.
|
|
|
|
|
*
|
|
|
|
|
* Bug-28 regression pin: the binary branch of AuthorizationChecker.check
|
|
|
|
|
* rebuilt its options with a fixed six-field object and dropped
|
|
|
|
|
* partialGraphContext, so a binary check with a partial grant denied
|
|
|
|
|
* everything. Contracts pinned:
|
|
|
|
|
* - binary decisions honor partial facts: a partial grant above
|
|
|
|
|
* minAllowPossibility allows; below it denies;
|
|
|
|
|
* - persistent relations still win over partial facts in binary mode;
|
|
|
|
|
* - binary and normal decisions agree on the same partial overlay
|
|
|
|
|
* (binary.allow === (normalPossibility >= threshold) for direct
|
|
|
|
|
* configs).
|
|
|
|
|
*/
|
|
|
|
|
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 DOCS = 2;
|
|
|
|
|
const NODES = USERS + DOCS;
|
|
|
|
|
const nodeKey = (id) => (id < USERS ? `u:${id}` : `doc:${id - USERS}`);
|
|
|
|
|
const THRESHOLD = 0.6;
|
|
|
|
|
|
|
|
|
|
function makeWrapper() {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
for (let i = 0; i < NODES; i++) {
|
|
|
|
|
arbiter.addNode(nodeKey(i), i < USERS ? 'user' : 'doc');
|
|
|
|
|
}
|
|
|
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
|
|
|
const tuples = new Map();
|
|
|
|
|
const partialTuples = new Map();
|
|
|
|
|
const tupleKey = (s, r, d) => `${s}|${r}|${d}`;
|
|
|
|
|
const ops = [];
|
|
|
|
|
|
|
|
|
|
const wrapper = {
|
|
|
|
|
engine: arbiter,
|
|
|
|
|
tuples,
|
|
|
|
|
partialTuples,
|
|
|
|
|
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 };
|
|
|
|
|
},
|
|
|
|
|
addPartial(src, rel, dst, p) {
|
|
|
|
|
partialTuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p);
|
|
|
|
|
return { ok: true };
|
|
|
|
|
},
|
|
|
|
|
check(src, rel, dst) {
|
|
|
|
|
const baseRel = rel === 'can_read' ? 'owner' : rel;
|
|
|
|
|
const key = tupleKey(nodeKey(src), baseRel, nodeKey(dst));
|
|
|
|
|
const partialSpec = {
|
|
|
|
|
relations: [...partialTuples.entries()].map(([k, p]) => {
|
|
|
|
|
const [s, r, d] = k.split('|');
|
|
|
|
|
return { src: s, relation: r, dst: d, possibility: p };
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
const binaryRes = arbiter.check(nodeKey(src), rel, nodeKey(dst), {
|
|
|
|
|
binary: true,
|
|
|
|
|
minAllowPossibility: THRESHOLD,
|
|
|
|
|
maxDenyPossibility: THRESHOLD,
|
|
|
|
|
partialGraph: partialSpec
|
|
|
|
|
});
|
|
|
|
|
const normalRes = arbiter.check(nodeKey(src), rel, nodeKey(dst), { partialGraph: partialSpec });
|
|
|
|
|
const persistentValue = tuples.get(key) ?? 0;
|
|
|
|
|
const partialValue = partialTuples.get(key) ?? 0;
|
|
|
|
|
return {
|
|
|
|
|
binaryAllow: binaryRes.allow,
|
|
|
|
|
binaryReason: binaryRes.reason,
|
|
|
|
|
normalPossibility: normalRes.possibility,
|
|
|
|
|
persistentValue,
|
|
|
|
|
partialValue,
|
|
|
|
|
expected: persistentValue > 0 ? persistentValue : partialValue
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
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.addPartial = record('addPartial', wrapper.addPartial);
|
|
|
|
|
wrapper.check = record('check', wrapper.check);
|
|
|
|
|
return wrapper;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Binary mode with partial graphs (rigor)', () => {
|
|
|
|
|
it('FIXED MATRIX: binary honors partial grants; persistent wins; decisions agree', () => {
|
|
|
|
|
const arb = new Arbiter();
|
|
|
|
|
arb.addNode('u:0', 'user');
|
|
|
|
|
arb.addNode('doc:0', 'doc');
|
|
|
|
|
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
|
|
|
|
|
|
|
|
const grant = arb.check('u:0', 'can_read', 'doc:0', {
|
|
|
|
|
binary: true,
|
|
|
|
|
minAllowPossibility: THRESHOLD,
|
|
|
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }] }
|
|
|
|
|
});
|
|
|
|
|
assert.equal(grant.allow, true, 'partial grant above threshold allows');
|
|
|
|
|
assert.equal(grant.possibility, 0.9, 'binary returns the partial possibility');
|
|
|
|
|
|
|
|
|
|
const weak = arb.check('u:0', 'can_read', 'doc:0', {
|
|
|
|
|
binary: true,
|
|
|
|
|
minAllowPossibility: THRESHOLD,
|
|
|
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.2 }] }
|
|
|
|
|
});
|
|
|
|
|
assert.equal(weak.allow, false, 'partial grant below threshold denies');
|
|
|
|
|
|
|
|
|
|
arb.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
|
|
|
|
const persistent = arb.check('u:0', 'can_read', 'doc:0', {
|
|
|
|
|
binary: true,
|
|
|
|
|
minAllowPossibility: THRESHOLD,
|
|
|
|
|
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.3 }] }
|
|
|
|
|
});
|
|
|
|
|
assert.equal(persistent.allow, true, 'persistent 0.8 wins over partial 0.3');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('PROPERTY CAMPAIGN: binary and normal decisions agree on the same overlay', 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), rigor.gen.constant('owner'), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.1, 0.3, 0.5, 0.7, 0.9]))),
|
|
|
|
|
rigor.method('addPartial', function (s, r, d, p) { return this.addPartial(s, r, d, p); },
|
|
|
|
|
rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.constant('owner'), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.4, 0.6, 0.8, 0.95]))),
|
|
|
|
|
rigor.method('check', function (s, r, d) { return this.check(s, r, d); },
|
|
|
|
|
rigor.args(rigor.gen.int(0, USERS - 1), rigor.gen.constant('can_read'), rigor.gen.oneOf([2, 3])))
|
|
|
|
|
])],
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.invariant('binary decision equals normal decision at threshold', (ctx) => {
|
|
|
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
|
|
|
const { binaryAllow, normalPossibility } = ctx.actual;
|
|
|
|
|
const normalAllow = normalPossibility >= THRESHOLD;
|
|
|
|
|
return binaryAllow === normalAllow;
|
|
|
|
|
}),
|
|
|
|
|
rigor.invariant('expected overlay value matches the engine', (ctx) => {
|
|
|
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
|
|
|
const { normalPossibility, expected } = ctx.actual;
|
|
|
|
|
return Math.abs(normalPossibility - expected) < 1e-9;
|
|
|
|
|
}),
|
|
|
|
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
|
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 400, seed: 'binary-partial-parity', maxTraceLength: 25 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 15:31:34 -07:00
|
|
|
|
|
|
|
|
const inv = result.crucibleVerdict;
|
|
|
|
|
assert.equal(inv.passed, true, [
|
|
|
|
|
`binary/partial 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'));
|
|
|
|
|
});
|
|
|
|
|
});
|