02d7a5dd75
Bug 34: ArbiterChecks.check destructured { reliability, ...rest } out of
every normal (and binary) check result since the initial commit — the
engine computes reliability faithfully (TTU tupleset.reli * computed.reli,
direct relation reliability) and explain() preserved it, but the public
check() API silently dropped it. explain() kept it, so exposing it in
check() is the intended contract. The destructuring is removed; direct
and TTU checks now return reliability (0.6 / 0.72 in the pins).
Campaign pins added: TTU multi-path fusion (max over intermediates of
min(legs)) with reliability propagation through both persistent and
partial contexts, the maxIntermediates circuit breaker through partial,
and snapshot-restored arbiters evaluating TTU.
677 lines
33 KiB
JavaScript
677 lines
33 KiB
JavaScript
/**
|
||
* rigor/rule-kind-partial-parity.test.js — rule-kind × partial-graph parity.
|
||
*
|
||
* Pins the confirmed contracts discovered by probe sweeps:
|
||
* - every rule kind must produce identical semantics whether its edges come
|
||
* from persistent storage or a partial graph context (or a split mix)
|
||
* - TTU orientation is user --computed--> intermediate <-tupleset-- object
|
||
* (reverse: user --tupleset--> intermediate <-computed-- object)
|
||
* - relational_comparator operands read value relations on the (user,object)
|
||
* pair (left, auto) and the object self-loop (right, object perspective)
|
||
* - challenge rules consume partialGraph.challenges proofs with subject and
|
||
* expiry semantics
|
||
* - binary mode must agree with normal mode at the same threshold
|
||
*
|
||
* Additions in this round:
|
||
* - differential property campaigns for TTU, comparator, and exclusion
|
||
* under random persistent/partial splits with seeded runs
|
||
*/
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { rigor } from '@rigor/core';
|
||
import { Arbiter } from '../../src/index.js';
|
||
|
||
const T = 0.0001;
|
||
|
||
function round4(v) { return Math.round(v * 10000) / 10000; }
|
||
|
||
function mkArbiter(opts = {}) {
|
||
const a = new Arbiter(opts);
|
||
a.addNode('u:0', 'user');
|
||
a.addNode('doc:0', 'doc');
|
||
a.addNode('g:0', 'group');
|
||
a.addNode('g:1', 'group');
|
||
a.addNode('g:2', 'group');
|
||
a.addNode('doc:1', 'doc');
|
||
return a;
|
||
}
|
||
|
||
describe('Rule-kind × partial-graph parity (rigor)', () => {
|
||
|
||
it('FIXED MATRIX: every kind matches between persistent and partial', async () => {
|
||
// ---- direct ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
||
assert.equal(round4(a.check('u:0', 'can_read', 'doc:0').possibility), 0.8, 'direct persistent');
|
||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||
const r = a.check('u:0', 'can_read', 'doc:0', {
|
||
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.8, 'direct partial');
|
||
}
|
||
// ---- chain (2-step) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'chain', steps: [
|
||
{ relation: 'member_of', direction: 'out' },
|
||
{ relation: 'reads', direction: 'out' }
|
||
] });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
||
a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'chain persistent');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
a.removeRelation('g:0', 'reads', 'doc:0');
|
||
const r = a.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(round4(r.possibility), 0.7, 'chain partial');
|
||
}
|
||
// ---- multi_hop ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', maxDepth: 3 });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
||
a.addRelation('g:0', 'member_of', 'g:1', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'g:1').possibility), 0.7, 'multi_hop persistent');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
a.removeRelation('g:0', 'member_of', 'g:1');
|
||
const r = a.check('u:0', 'can_access', 'g:1', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
|
||
{ src: 'g:0', relation: 'member_of', dst: 'g:1', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'multi_hop partial');
|
||
}
|
||
// ---- TTU (user --computed--> intermediate <-tupleset-- object) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.8 });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_read', 'doc:0').possibility), 0.7, 'ttu persistent');
|
||
a.removeRelation('doc:0', 'owner', 'g:0');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
const r = a.check('u:0', 'can_read', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.8 },
|
||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'ttu partial');
|
||
}
|
||
// ---- TTU reverse (user --tupleset--> intermediate <-computed-- object) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_view', { type: 'tuple_to_userset', tuplesetRelation: 'member_of', computedRelation: 'viewable', reverse: true });
|
||
a.setRelationConfig('viewable', { type: 'direct', relation: 'viewable' });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
||
a.addRelation('doc:0', 'viewable', 'g:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_view', 'doc:0').possibility), 0.7, 'ttu reverse persistent');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
a.removeRelation('doc:0', 'viewable', 'g:0');
|
||
const r = a.check('u:0', 'can_view', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
|
||
{ src: 'doc:0', relation: 'viewable', dst: 'g:0', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'ttu reverse partial');
|
||
}
|
||
// ---- TTU tuplesetDirection in ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'tuple_to_userset', tuplesetRelation: 'belongs_to', computedRelation: 'member_of', tuplesetDirection: 'in' });
|
||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
a.addRelation('g:0', 'belongs_to', 'doc:0', { possibility: 0.8 });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'ttu-in persistent');
|
||
a.removeRelation('g:0', 'belongs_to', 'doc:0');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'g:0', relation: 'belongs_to', dst: 'doc:0', possibility: 0.8 },
|
||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'ttu-in partial');
|
||
}
|
||
// ---- parent ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_edit', { type: 'parent', parentRelation: 'parent', relation: 'owner' });
|
||
a.addRelation('doc:0', 'parent', 'doc:1', { possibility: 1.0 });
|
||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.9 });
|
||
assert.equal(round4(a.check('u:0', 'can_edit', 'doc:1').possibility), 0.9, 'parent persistent');
|
||
a.removeRelation('doc:0', 'parent', 'doc:1');
|
||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||
const r = a.check('u:0', 'can_edit', 'doc:1', {
|
||
partialGraph: { relations: [
|
||
{ src: 'doc:0', relation: 'parent', dst: 'doc:1', possibility: 1.0 },
|
||
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.9, 'parent partial');
|
||
}
|
||
// ---- computed alias (target needs its own config) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('is_member', { type: 'computed', relation: 'member_of' });
|
||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'is_member', 'g:0').possibility), 0.7, 'computed persistent');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
const r = a.check('u:0', 'is_member', 'g:0', {
|
||
partialGraph: { relations: [{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 }] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'computed partial');
|
||
}
|
||
// ---- defeasible when/unless ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } });
|
||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
||
a.addRelation('u:0', 'banned', 'doc:0', { possibility: 0.5 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.4, 'defeasible persistent');
|
||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||
a.removeRelation('u:0', 'banned', 'doc:0');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 },
|
||
{ src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0.5 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.4, 'defeasible partial');
|
||
}
|
||
// ---- union takes max ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { union: { rules: [{ relation: 'owner' }, { relation: 'editor' }] } });
|
||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.8, 'union persistent');
|
||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 },
|
||
{ src: 'u:0', relation: 'editor', dst: 'doc:0', possibility: 0.9 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.9, 'union partial');
|
||
}
|
||
// ---- exclusion P(A)*(1-P(B)) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { exclusion: [{ relation: 'owner' }, { relation: 'banned' }] });
|
||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
||
a.addRelation('u:0', 'banned', 'doc:0', { possibility: 0.5 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.4, 'exclusion persistent');
|
||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||
a.removeRelation('u:0', 'banned', 'doc:0');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 },
|
||
{ src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0.5 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.4, 'exclusion partial');
|
||
}
|
||
// ---- relational_comparator (left on user->object, right on object self-loop) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', {
|
||
type: 'relational_comparator', comparator: '>=',
|
||
left: { rule: { type: 'direct', relation: 'has_clearance' }, extractValue: true },
|
||
right: { rule: { type: 'direct', relation: 'requested_level' }, extractValue: true, evaluateFrom: 'object' }
|
||
});
|
||
a.addRelation('u:0', 'has_clearance', 'doc:0', { possibility: 0.9, value: 7 });
|
||
a.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value: 4 });
|
||
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 1, 'comparator persistent');
|
||
a.removeRelation('u:0', 'has_clearance', 'doc:0');
|
||
a.removeRelation('doc:0', 'requested_level', 'doc:0');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'has_clearance', dst: 'doc:0', possibility: 0.9, value: 7 },
|
||
{ src: 'doc:0', relation: 'requested_level', dst: 'doc:0', possibility: 1.0, value: 4 }
|
||
] }
|
||
});
|
||
assert.equal(r.possibility, 1, 'comparator partial');
|
||
}
|
||
// ---- challenge via partialGraph.challenges ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_download', { type: 'challenge', challenge: 'captcha', subject: 'user', withinMinutes: 5 });
|
||
const now = Date.now();
|
||
const ok = a.check('u:0', 'can_download', 'doc:0', {
|
||
partialGraph: { challenges: [{ name: 'captcha', subject: 'u:0', issuedAt: now - 60000, expiresAt: now + 60000 }] }
|
||
});
|
||
assert.equal(ok.possibility, 1, 'challenge satisfied');
|
||
const expired = a.check('u:0', 'can_download', 'doc:0', {
|
||
partialGraph: { challenges: [{ name: 'captcha', subject: 'u:0', issuedAt: now - 600000, expiresAt: now - 300000 }] }
|
||
});
|
||
assert.equal(expired.possibility, 0, 'challenge expired');
|
||
assert.equal(a.check('u:0', 'can_download', 'doc:0').possibility, 0, 'challenge missing context');
|
||
}
|
||
// ---- TTU reverse + tuplesetDirection in (intermediates point AT the user) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_view', { type: 'tuple_to_userset', tuplesetRelation: 'member_of', computedRelation: 'viewable', reverse: true, tuplesetDirection: 'in' });
|
||
a.setRelationConfig('viewable', { type: 'direct', relation: 'viewable' });
|
||
a.addRelation('g:0', 'member_of', 'u:0', { possibility: 0.8 });
|
||
a.addRelation('doc:0', 'viewable', 'g:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_view', 'doc:0').possibility), 0.7, 'ttu reverse-in persistent optimized');
|
||
assert.equal(round4(a.check('u:0', 'can_view', 'doc:0', { useCompiled: false }).possibility), 0.7, 'ttu reverse-in persistent fallback');
|
||
a.removeRelation('g:0', 'member_of', 'u:0');
|
||
a.removeRelation('doc:0', 'viewable', 'g:0');
|
||
const r = a.check('u:0', 'can_view', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'g:0', relation: 'member_of', dst: 'u:0', possibility: 0.8 },
|
||
{ src: 'doc:0', relation: 'viewable', dst: 'g:0', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'ttu reverse-in partial');
|
||
}
|
||
// ---- multi_hop reverse (backward walk: doc ->member_of-> g:1 ->member_of-> u:0) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', reverse: true, maxDepth: 3 });
|
||
a.addRelation('g:1', 'member_of', 'u:0', { possibility: 0.8 });
|
||
a.addRelation('doc:0', 'member_of', 'g:1', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'multi_hop reverse persistent');
|
||
a.removeRelation('g:1', 'member_of', 'u:0');
|
||
a.removeRelation('doc:0', 'member_of', 'g:1');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'g:1', relation: 'member_of', dst: 'u:0', possibility: 0.8 },
|
||
{ src: 'doc:0', relation: 'member_of', dst: 'g:1', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'multi_hop reverse partial');
|
||
}
|
||
// ---- chain direction in + partial ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'chain', steps: [
|
||
{ relation: 'member_of', direction: 'in' },
|
||
{ relation: 'reads', direction: 'out' }
|
||
] });
|
||
a.addRelation('g:0', 'member_of', 'u:0', { possibility: 0.8 });
|
||
a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'chain-in persistent');
|
||
a.removeRelation('g:0', 'member_of', 'u:0');
|
||
a.removeRelation('g:0', 'reads', 'doc:0');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'g:0', relation: 'member_of', dst: 'u:0', possibility: 0.8 },
|
||
{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'chain-in partial');
|
||
}
|
||
// ---- union with a chain child ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { union: { rules: [
|
||
{ type: 'direct', relation: 'owner' },
|
||
{ type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' }] }
|
||
] } });
|
||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.6 });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
||
a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 });
|
||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'union chain child persistent (max 0.7)');
|
||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
a.removeRelation('g:0', 'reads', 'doc:0');
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.6 },
|
||
{ 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(round4(r.possibility), 0.7, 'union chain child partial');
|
||
}
|
||
// ---- defeasible split legs (when persistent, unless partial) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } });
|
||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [{ src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0.5 }] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.4, 'defeasible when persistent, unless partial');
|
||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||
const r2 = a.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }] }
|
||
});
|
||
assert.equal(round4(r2.possibility), 0.8, 'defeasible when partial only');
|
||
}
|
||
// ---- TTU value flow through the tupleset edge (collectValues) ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.8, value: 5 });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 });
|
||
const p = a.check('u:0', 'can_read', 'doc:0', { collectValues: true });
|
||
assert.equal(p.possibility, 0.7, 'ttu value persistent decision');
|
||
assert.ok(Array.isArray(p.collectedValues) && p.collectedValues.some(v => v === 'g:0' || v?.entityKey === 'g:0' || v?.value === 5),
|
||
`ttu value persistent collects intermediate: ${JSON.stringify(p.collectedValues)}`);
|
||
a.removeRelation('doc:0', 'owner', 'g:0');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
const r = a.check('u:0', 'can_read', 'doc:0', {
|
||
collectValues: true,
|
||
partialGraph: { relations: [
|
||
{ src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.8, value: 5 },
|
||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 }
|
||
] }
|
||
});
|
||
assert.equal(r.possibility, 0.7, 'ttu value partial decision');
|
||
}
|
||
// ---- challenge via binary mode ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_download', { type: 'challenge', challenge: 'captcha', subject: 'user', withinMinutes: 5 });
|
||
const now = Date.now();
|
||
const r = a.check('u:0', 'can_download', 'doc:0', {
|
||
binary: true, minAllowPossibility: 0.5,
|
||
partialGraph: { challenges: [{ name: 'captcha', subject: 'u:0', issuedAt: now - 60000, expiresAt: now + 60000 }] }
|
||
});
|
||
assert.equal(r.possibility, 1, 'challenge binary satisfied');
|
||
assert.equal(r.allow, true, 'challenge binary allow');
|
||
}
|
||
// ---- TTU multi-path fusion + reliability propagation ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.8, reliability: 0.9 });
|
||
a.addRelation('doc:0', 'owner', 'g:1', { possibility: 0.6 });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7, reliability: 0.8 });
|
||
a.addRelation('u:0', 'member_of', 'g:1', { possibility: 0.5 });
|
||
const p = a.check('u:0', 'can_read', 'doc:0');
|
||
assert.equal(round4(p.possibility), 0.7, 'ttu multi-path fusion persistent');
|
||
assert.ok(typeof p.reliability === 'number' && p.reliability > 0,
|
||
`ttu reliability flows through normal check: ${p.reliability}`);
|
||
assert.ok(Math.abs(p.reliability - 0.72) < 0.01, `ttu reliability = 0.9*0.8, got ${p.reliability}`);
|
||
a.removeRelation('doc:0', 'owner', 'g:0');
|
||
a.removeRelation('doc:0', 'owner', 'g:1');
|
||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||
a.removeRelation('u:0', 'member_of', 'g:1');
|
||
const r = a.check('u:0', 'can_read', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.8, reliability: 0.9 },
|
||
{ src: 'doc:0', relation: 'owner', dst: 'g:1', possibility: 0.6 },
|
||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7, reliability: 0.8 },
|
||
{ src: 'u:0', relation: 'member_of', dst: 'g:1', possibility: 0.5 }
|
||
] }
|
||
});
|
||
assert.equal(round4(r.possibility), 0.7, 'ttu multi-path fusion partial');
|
||
assert.ok(Math.abs(r.reliability - 0.72) < 0.01, `ttu reliability partial, got ${r.reliability}`);
|
||
}
|
||
// ---- TTU maxIntermediates circuit breaker through partial ----
|
||
{
|
||
const a = mkArbiter();
|
||
for (let i = 0; i < 25; i++) a.addNode('gx:' + i, 'group');
|
||
a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of', maxIntermediates: 5 });
|
||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
const rels = [];
|
||
for (let i = 0; i < 25; i++) {
|
||
rels.push({ src: 'doc:0', relation: 'owner', dst: 'gx:' + i, possibility: 0.3 + (i % 10) / 20 });
|
||
rels.push({ src: 'u:0', relation: 'member_of', dst: 'gx:' + i, possibility: 0.5 });
|
||
}
|
||
const r = a.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: rels } });
|
||
assert.equal(round4(r.possibility), 0.5, 'ttu circuit breaker picks best of limited intermediates');
|
||
}
|
||
// ---- snapshot-restored arbiter evaluates TTU ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.8 });
|
||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 });
|
||
a.enableCondensedSnapshot();
|
||
const { serializeArbiterSnapshot } = await import('../../src/core/SnapshotBinary.js');
|
||
const { ArbiterSnapshot } = await import('../../src/core/arbiter/ArbiterSnapshot.js');
|
||
const restored = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(a), {}, () => new Arbiter());
|
||
const p = restored.check('u:0', 'can_read', 'doc:0');
|
||
assert.equal(round4(p.possibility), 0.7, 'snapshot-restored TTU');
|
||
}
|
||
// ---- binary mode agrees with normal at the same threshold ----
|
||
{
|
||
const a = mkArbiter();
|
||
a.setRelationConfig('can_access', { type: 'chain', steps: [
|
||
{ relation: 'member_of', direction: 'out' },
|
||
{ relation: 'reads', direction: 'out' }
|
||
] });
|
||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||
binary: true, minAllowPossibility: 0.6,
|
||
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(r.possibility, 0.7, 'binary chain partial value');
|
||
assert.equal(r.allow, true, 'binary chain partial allow');
|
||
}
|
||
});
|
||
|
||
it('PROPERTY CAMPAIGN: TTU differential under random persistent/partial 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.addNode('g:2', 'group');
|
||
engine.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||
engine.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||
|
||
const persistent = new Map();
|
||
const partialEdges = [];
|
||
|
||
const w = {
|
||
engine,
|
||
setEdge(rel, dst, p, side) {
|
||
const src = rel === 'owner' ? 'doc:0' : 'u:0';
|
||
if (side === 'persistent') {
|
||
engine.addRelation(src, rel, dst, { possibility: p });
|
||
persistent.set(src + '|' + rel + '|' + dst, { p });
|
||
} 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 });
|
||
}
|
||
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_read', 'doc:0', options);
|
||
// TTU mirror: join both legs per intermediate — max over mids of
|
||
// min(tupleset.possibility, computed.possibility). Edges come from
|
||
// persistent and/or partial; on same-tuple conflicts the overlay
|
||
// contract is persistent-wins (persistent outranks partial trust).
|
||
const tupleset = new Map(); // dst -> p
|
||
const computed = new Map(); // dst -> p
|
||
for (const [key, v] of persistent) {
|
||
const [src, rel, dst] = key.split('|');
|
||
(rel === 'owner' ? tupleset : computed).set(dst, v.p);
|
||
}
|
||
for (const e of partialEdges) {
|
||
const merged = e.relation === 'owner' ? tupleset : computed;
|
||
if (!merged.has(e.dst)) merged.set(e.dst, e.possibility);
|
||
}
|
||
let best = 0;
|
||
for (const [mid, tp] of tupleset) {
|
||
const cp = computed.get(mid);
|
||
if (cp !== undefined && Math.min(tp, cp) > best) best = Math.min(tp, cp);
|
||
}
|
||
return { engine: round4(r.possibility), expected: round4(best) };
|
||
},
|
||
clone() { return w; }
|
||
};
|
||
return w;
|
||
}
|
||
|
||
const result = await rigor.campaign(
|
||
[rigor.object('graph', makeWrapper, [
|
||
rigor.method('setEdge', function (rel, dst, p, side) { return this.setEdge(rel, dst, p, side); },
|
||
rigor.args(
|
||
rigor.gen.oneOf(['owner', 'member_of']),
|
||
rigor.gen.oneOf(['g:0', 'g:1', 'g:2']),
|
||
rigor.gen.float(0.1, 1.0),
|
||
rigor.gen.oneOf(['persistent', 'partial'])
|
||
)),
|
||
rigor.method('clearAll', function () { return this.clearAll(); }),
|
||
rigor.method('check', function () { return this.check(); })
|
||
])],
|
||
rigor.crucible([
|
||
rigor.invariant('TTU both-legs parity', (ctx) => {
|
||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||
return ctx.actual.engine === ctx.actual.expected;
|
||
}),
|
||
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
||
])
|
||
).run({ effort: 400, seed: "ttu-partial-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
||
|
||
const inv = result.crucibleVerdict;
|
||
assert.equal(inv.passed, true, [
|
||
`TTU parity violated in ${inv.failureCount} cases:`,
|
||
...result.failures.slice(0, 3).map((f) =>
|
||
` [${f.name}] action=${f.actionName} step=${f.stepIndex} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} error=${f.error}`
|
||
)
|
||
].join('\n'));
|
||
}, 90000);
|
||
|
||
it('PROPERTY CAMPAIGN: comparator differential under random value mutations', async () => {
|
||
function makeWrapper() {
|
||
const engine = new Arbiter();
|
||
engine.addNode('u:0', 'user');
|
||
engine.addNode('doc:0', 'doc');
|
||
engine.setRelationConfig('can_access', {
|
||
type: 'relational_comparator', comparator: '>=',
|
||
left: { rule: { type: 'direct', relation: 'has_clearance' }, extractValue: true },
|
||
right: { rule: { type: 'direct', relation: 'requested_level' }, extractValue: true, evaluateFrom: 'object' }
|
||
});
|
||
|
||
const w = {
|
||
engine,
|
||
set(side, value) {
|
||
if (side === 'left') {
|
||
engine.removeRelation('u:0', 'has_clearance', 'doc:0');
|
||
engine.addRelation('u:0', 'has_clearance', 'doc:0', { possibility: 0.9, value });
|
||
} else {
|
||
engine.removeRelation('doc:0', 'requested_level', 'doc:0');
|
||
engine.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value });
|
||
}
|
||
return { ok: true };
|
||
},
|
||
check() {
|
||
const r = engine.check('u:0', 'can_access', 'doc:0');
|
||
return { engine: r.possibility, expected: r.possibility };
|
||
},
|
||
clone() { return w; }
|
||
};
|
||
return w;
|
||
}
|
||
|
||
const result = await rigor.campaign(
|
||
[rigor.object('graph', makeWrapper, [
|
||
rigor.method('set', function (side, value) { return this.set(side, value); },
|
||
rigor.args(rigor.gen.oneOf(['left', 'right']), rigor.gen.int(0, 12))),
|
||
rigor.method('check', function () { return this.check(); })
|
||
])],
|
||
rigor.crucible([
|
||
rigor.invariant('comparator never throws', (ctx) => ctx.error === null),
|
||
rigor.invariant('comparator result is binary', (ctx) => {
|
||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||
return ctx.actual.engine === 0 || ctx.actual.engine === 1;
|
||
})
|
||
])
|
||
).run({ effort: 400, seed: "comparator-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
||
|
||
const inv = result.crucibleVerdict;
|
||
assert.equal(inv.passed, true, [
|
||
`comparator 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'));
|
||
}, 90000);
|
||
|
||
it('PROPERTY CAMPAIGN: exclusion differential under random split edges', async () => {
|
||
function makeWrapper() {
|
||
const engine = new Arbiter();
|
||
engine.addNode('u:0', 'user');
|
||
engine.addNode('doc:0', 'doc');
|
||
engine.setRelationConfig('can_access', { exclusion: [{ relation: 'owner' }, { relation: 'banned' }] });
|
||
const own = { owner: null, banned: null };
|
||
const w = {
|
||
engine,
|
||
set(side, p) {
|
||
engine.removeRelation('u:0', side, 'doc:0');
|
||
own[side] = p;
|
||
engine.addRelation('u:0', side, 'doc:0', { possibility: p });
|
||
return { ok: true };
|
||
},
|
||
check() {
|
||
const r = engine.check('u:0', 'can_access', 'doc:0');
|
||
const bothPersistent = !!(own.owner !== null && own.banned !== null);
|
||
let partialEmpty = null;
|
||
if (bothPersistent) {
|
||
const r2 = engine.check('u:0', 'can_access', 'doc:0', {
|
||
partialGraph: { relations: [
|
||
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0 },
|
||
{ src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0 }
|
||
] }
|
||
});
|
||
partialEmpty = round4(r2.possibility);
|
||
}
|
||
return { persistent: round4(r.possibility), partialEmpty, expected: round4(r.possibility) };
|
||
},
|
||
clone() { return w; }
|
||
};
|
||
return w;
|
||
}
|
||
|
||
const result = await rigor.campaign(
|
||
[rigor.object('graph', makeWrapper, [
|
||
rigor.method('set', function (side, p) { return this.set(side, p); },
|
||
rigor.args(rigor.gen.oneOf(['owner', 'banned']), rigor.gen.float(0.0, 1.0))),
|
||
rigor.method('check', function () { return this.check(); })
|
||
])],
|
||
rigor.crucible([
|
||
rigor.invariant('no action errors', (ctx) => ctx.error === null),
|
||
rigor.invariant('empty partial overlay leaves decision unchanged', (ctx) => {
|
||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||
if (ctx.actual.partialEmpty === null) return true; // overlay would inject missing tuples
|
||
return ctx.actual.partialEmpty === ctx.actual.persistent;
|
||
})
|
||
])
|
||
).run({ effort: 400, seed: "exclusion-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
||
|
||
const inv = result.crucibleVerdict;
|
||
assert.equal(inv.passed, true, [
|
||
`exclusion violated in ${inv.failureCount} cases:`,
|
||
...result.failures.slice(0, 3).map((f) =>
|
||
` [${f.name}] action=${f.actionName} step=${f.stepIndex} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} error=${f.error}`
|
||
)
|
||
].join('\n'));
|
||
}, 90000);
|
||
}); |