Files
core/tests/rigor/input-range-parity.test.js
T

181 lines
7.9 KiB
JavaScript
Raw Normal View History

/**
* rigor/input-range-parity.test.js — write-boundary possibility validation
* and id-hygiene contracts.
*
* The engine contract for addRelation()/removeRelation() writes:
* - possibility must be a finite number in [0, 1]; anything else THROWS
* and leaves the graph untouched (no partial writes).
* - undefined possibility defaults to 1.0 (pre-existing contract).
* - node keys and relation names are exact-match strings: a numeric key
* is a DIFFERENT node than its string form (missing_node), and '|'
* inside keys/relation names is harmless (keys are numeric ids in the
* cache layer, so delimiter injection is structurally impossible).
*
* Two arms:
* 1. FIXED MATRIX — every invalid shape is rejected, every boundary value
* accepted, graph state preserved across failed writes.
* 2. STATE PROPERTY CAMPAIGN — random add/check sequences against a mirror
* model; the mirror independently classifies each possibility as
* valid/invalid and the engine must agree exactly, plus check()
* results always stay in [0, 1].
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
const U = 2; // users
const D = U + 2; // docs
const NODES = D + 1;
function nodeKey(id) {
if (id < U) return `u:${id}`;
if (id < D) return `doc:${id - U}`;
return 'g:0';
}
function isValid(p) {
return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1;
}
function makeWrapper() {
const arbiter = new Arbiter();
for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < U ? 'user' : 'doc');
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
const tuples = new Map();
const tupleKey = (src, rel, dst) => `${src}|${rel}|${dst}`;
return {
tuples,
arbiter,
add(src, rel, dst, p) {
const key = nodeKey(src);
const dstKey = nodeKey(dst);
if (p === undefined || isValid(p)) {
arbiter.addRelation(key, rel, dstKey, p === undefined ? undefined : { possibility: p });
tuples.set(tupleKey(key, rel, dstKey), p === undefined ? 1.0 : p);
return { accepted: true };
}
return { accepted: false };
},
check(src, rel, dst) {
const result = arbiter.check(nodeKey(src), rel, nodeKey(dst));
let expected = 0;
for (const [k, p] of tuples) {
if (k === tupleKey(nodeKey(src), 'owner', nodeKey(dst))) expected = Math.max(expected, p);
}
const engine = typeof result.possibility === 'number' && Number.isFinite(result.possibility) ? result.possibility : -1;
return { engine: Math.round(engine * 10000) / 10000, expected: Math.round(expected * 10000) / 10000 };
},
checkNumericId(src, rel, dst) {
const result = arbiter.check(src, rel, dst);
return result.reason;
},
clone() {
const fresh = makeWrapper();
for (const [k, p] of tuples) {
const [src, rel, dst] = k.split('|');
fresh.arbiter.addRelation(src, rel, dst, { possibility: p });
fresh.tuples.set(k, p);
}
return fresh;
}
};
}
describe('Possibility write-boundary validation (rigor)', () => {
it('FIXED MATRIX: invalid possibilities throw and leave the graph untouched; boundaries accepted', () => {
const w = makeWrapper();
const invalid = [2.0, -1, -0.0001, 1.0001, NaN, Infinity, -Infinity, null];
for (const p of invalid) {
assert.throws(
() => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p }),
/Invalid possibility/,
`possibility ${String(p)} must be rejected`
);
assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0, 'graph must stay untouched');
}
for (const p of [0, 1, 0.001, 0.99999, 0.3]) {
w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p });
const got = w.arbiter.check('u:0', 'can_read', 'doc:0').possibility;
assert.ok(Math.abs(got - p) < 1e-12, `boundary value ${p} accepted and returned (got ${got})`);
}
w.arbiter.addRelation('u:0', 'owner', 'doc:1', {});
assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:1').possibility, 1.0, 'undefined possibility defaults to 1.0 on create');
assert.throws(
() => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: -0.5 }),
/Invalid possibility/
);
assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'failed modify keeps old value');
const fresh = makeWrapper();
fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.3 });
fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', {});
assert.equal(fresh.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'modify with undefined possibility preserves old value');
});
it('ID HYGIENE: numeric keys are distinct nodes; pipe characters are harmless', () => {
const w = makeWrapper();
w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.9 });
assert.equal(w.checkNumericId('u:0', 'can_read', 'doc:0'), 'direct_match', 'string keys resolve');
assert.equal(w.checkNumericId(0, 'can_read', 2), 'missing_node', 'numeric keys are different nodes');
assert.equal(w.arbiter.keyManager.getStringId('u:0') !== w.arbiter.keyManager.getStringId(0), true, 'string/number ids never collide');
assert.equal(w.arbiter.keyManager.getStringId('a|b') !== w.arbiter.keyManager.getStringId('a'), true, 'pipe-bearing keys are distinct');
w.arbiter.setRelationConfig('r|x', { type: 'direct', relation: 'owner' });
w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.7 });
assert.equal(w.arbiter.check('u:0', 'r|x', 'doc:0').possibility, 0.7, 'pipe in relation name works');
assert.equal(w.arbiter.check('u:0', 'r', 'doc:0').possibility, 0, 'relation r is NOT r|x');
});
it('PROPERTY CAMPAIGN: engine write contract agrees with the independent classifier', async () => {
const addArgs = 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([0.1, 0.5, 0.9, NaN, Infinity, -Infinity, 2.5, -0.5, 1.001, null, undefined])
);
const checkArgs = rigor.gen.tuple(
rigor.gen.int(0, U - 1),
rigor.gen.constant('can_read'),
rigor.gen.constant(D)
);
const result = await rigor.campaign(
[rigor.object('graph', makeWrapper, [
rigor.method('add', function (src, rel, dst, p) { return this.add(src, rel, dst, p); },
rigor.args(addArgs)),
rigor.method('check', function (src, rel, dst) { return this.check(src, rel, dst); },
rigor.args(checkArgs))
])],
rigor.crucible([
rigor.invariant('rejected iff invalid', (ctx) => {
if (ctx.action !== 'graph.add') return true;
const p = ctx.args[3];
return ctx.actual.accepted === (p === undefined || isValid(p));
}),
rigor.invariant('no partial writes on rejection', (ctx) => {
if (ctx.action !== 'graph.add') return true;
if (ctx.actual.accepted) return true;
return ctx.error === null;
}),
rigor.invariant('check parity with mirror', (ctx) => {
if (ctx.action !== 'graph.check') return true;
return ctx.actual.engine === ctx.actual.expected;
}),
rigor.invariant('possibility always in [0,1] or absent', (ctx) => {
if (ctx.action !== 'graph.check') return true;
return ctx.actual.engine >= 0 && ctx.actual.engine <= 1;
})
])
).run({ effort: 400, seed: 'input-range-contract' });
const inv = result.crucibleVerdict;
assert.equal(inv.passed, true, [
`engine diverged from contract in ${inv.failureCount} cases:`,
...result.failures.slice(0, 3).map((f) =>
` [${f.action}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
)
].join('\n'));
});
});