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.
This commit is contained in:
@@ -155,13 +155,18 @@ export class AuthorizationChecker {
|
|||||||
if (effectiveThreshold !== null && directRel.possibility < effectiveThreshold) {
|
if (effectiveThreshold !== null && directRel.possibility < effectiveThreshold) {
|
||||||
result = {
|
result = {
|
||||||
possibility: 0,
|
possibility: 0,
|
||||||
|
reliability: 0,
|
||||||
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
|
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
|
||||||
reason: 'threshold_not_met'
|
reason: 'threshold_not_met'
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
result = {
|
result = {
|
||||||
possibility: directRel.possibility,
|
possibility: directRel.possibility,
|
||||||
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
|
// A denied decision (possibility 0) must not leak the
|
||||||
|
// relation's reliability — the rule-collection path zeroes it.
|
||||||
|
reliability: directRel.possibility > 0
|
||||||
|
? (directRel.reliability !== undefined ? directRel.reliability : 1.0)
|
||||||
|
: 0,
|
||||||
...(includeMeta && {
|
...(includeMeta && {
|
||||||
meta: {
|
meta: {
|
||||||
allow: {
|
allow: {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Arbiter } from '../../src/index.js';
|
|||||||
|
|
||||||
const KEY = 'u:0|owner|doc:0';
|
const KEY = 'u:0|owner|doc:0';
|
||||||
const VALUES = [0.2, 0.5, 0.8, 0.9];
|
const VALUES = [0.2, 0.5, 0.8, 0.9];
|
||||||
|
const RELIABILITIES = [0.3, 0.6, 0.9];
|
||||||
|
|
||||||
function applyMirror(tuples, ops) {
|
function applyMirror(tuples, ops) {
|
||||||
for (const op of ops) {
|
for (const op of ops) {
|
||||||
@@ -26,7 +27,9 @@ function applyMirror(tuples, ops) {
|
|||||||
} else if (op.operation === 'modify' && !tuples.has(KEY)) {
|
} else if (op.operation === 'modify' && !tuples.has(KEY)) {
|
||||||
continue; // engine no-op on missing tuple
|
continue; // engine no-op on missing tuple
|
||||||
} else {
|
} else {
|
||||||
tuples.set(KEY, op.value);
|
// Last write wins for the whole tuple: reliability rides along, so
|
||||||
|
// the mirror carries it and the decision invariant can check it too.
|
||||||
|
tuples.set(KEY, { p: op.value, r: op.reliability });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +47,9 @@ function makeWrapper() {
|
|||||||
tuples,
|
tuples,
|
||||||
add(value) {
|
add(value) {
|
||||||
arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: value });
|
arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: value });
|
||||||
tuples.set(KEY, value);
|
// add-on-existing is a modify: reliability is preserved, not reset.
|
||||||
|
const existing = tuples.get(KEY);
|
||||||
|
tuples.set(KEY, { p: value, r: existing ? existing.r : 1.0 });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
remove() {
|
remove() {
|
||||||
@@ -58,7 +63,7 @@ function makeWrapper() {
|
|||||||
srcKey: 'u:0',
|
srcKey: 'u:0',
|
||||||
relation: 'owner',
|
relation: 'owner',
|
||||||
dstKey: 'doc:0',
|
dstKey: 'doc:0',
|
||||||
options: op.operation === 'remove' ? {} : { possibility: op.value }
|
options: op.operation === 'remove' ? {} : { possibility: op.value, reliability: op.reliability }
|
||||||
}));
|
}));
|
||||||
arbiter.relationManager.updateRelationsBatch(batch);
|
arbiter.relationManager.updateRelationsBatch(batch);
|
||||||
applyMirror(tuples, batchOps);
|
applyMirror(tuples, batchOps);
|
||||||
@@ -66,8 +71,10 @@ function makeWrapper() {
|
|||||||
},
|
},
|
||||||
check() {
|
check() {
|
||||||
const result = arbiter.check('u:0', 'can_read', 'doc:0');
|
const result = arbiter.check('u:0', 'can_read', 'doc:0');
|
||||||
const expected = tuples.get(KEY) ?? 0;
|
const entry = tuples.get(KEY);
|
||||||
return { engine: result.possibility, expected, reason: result.reason };
|
const expected = entry ? entry.p : 0;
|
||||||
|
const expectedReliability = entry ? entry.r : 0;
|
||||||
|
return { engine: result.possibility, expected, expectedReliability, reliability: result.reliability, reason: result.reason };
|
||||||
},
|
},
|
||||||
clone() {
|
clone() {
|
||||||
const fresh = makeWrapper();
|
const fresh = makeWrapper();
|
||||||
@@ -93,7 +100,8 @@ function makeWrapper() {
|
|||||||
|
|
||||||
const opGen = rigor.gen.record({
|
const opGen = rigor.gen.record({
|
||||||
operation: rigor.gen.enum(['add', 'modify', 'remove']),
|
operation: rigor.gen.enum(['add', 'modify', 'remove']),
|
||||||
value: rigor.gen.oneOf(VALUES)
|
value: rigor.gen.oneOf(VALUES),
|
||||||
|
reliability: rigor.gen.oneOf(RELIABILITIES)
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Batch update ordering semantics (rigor)', () => {
|
describe('Batch update ordering semantics (rigor)', () => {
|
||||||
@@ -140,6 +148,11 @@ describe('Batch update ordering semantics (rigor)', () => {
|
|||||||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||||||
return ctx.actual.engine === ctx.actual.expected;
|
return ctx.actual.engine === ctx.actual.expected;
|
||||||
}),
|
}),
|
||||||
|
rigor.invariant('reliability parity after every step', (ctx) => {
|
||||||
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||||||
|
if (ctx.actual.expectedReliability === 0) return true; // denied decision: reliability 0
|
||||||
|
return Math.abs((ctx.actual.reliability ?? 1.0) - ctx.actual.expectedReliability) < 1e-9;
|
||||||
|
}),
|
||||||
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
||||||
])
|
])
|
||||||
).run({ effort: 400, seed: 'batch-order-parity', maxTraceLength: 25 , artifacts: { dir: '', persist: 'never' }});
|
).run({ effort: 400, seed: 'batch-order-parity', maxTraceLength: 25 , artifacts: { dir: '', persist: 'never' }});
|
||||||
@@ -148,7 +161,7 @@ describe('Batch update ordering semantics (rigor)', () => {
|
|||||||
assert.equal(inv.passed, true, [
|
assert.equal(inv.passed, true, [
|
||||||
`batch ordering violated in ${inv.failureCount} cases:`,
|
`batch ordering violated in ${inv.failureCount} cases:`,
|
||||||
...result.failures.slice(0, 3).map((f) =>
|
...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}`
|
` [${f.name}] action=${f.actionName} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} actual=${JSON.stringify(f.actual)} error=${f.error}`
|
||||||
)
|
)
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ function makeModel() {
|
|||||||
copy.check = this.check;
|
copy.check = this.check;
|
||||||
return copy;
|
return copy;
|
||||||
},
|
},
|
||||||
add(src, rel, dst, p) {
|
add(src, rel, dst, p, reli) {
|
||||||
this.tuples.set(this.key(src, rel, dst), p);
|
this.tuples.set(this.key(src, rel, dst), { p, r: reli ?? 1.0 });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
remove(src, rel, dst) {
|
remove(src, rel, dst) {
|
||||||
@@ -61,22 +61,36 @@ function makeModel() {
|
|||||||
},
|
},
|
||||||
check(src, rel, dst) {
|
check(src, rel, dst) {
|
||||||
let possibility = 0;
|
let possibility = 0;
|
||||||
|
let reliability = 0;
|
||||||
if (rel === 'can_read') {
|
if (rel === 'can_read') {
|
||||||
for (const [k, p] of this.tuples) {
|
for (const [k, v] of this.tuples) {
|
||||||
if (k === this.key(src, 'owner', DOC)) possibility = Math.max(possibility, p);
|
if (k === this.key(src, 'owner', DOC)) {
|
||||||
|
if (v.p > possibility) {
|
||||||
|
possibility = v.p;
|
||||||
|
reliability = v.r;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (rel === 'can_access') {
|
} else if (rel === 'can_access') {
|
||||||
// The chain traverses member_of from ANY node, then reads into the
|
// The chain traverses member_of from ANY node, then reads into the
|
||||||
// object from any reached node — mirror the engine exactly.
|
// 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++) {
|
for (let m = 0; m < NODES; m++) {
|
||||||
const a = this.tuples.get(this.key(src, 'member_of', m));
|
const a = this.tuples.get(this.key(src, 'member_of', m));
|
||||||
const b = this.tuples.get(this.key(m, 'reads', DOC));
|
const b = this.tuples.get(this.key(m, 'reads', DOC));
|
||||||
if (a !== undefined && b !== undefined) {
|
if (a !== undefined && b !== undefined) {
|
||||||
possibility = Math.max(possibility, Math.min(a, b));
|
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 };
|
return {
|
||||||
|
possibility: Math.round(possibility * 10000) / 10000,
|
||||||
|
reliability: Math.round(reliability * 10000) / 10000
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,9 +113,9 @@ function makeSut() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ops,
|
ops,
|
||||||
addRelation(src, rel, dst, p) {
|
addRelation(src, rel, dst, p, reli) {
|
||||||
arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p });
|
arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p, reliability: reli });
|
||||||
ops.push(['add', src, rel, dst, p]);
|
ops.push(['add', src, rel, dst, p, reli]);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
removeRelation(src, rel, dst) {
|
removeRelation(src, rel, dst) {
|
||||||
@@ -111,12 +125,15 @@ function makeSut() {
|
|||||||
},
|
},
|
||||||
check(src, rel, dst) {
|
check(src, rel, dst) {
|
||||||
const result = arbiter.check(nodeKey(src), rel, nodeKey(dst));
|
const result = arbiter.check(nodeKey(src), rel, nodeKey(dst));
|
||||||
return { possibility: Math.round(result.possibility * 10000) / 10000 };
|
return {
|
||||||
|
possibility: Math.round(result.possibility * 10000) / 10000,
|
||||||
|
reliability: Math.round((result.reliability ?? 0) * 10000) / 10000
|
||||||
|
};
|
||||||
},
|
},
|
||||||
clone() {
|
clone() {
|
||||||
const fresh = makeSut();
|
const fresh = makeSut();
|
||||||
for (const op of ops) {
|
for (const op of ops) {
|
||||||
if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4]);
|
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]);
|
else fresh.removeRelation(op[1], op[2], op[3]);
|
||||||
}
|
}
|
||||||
return fresh;
|
return fresh;
|
||||||
@@ -128,7 +145,8 @@ const tupleArgs = rigor.gen.tuple(
|
|||||||
rigor.gen.int(0, NODES - 1),
|
rigor.gen.int(0, NODES - 1),
|
||||||
rigor.gen.enum(['owner', 'member_of', 'reads']),
|
rigor.gen.enum(['owner', 'member_of', 'reads']),
|
||||||
rigor.gen.int(0, NODES - 1),
|
rigor.gen.int(0, NODES - 1),
|
||||||
rigor.gen.oneOf(POS)
|
rigor.gen.oneOf(POS),
|
||||||
|
rigor.gen.oneOf([0.3, 0.6, 0.9])
|
||||||
);
|
);
|
||||||
const removeArgs = rigor.gen.tuple(
|
const removeArgs = rigor.gen.tuple(
|
||||||
rigor.gen.int(0, NODES - 1),
|
rigor.gen.int(0, NODES - 1),
|
||||||
@@ -145,7 +163,7 @@ const OPERATIONS = [
|
|||||||
{
|
{
|
||||||
name: 'addRelation',
|
name: 'addRelation',
|
||||||
args: tupleArgs,
|
args: tupleArgs,
|
||||||
run: (model, src, rel, dst, p) => model.add(src, rel, dst, p)
|
run: (model, src, rel, dst, p, reli) => model.add(src, rel, dst, p, reli)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'removeRelation',
|
name: 'removeRelation',
|
||||||
|
|||||||
@@ -582,15 +582,15 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
|
|||||||
|
|
||||||
const w = {
|
const w = {
|
||||||
engine,
|
engine,
|
||||||
setEdge(rel, dst, p, side) {
|
setEdge(rel, dst, p, reli, side) {
|
||||||
const src = rel === 'owner' ? 'doc:0' : 'u:0';
|
const src = rel === 'owner' ? 'doc:0' : 'u:0';
|
||||||
if (side === 'persistent') {
|
if (side === 'persistent') {
|
||||||
engine.addRelation(src, rel, dst, { possibility: p });
|
engine.addRelation(src, rel, dst, { possibility: p, reliability: reli });
|
||||||
persistent.set(src + '|' + rel + '|' + dst, { p });
|
persistent.set(src + '|' + rel + '|' + dst, { p, r: reli });
|
||||||
} else {
|
} else {
|
||||||
const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst);
|
const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst);
|
||||||
if (idx >= 0) partialEdges.splice(idx, 1);
|
if (idx >= 0) partialEdges.splice(idx, 1);
|
||||||
partialEdges.push({ src, relation: rel, dst, possibility: p });
|
partialEdges.push({ src, relation: rel, dst, possibility: p, reliability: reli });
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
@@ -609,25 +609,37 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
|
|||||||
: {};
|
: {};
|
||||||
const r = engine.check('u:0', 'can_read', 'doc:0', options);
|
const r = engine.check('u:0', 'can_read', 'doc:0', options);
|
||||||
// TTU mirror: join both legs per intermediate — max over mids of
|
// TTU mirror: join both legs per intermediate — max over mids of
|
||||||
// min(tupleset.possibility, computed.possibility). Edges come from
|
// min(tupleset.possibility, computed.possibility), with the winning
|
||||||
// persistent and/or partial; on same-tuple conflicts the overlay
|
// intermediate's reliability = tupleset.reli * computed.reli. Edges
|
||||||
// contract is persistent-wins (persistent outranks partial trust).
|
// come from persistent and/or partial; on same-tuple conflicts the
|
||||||
const tupleset = new Map(); // dst -> p
|
// overlay contract is persistent-wins (persistent outranks partial).
|
||||||
const computed = new Map(); // dst -> p
|
const tupleset = new Map(); // dst -> { p, r }
|
||||||
|
const computed = new Map(); // dst -> { p, r }
|
||||||
for (const [key, v] of persistent) {
|
for (const [key, v] of persistent) {
|
||||||
const [src, rel, dst] = key.split('|');
|
const [src, rel, dst] = key.split('|');
|
||||||
(rel === 'owner' ? tupleset : computed).set(dst, v.p);
|
(rel === 'owner' ? tupleset : computed).set(dst, v);
|
||||||
}
|
}
|
||||||
for (const e of partialEdges) {
|
for (const e of partialEdges) {
|
||||||
const merged = e.relation === 'owner' ? tupleset : computed;
|
const merged = e.relation === 'owner' ? tupleset : computed;
|
||||||
if (!merged.has(e.dst)) merged.set(e.dst, e.possibility);
|
if (!merged.has(e.dst)) merged.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0 });
|
||||||
}
|
}
|
||||||
let best = 0;
|
let best = 0;
|
||||||
|
let bestReliability = 0;
|
||||||
for (const [mid, tp] of tupleset) {
|
for (const [mid, tp] of tupleset) {
|
||||||
const cp = computed.get(mid);
|
const cp = computed.get(mid);
|
||||||
if (cp !== undefined && Math.min(tp, cp) > best) best = Math.min(tp, cp);
|
if (cp === undefined) continue;
|
||||||
|
const combinedP = Math.min(tp.p, cp.p);
|
||||||
|
if (combinedP > best) {
|
||||||
|
best = combinedP;
|
||||||
|
bestReliability = (tp.r ?? 1.0) * (cp.r ?? 1.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { engine: round4(r.possibility), expected: round4(best) };
|
return {
|
||||||
|
engine: round4(r.possibility),
|
||||||
|
expected: round4(best),
|
||||||
|
engineReliability: round4(r.reliability ?? 0),
|
||||||
|
expectedReliability: round4(bestReliability)
|
||||||
|
};
|
||||||
},
|
},
|
||||||
clone() { return w; }
|
clone() { return w; }
|
||||||
};
|
};
|
||||||
@@ -636,11 +648,12 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
|
|||||||
|
|
||||||
const result = await rigor.campaign(
|
const result = await rigor.campaign(
|
||||||
[rigor.object('graph', makeWrapper, [
|
[rigor.object('graph', makeWrapper, [
|
||||||
rigor.method('setEdge', function (rel, dst, p, side) { return this.setEdge(rel, dst, p, side); },
|
rigor.method('setEdge', function (rel, dst, p, reli, side) { return this.setEdge(rel, dst, p, reli, side); },
|
||||||
rigor.args(
|
rigor.args(
|
||||||
rigor.gen.oneOf(['owner', 'member_of']),
|
rigor.gen.oneOf(['owner', 'member_of']),
|
||||||
rigor.gen.oneOf(['g:0', 'g:1', 'g:2']),
|
rigor.gen.oneOf(['g:0', 'g:1', 'g:2']),
|
||||||
rigor.gen.float(0.1, 1.0),
|
rigor.gen.float(0.1, 1.0),
|
||||||
|
rigor.gen.oneOf([0.3, 0.6, 0.9]),
|
||||||
rigor.gen.oneOf(['persistent', 'partial'])
|
rigor.gen.oneOf(['persistent', 'partial'])
|
||||||
)),
|
)),
|
||||||
rigor.method('clearAll', function () { return this.clearAll(); }),
|
rigor.method('clearAll', function () { return this.clearAll(); }),
|
||||||
@@ -651,6 +664,11 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
|
|||||||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||||||
return ctx.actual.engine === ctx.actual.expected;
|
return ctx.actual.engine === ctx.actual.expected;
|
||||||
}),
|
}),
|
||||||
|
rigor.invariant('TTU reliability parity', (ctx) => {
|
||||||
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||||||
|
if (ctx.actual.expected === 0) return true; // denied: reliability 0
|
||||||
|
return ctx.actual.engineReliability === ctx.actual.expectedReliability;
|
||||||
|
}),
|
||||||
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
||||||
])
|
])
|
||||||
).run({ effort: 400, seed: "ttu-partial-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
).run({ effort: 400, seed: "ttu-partial-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
||||||
@@ -720,6 +738,126 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
|
|||||||
].join('\n'));
|
].join('\n'));
|
||||||
}, 90000);
|
}, 90000);
|
||||||
|
|
||||||
|
it('PROPERTY CAMPAIGN: chain reliability differential under random edge splits', async () => {
|
||||||
|
function makeWrapper() {
|
||||||
|
const engine = new Arbiter();
|
||||||
|
engine.addNode('u:0', 'user');
|
||||||
|
engine.addNode('doc:0', 'doc');
|
||||||
|
engine.addNode('g:0', 'group');
|
||||||
|
engine.addNode('g:1', 'group');
|
||||||
|
engine.setRelationConfig('can_access', { type: 'chain', steps: [
|
||||||
|
{ relation: 'member_of', direction: 'out' },
|
||||||
|
{ relation: 'reads', direction: 'out' }
|
||||||
|
] });
|
||||||
|
|
||||||
|
const persistent = new Map();
|
||||||
|
const partialEdges = [];
|
||||||
|
|
||||||
|
const w = {
|
||||||
|
engine,
|
||||||
|
setEdge(rel, dst, p, reli, side) {
|
||||||
|
const src = rel === 'member_of' ? 'u:0' : 'g:0';
|
||||||
|
if (rel === 'reads') dst = 'doc:0'; // the chain's second leg must point at the target
|
||||||
|
if (side === 'persistent') {
|
||||||
|
engine.addRelation(src, rel, dst, { possibility: p, reliability: reli });
|
||||||
|
persistent.set(src + '|' + rel + '|' + dst, { p, r: reli });
|
||||||
|
} else {
|
||||||
|
const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst);
|
||||||
|
if (idx >= 0) partialEdges.splice(idx, 1);
|
||||||
|
partialEdges.push({ src, relation: rel, dst, possibility: p, reliability: reli });
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
for (const key of [...persistent.keys()]) {
|
||||||
|
const [src, rel, dst] = key.split('|');
|
||||||
|
engine.removeRelation(src, rel, dst);
|
||||||
|
}
|
||||||
|
persistent.clear();
|
||||||
|
partialEdges.length = 0;
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
check() {
|
||||||
|
const options = partialEdges.length > 0
|
||||||
|
? { partialGraph: { relations: partialEdges.slice() } }
|
||||||
|
: {};
|
||||||
|
const r = engine.check('u:0', 'can_access', 'doc:0', options);
|
||||||
|
// Mirror: chain = max over intermediates of min(legs); the winning
|
||||||
|
// path's reliability is the product of its edges' reliabilities.
|
||||||
|
// Same-tuple conflicts: persistent wins.
|
||||||
|
const memberOf = new Map(); // dst -> { p, r }
|
||||||
|
const reads = new Map(); // src -> { p, r } (edge src ->reads-> doc:0)
|
||||||
|
for (const [key, v] of persistent) {
|
||||||
|
const [src, rel, dst] = key.split('|');
|
||||||
|
if (rel === 'member_of') memberOf.set(dst, v);
|
||||||
|
else reads.set(src, v);
|
||||||
|
}
|
||||||
|
for (const e of partialEdges) {
|
||||||
|
if (e.relation === 'member_of') {
|
||||||
|
if (!memberOf.has(e.dst)) memberOf.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0 });
|
||||||
|
} else {
|
||||||
|
if (!reads.has(e.src)) reads.set(e.src, { p: e.possibility, r: e.reliability ?? 1.0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let best = 0;
|
||||||
|
let bestReliability = 0;
|
||||||
|
for (const [mid, m] of memberOf) {
|
||||||
|
const rd = reads.get(mid);
|
||||||
|
if (rd === undefined) continue;
|
||||||
|
const combinedP = Math.min(m.p, rd.p);
|
||||||
|
if (combinedP > best) {
|
||||||
|
best = combinedP;
|
||||||
|
bestReliability = (m.r ?? 1.0) * (rd.r ?? 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
engine: round4(r.possibility),
|
||||||
|
expected: round4(best),
|
||||||
|
engineReliability: round4(r.reliability ?? 0),
|
||||||
|
expectedReliability: round4(bestReliability)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clone() { return w; }
|
||||||
|
};
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await rigor.campaign(
|
||||||
|
[rigor.object('graph', makeWrapper, [
|
||||||
|
rigor.method('setEdge', function (rel, dst, p, reli, side) { return this.setEdge(rel, dst, p, reli, side); },
|
||||||
|
rigor.args(
|
||||||
|
rigor.gen.oneOf(['member_of', 'reads']),
|
||||||
|
rigor.gen.oneOf(['g:0', 'g:1', 'doc:0']),
|
||||||
|
rigor.gen.float(0.1, 1.0),
|
||||||
|
rigor.gen.oneOf([0.3, 0.6, 0.9]),
|
||||||
|
rigor.gen.oneOf(['persistent', 'partial'])
|
||||||
|
)),
|
||||||
|
rigor.method('clearAll', function () { return this.clearAll(); }),
|
||||||
|
rigor.method('check', function () { return this.check(); })
|
||||||
|
])],
|
||||||
|
rigor.crucible([
|
||||||
|
rigor.invariant('chain decision parity', (ctx) => {
|
||||||
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||||||
|
return ctx.actual.engine === ctx.actual.expected;
|
||||||
|
}),
|
||||||
|
rigor.invariant('chain reliability parity', (ctx) => {
|
||||||
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||||||
|
if (ctx.actual.expected === 0) return true; // denied: reliability 0
|
||||||
|
return ctx.actual.engineReliability === ctx.actual.expectedReliability;
|
||||||
|
}),
|
||||||
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
||||||
|
])
|
||||||
|
).run({ effort: 400, seed: "chain-reliability-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
||||||
|
|
||||||
|
const inv = result.crucibleVerdict;
|
||||||
|
assert.equal(inv.passed, true, [
|
||||||
|
`chain reliability violated in ${inv.failureCount} cases:`,
|
||||||
|
...result.failures.slice(0, 3).map((f) =>
|
||||||
|
` [${f.name}] action=${f.actionName} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} error=${f.error}`
|
||||||
|
)
|
||||||
|
].join('\n'));
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
it('PROPERTY CAMPAIGN: exclusion differential under random split edges', async () => {
|
it('PROPERTY CAMPAIGN: exclusion differential under random split edges', async () => {
|
||||||
function makeWrapper() {
|
function makeWrapper() {
|
||||||
const engine = new Arbiter();
|
const engine = new Arbiter();
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ function buildGraph(edges, values) {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
for (let i = 0; i < edges.length; i++) {
|
for (let i = 0; i < edges.length; i++) {
|
||||||
arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), { possibility: values[i] });
|
arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), {
|
||||||
|
possibility: values[i],
|
||||||
|
// deterministic per-edge reliability so the round-trip pins the
|
||||||
|
// codec's reliability channel, not just possibility
|
||||||
|
reliability: 0.1 + ((i * 7 + 3) % 9) / 10
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return arb;
|
return arb;
|
||||||
}
|
}
|
||||||
@@ -64,12 +69,14 @@ for (const u of [0, 1]) {
|
|||||||
function roundTripReport(edges, values) {
|
function roundTripReport(edges, values) {
|
||||||
const live = buildGraph(edges, values);
|
const live = buildGraph(edges, values);
|
||||||
const before = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
const before = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||||
|
const beforeReliability = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).reliability ?? 0);
|
||||||
|
|
||||||
live.enableCondensedSnapshot();
|
live.enableCondensedSnapshot();
|
||||||
const buffer = serializeArbiterSnapshot(live);
|
const buffer = serializeArbiterSnapshot(live);
|
||||||
|
|
||||||
const r1 = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
|
const r1 = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
|
||||||
const after1 = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
const after1 = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||||
|
const after1Reliability = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).reliability ?? 0);
|
||||||
|
|
||||||
const r1b = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
|
const r1b = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
|
||||||
const after1b = QUERIES.map(([rel, u, d]) => r1b.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
const after1b = QUERIES.map(([rel, u, d]) => r1b.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||||
@@ -78,7 +85,7 @@ function roundTripReport(edges, values) {
|
|||||||
const r2 = ArbiterSnapshot.fromSnapshotBinary(buffer2, {}, () => new Arbiter());
|
const r2 = ArbiterSnapshot.fromSnapshotBinary(buffer2, {}, () => new Arbiter());
|
||||||
const after2 = QUERIES.map(([rel, u, d]) => r2.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
const after2 = QUERIES.map(([rel, u, d]) => r2.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||||
|
|
||||||
return { before, after1, after1b, after2, graphBytes: live.snapshotGraph.toBinary().byteLength };
|
return { before, after1, after1b, after2, beforeReliability, after1Reliability, graphBytes: live.snapshotGraph.toBinary().byteLength };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Condensed snapshot quantization parity (rigor)', () => {
|
describe('Condensed snapshot quantization parity (rigor)', () => {
|
||||||
@@ -106,6 +113,16 @@ describe('Condensed snapshot quantization parity (rigor)', () => {
|
|||||||
}
|
}
|
||||||
assert.deepEqual(r.after1, r.after1b, 'restoring the same buffer is deterministic');
|
assert.deepEqual(r.after1, r.after1b, 'restoring the same buffer is deterministic');
|
||||||
assert.deepEqual(r.after1, r.after2, 'snapshot-of-snapshot preserves values');
|
assert.deepEqual(r.after1, r.after2, 'snapshot-of-snapshot preserves values');
|
||||||
|
// Reliability passes through the same 16-bit quantization; chain
|
||||||
|
// reliabilities multiply two quantized inputs, so the band is 4x the
|
||||||
|
// single-value tolerance (still far tighter than any codec loss).
|
||||||
|
const reliabilityTol = TOL * 4;
|
||||||
|
for (let i = 0; i < r.beforeReliability.length; i++) {
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(r.after1Reliability[i] - r.beforeReliability[i]) <= reliabilityTol,
|
||||||
|
`query ${i}: live reliability=${r.beforeReliability[i]} restored=${r.after1Reliability[i]} exceeds tolerance ${reliabilityTol}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const g1 = buildGraph(edges, values);
|
const g1 = buildGraph(edges, values);
|
||||||
g1.enableCondensedSnapshot();
|
g1.enableCondensedSnapshot();
|
||||||
|
|||||||
Reference in New Issue
Block a user