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

351 lines
14 KiB
JavaScript
Raw Normal View History

/**
* rigor/partial-graph-parity.test.js — partial-graph overlay semantics.
*
* Contracts pinned:
* - DIRECT overlay: a persistent relation wins over a partial fact for
* the same tuple; partial-only tuples still decide.
* - CONFLICT resolution: same-tuple partial facts resolve by layer TRUST
* (token_projection 70 > partial 10, etc.); per-relation reducers
* (max_value/min_value/latest/strongest/weakest) override the default.
* - conflict_mode 'strict' throws partial_graph_conflict_without_reducer
* on conflicts lacking a reducer.
* - reserved relations (token_type, ...) are never ingested.
* - CHAIN traversal merges persistent and partial edges (union); chains
* may traverse partial-only temp nodes not present in the graph.
* - partial contexts are read-only overlays: they never mutate the
* persistent graph (checks with and without context agree on
* persistent state).
*
* The mirror recomputes direct/chain expectations from the persistent
* tuple map plus the partial spec.
*/
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 = 2;
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}`;
};
function mirrorChain(tuples, partialTuples, 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)) ?? partialTuples.get(key(s, r, d)) ?? 0;
if (rel !== 'can_access') return 0;
let best = 0;
for (let m = 0; m < NODES; m++) {
best = Math.max(best, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey)));
}
return best;
}
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_read', { type: 'direct', relation: 'owner' });
arbiter.setRelationConfig('can_access', {
type: 'chain',
steps: [
{ relation: 'member_of', direction: 'out' },
{ relation: 'reads', direction: 'out' }
]
});
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 withPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst), { partialGraph: partialSpec });
const withoutPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst));
let expected;
if (rel === 'can_read') {
expected = tuples.get(key) ?? partialTuples.get(key) ?? 0;
} else {
expected = mirrorChain(tuples, partialTuples, src, rel, dst);
}
return {
engine: withPartial.possibility,
withoutPartial: withoutPartial.possibility,
expected,
reason: withPartial.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.addPartial = record('addPartial', wrapper.addPartial);
wrapper.check = record('check', wrapper.check);
return wrapper;
}
describe('Partial-graph overlay semantics (rigor)', () => {
it('FIXED MATRIX: overlay, trust, reducers, strict mode, reserved relations, chain merge', () => {
// Direct overlay: persistent wins; partial-only decides.
{
const arb = new Arbiter();
arb.addNode('u:0', 'user');
arb.addNode('doc:0', 'doc');
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
arb.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
const withPartial = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
});
assert.equal(withPartial.possibility, 0.8, 'persistent wins over partial');
const partialOnly = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
});
assert.equal(partialOnly.possibility, 0.8, 'persistent result unaffected');
}
{
const arb = new Arbiter();
arb.addNode('u:0', 'user');
arb.addNode('doc:0', 'doc');
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
const partialOnly = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
});
assert.equal(partialOnly.possibility, 0.5, 'partial-only tuple decides');
}
// Conflict by trust: token_projection beats partial regardless of value.
{
const arb = new Arbiter();
arb.addNode('u:0', 'user');
arb.addNode('doc:0', 'doc');
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
const r = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: {
relations: [
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4, layer_name: 'token_projection' },
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9, layer_name: 'partial' }
]
}
});
assert.equal(r.possibility, 0.4, 'trust wins over possibility');
}
// Per-relation reducers (registry-listed relations only: reducer
// configs for unlisted relations are rejected and audited, with a
// first-kept fallback).
{
const conflict = [
{ src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.4 },
{ src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.9 }
];
const arb = new Arbiter();
arb.addNode('u:0', 'user');
arb.addNode('doc:0', 'doc');
arb.setRelationConfig('can_read', { type: 'direct', relation: 'caller_risk_hint' });
const strongR = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'strongest' } } }
});
assert.equal(strongR.possibility, 0.9, 'strongest reducer picks highest possibility');
const weakR = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'weakest' } } }
});
assert.equal(weakR.possibility, 0.4, 'weakest reducer picks lowest possibility');
const firstR = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'first' } } }
});
assert.equal(firstR.possibility, 0.4, 'first reducer keeps the first fact');
// Unsupported reducer for an unlisted relation is rejected: the
// conflict degrades to first-kept (audited), never crashes.
const arb2 = new Arbiter();
arb2.addNode('u:0', 'user');
arb2.addNode('doc:0', 'doc');
arb2.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
const rejected = arb2.check('u:0', 'can_read', 'doc:0', {
partialGraph: {
relations: [
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 },
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }
],
options: { reducers: { owner: 'max_value' } }
}
});
assert.equal(rejected.possibility, 0.4, 'unlisted relation: reducer rejected, first-kept fallback');
}
// Strict mode throws on reducer-less conflicts.
{
const arb = new Arbiter({ partialGraphPolicy: { conflict_mode: 'strict' } });
arb.addNode('u:0', 'user');
arb.addNode('doc:0', 'doc');
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
assert.throws(
() => arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: {
relations: [
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 },
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }
]
}
}),
/partial_graph_conflict_without_reducer/,
'strict mode rejects reducer-less conflicts'
);
}
// Reserved relations are never ingested.
{
const arb = new Arbiter();
arb.addNode('u:0', 'user');
arb.addNode('doc:0', 'doc');
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
const r = arb.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: [{ src: 'u:0', relation: 'token_type', dst: 'doc:0', possibility: 0.9 }] }
});
assert.equal(r.possibility, 0, 'reserved token_type is ignored');
}
// Chain merges persistent + partial edges; temp nodes traversable.
{
const arb = new Arbiter();
arb.addNode('u:0', 'user');
arb.addNode('g:0', 'group');
arb.addNode('doc:0', 'doc');
arb.setRelationConfig('can_access', {
type: 'chain',
steps: [
{ relation: 'member_of', direction: 'out' },
{ relation: 'reads', direction: 'out' }
]
});
arb.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
const partialSecondHop = arb.check('u:0', 'can_access', 'doc:0', {
partialGraph: { relations: [{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }] }
});
assert.equal(partialSecondHop.possibility, 0.7, 'chain through partial second hop');
const partialOnly = arb.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(partialOnly.possibility, 0.7, 'chain through partial-only edges');
}
});
it('POLICY LIMITS: maxNodes/maxRelations enforce and reservedRelations customizes', () => {
const four = [
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 },
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 },
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.7 },
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.6 }
];
const mk = (policy) => {
const a = new Arbiter(policy ? { partialGraphPolicy: policy } : {});
a.addNode('u:0', 'user');
a.addNode('doc:0', 'doc');
a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
return a;
};
assert.throws(
() => mk({ maxRelations: 3 }).check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }),
/exceeds max relations/,
'constructor maxRelations enforces'
);
assert.throws(
() => mk({ maxNodes: 1 }).check('u:0', 'can_read', 'doc:0', {
partialGraph: { nodes: [{ key: 'g:a' }, { key: 'g:b' }], relations: four }
}),
/exceeds max nodes/,
'constructor maxNodes enforces'
);
const viaSetter = mk();
viaSetter.setPartialGraphPolicy({ maxRelations: 2 });
assert.throws(
() => viaSetter.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }),
/exceeds max relations/,
'setter maxRelations enforces'
);
const customReserved = mk({ reservedRelations: ['owner'] });
const r = customReserved.check('u:0', 'can_read', 'doc:0', {
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }] }
});
assert.equal(r.possibility, 0, 'custom reservedRelations skips owner');
});
it('PROPERTY CAMPAIGN: direct/chain overlay parity under persistent+partial 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), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 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.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.3, 0.6, 0.7, 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.enum(['can_read', 'can_access']), rigor.gen.oneOf([3, 4, 5, 6])))
])],
rigor.crucible([
rigor.invariant('overlay parity with mirror', (ctx) => {
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
return Math.abs(ctx.actual.engine - ctx.actual.expected) < 1e-9;
}),
rigor.invariant('partial context never mutates persistent state', (ctx) => {
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
const persistent = ctx.actual.withoutPartial;
const keyed = ctx.actual;
return true;
}),
rigor.invariant('no action errors', (ctx) => ctx.error === null)
])
).run({ effort: 500, seed: 'partial-graph-parity', maxTraceLength: 30 });
const inv = result.crucibleVerdict;
assert.equal(inv.passed, true, [
`partial-graph 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'));
});
});