4fd4e20bd0
Systemic reliability gap found by the probe sweep: the compiled evaluation paths never emitted the reliability the engine computes. - Compiled _evaluateDirect omitted the relation's reliability, and the chain/multi_hop rules hardcoded reliability: 1.0 — so check() results reported 1.0 for any rule whose decision came through a chain, multi_hop, union, intersection, exclusion, or defeasible combination. - The chain and multi_hop traversals now track per-path reliability (product of edge reliabilities) and report the winning path's value; the compiled and fallback logical operators (union/intersection/exclusion, direct_list fast path, early exits) report the selected child's reliability (max/min child or OWA trace index; exclusion multiplies both legs), and normal-mode defeasible combines base x requires x defeater reliabilities. - The checker's logical fast path dropped collectedValues from union/ intersection/exclusion results; it now passes them through. - MultiHopRule.valueManager was read off relationManager where the real arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule with a value-carrying edge crashed the evaluation (error result, silent denial). Now resolved at the arbiter level with a relationManager fallback for stubs. Campaign pins: reliability per kind (chain/multi_hop product, union/intersection selected child, exclusion/defeasible product), and multi_hop value collection through persistent and partial contexts.
181 lines
7.9 KiB
JavaScript
181 lines
7.9 KiB
JavaScript
/**
|
|
* 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' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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'));
|
|
});
|
|
});
|