Files
core/tests/rigor/model-based-graph.test.js
T
John Dvorak ff6e52111d js-rigor: reliability crucibles across the campaigns; denied-decision leak fixed
The reliability gap found last round was invisible to every parity mirror
(they compared possibility only). Hardened the existing campaigns so the
mirrors carry reliability too:

- batch-order-parity: batch ops carry reliability; the mirror tracks
  last-write-wins reliability and the crucible asserts engine reliability
  parity (mirror corrected: add-on-existing preserves reliability, it does
  not reset it).
- rule-kind-partial-parity: the TTU differential property now generates
  per-edge reliabilities and asserts the winning intermediate's
  reliability (tupleset.reli * computed.reli); a new chain reliability
  differential property does the same for 2-step chains.
- snapshot-quantization-parity: edges carry deterministic reliabilities and
  the round-trip pins the codec's reliability channel (product-aware
  tolerance: chain reliability multiplies two quantized inputs).
- model-based-graph: the reference model tracks reliability per tuple and
  checks it alongside possibility for direct and chain queries.

The model crucible immediately caught a real bug: the direct-check fast
path returned the relation's reliability on a DENIED decision (possibility
0), while the rule-collection path zeroes it — denied results leaked
reliability. Both fast-path branches (direct match and threshold_not_met)
now report reliability 0 when the decision is denied.
2026-08-01 11:18:20 -07:00

228 lines
7.1 KiB
JavaScript

/**
* rigor/model-based-graph.test.js — model-based testing of the
* authorization graph via rigor.model.check.
*
* A reference model of the graph state (relation tuples with last-write-wins
* dedup) is driven through RANDOM operation sequences alongside a real
* Arbiter. Every check() command must agree between model and engine —
* across arbitrary interleavings of adds, removes and queries. This catches
* index desync, stale caches and mutation bugs that single-step tests miss.
*
* Model semantics (mirrors the engine):
* - addRelation: (src, rel, dst) is unique — a re-add REPLACES the
* possibility (last-write-wins).
* - removeRelation: no-op when the tuple is absent.
* - check can_read: max over (user, owner, doc) tuple possibilities.
* - check can_access: max over intermediate mids of
* min((user, member_of, mid), (mid, reads, doc)).
*/
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 MIDS = 2;
const DOC = USERS + MIDS; // node id of the object
const NODES = DOC + 1;
const POS = [0, 0.25, 0.5, 0.75, 1];
const EPS = 1e-9;
function nodeKey(id) {
if (id < USERS) return `u:${id}`;
if (id < USERS + MIDS) return `m:${id - USERS}`;
return 'doc:0';
}
/**
* Reference model state: plain tuple map + pure check computation.
* clone() is used by the runner to isolate sequences.
*/
function makeModel() {
return {
tuples: new Map(),
key(src, rel, dst) { return `${src}|${rel}|${dst}`; },
clone() {
const copy = { ...this, tuples: new Map(this.tuples) };
copy.clone = this.clone;
copy.key = this.key;
copy.add = this.add;
copy.remove = this.remove;
copy.check = this.check;
return copy;
},
add(src, rel, dst, p, reli) {
this.tuples.set(this.key(src, rel, dst), { p, r: reli ?? 1.0 });
return { ok: true };
},
remove(src, rel, dst) {
this.tuples.delete(this.key(src, rel, dst));
return { ok: true };
},
check(src, rel, dst) {
let possibility = 0;
let reliability = 0;
if (rel === 'can_read') {
for (const [k, v] of this.tuples) {
if (k === this.key(src, 'owner', DOC)) {
if (v.p > possibility) {
possibility = v.p;
reliability = v.r;
}
}
}
} else if (rel === 'can_access') {
// The chain traverses member_of from ANY node, then reads into the
// object from any reached node — mirror the engine exactly. The
// winning path's reliability is the product of its edges.
for (let m = 0; m < NODES; m++) {
const a = this.tuples.get(this.key(src, 'member_of', m));
const b = this.tuples.get(this.key(m, 'reads', DOC));
if (a !== undefined && b !== undefined) {
const combined = Math.min(a.p, b.p);
if (combined > possibility) {
possibility = combined;
reliability = a.r * b.r;
}
}
}
}
return {
possibility: Math.round(possibility * 10000) / 10000,
reliability: Math.round(reliability * 10000) / 10000
};
}
};
}
/**
* SUT wrapper: a real Arbiter with ops replay for per-sequence cloning.
*/
function makeSut() {
const arbiter = new Arbiter();
for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i === DOC ? 'doc' : i < USERS ? 'user' : 'group');
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 ops = [];
return {
ops,
addRelation(src, rel, dst, p, reli) {
arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p, reliability: reli });
ops.push(['add', src, rel, dst, p, reli]);
return { ok: true };
},
removeRelation(src, rel, dst) {
arbiter.removeRelation(nodeKey(src), rel, nodeKey(dst));
ops.push(['remove', src, rel, dst]);
return { ok: true };
},
check(src, rel, dst) {
const result = arbiter.check(nodeKey(src), rel, nodeKey(dst));
return {
possibility: Math.round(result.possibility * 10000) / 10000,
reliability: Math.round((result.reliability ?? 0) * 10000) / 10000
};
},
clone() {
const fresh = makeSut();
for (const op of ops) {
if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4], op[5]);
else fresh.removeRelation(op[1], op[2], op[3]);
}
return fresh;
}
};
}
const tupleArgs = rigor.gen.tuple(
rigor.gen.int(0, NODES - 1),
rigor.gen.enum(['owner', 'member_of', 'reads']),
rigor.gen.int(0, NODES - 1),
rigor.gen.oneOf(POS),
rigor.gen.oneOf([0.3, 0.6, 0.9])
);
const removeArgs = rigor.gen.tuple(
rigor.gen.int(0, NODES - 1),
rigor.gen.enum(['owner', 'member_of', 'reads']),
rigor.gen.int(0, NODES - 1)
);
const checkArgs = rigor.gen.tuple(
rigor.gen.int(0, USERS - 1),
rigor.gen.enum(['can_read', 'can_access']),
rigor.gen.constant(DOC)
);
const OPERATIONS = [
{
name: 'addRelation',
args: tupleArgs,
run: (model, src, rel, dst, p, reli) => model.add(src, rel, dst, p, reli)
},
{
name: 'removeRelation',
args: removeArgs,
run: (model, src, rel, dst) => model.remove(src, rel, dst)
},
{
name: 'check',
args: checkArgs,
run: (model, src, rel, dst) => model.check(src, rel, dst)
}
];
describe('Model-based authorization graph (rigor.model.check)', () => {
it('arbitrary add/remove/check sequences keep the engine in sync with the reference model', () => {
const result = rigor.model.check(
'graph-sync',
makeModel(),
makeSut(),
{
operations: OPERATIONS,
effort: 300,
maxSequenceLength: 24,
seed: 'model-graph-sync'
}
);
assert.equal(result.passed, true, [
`engine diverged from model in ${result.failures.length} sequences:`,
...result.failures.slice(0, 3).map((f) =>
` [${f.commandIndex}] ${f.sequence.map((c) => `${c.name}(${JSON.stringify(c.args)})`).join(' → ')}\n` +
` expected=${JSON.stringify(f.expected)} actual=${JSON.stringify(f.actual)}`
)
].join('\n'));
});
it('check results stay in [0, 1] and are deterministic under repeated queries', () => {
const result = rigor.model.check(
'graph-bounds',
makeModel(),
makeSut(),
{
operations: OPERATIONS,
effort: 100,
maxSequenceLength: 16,
seed: 'model-graph-bounds',
invariants: [
(model) => {
// Model-side invariant: every stored possibility is within [0,1]
for (const p of model.tuples.values()) {
if (p < 0 || p > 1) return false;
}
return true;
}
]
}
);
assert.equal(result.passed, true, `bounds invariant violated: ${result.failures.length} failures`);
});
});