2026-07-31 17:09:46 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)', () => {
|
|
|
|
|
|
|
2026-07-31 23:52:48 -07:00
|
|
|
|
it('FIXED MATRIX: every kind matches between persistent and partial', async () => {
|
2026-07-31 17:09:46 -07:00
|
|
|
|
// ---- 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');
|
|
|
|
|
|
}
|
2026-07-31 18:54:57 -07:00
|
|
|
|
// ---- 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');
|
|
|
|
|
|
}
|
2026-07-31 23:52:48 -07:00
|
|
|
|
// ---- 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');
|
|
|
|
|
|
}
|
2026-08-01 08:08:36 -07:00
|
|
|
|
// ---- comparator operand as TTU (value flows via the tupleset edge) ----
|
|
|
|
|
|
{
|
|
|
|
|
|
const a = mkArbiter();
|
|
|
|
|
|
a.setRelationConfig('can_access', {
|
|
|
|
|
|
type: 'relational_comparator', comparator: '>=',
|
|
|
|
|
|
left: { rule: { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }, extractValue: true },
|
|
|
|
|
|
right: { rule: { type: 'direct', relation: 'requested_level' }, extractValue: true, evaluateFrom: 'object' }
|
|
|
|
|
|
});
|
|
|
|
|
|
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
|
|
|
|
|
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9, value: 7 });
|
|
|
|
|
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
|
|
|
|
|
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, 'ttu operand persistent allow');
|
|
|
|
|
|
a.removeRelation('doc:0', 'owner', 'g:0');
|
|
|
|
|
|
a.removeRelation('u:0', 'member_of', 'g:0');
|
|
|
|
|
|
a.removeRelation('doc:0', 'requested_level', 'doc:0');
|
|
|
|
|
|
const r = a.check('u:0', 'can_access', 'doc:0', {
|
|
|
|
|
|
partialGraph: { relations: [
|
|
|
|
|
|
{ src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.9, value: 7 },
|
|
|
|
|
|
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
|
|
|
|
|
|
{ src: 'doc:0', relation: 'requested_level', dst: 'doc:0', possibility: 1.0, value: 4 }
|
|
|
|
|
|
] }
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(r.possibility, 1, 'ttu operand partial allow');
|
|
|
|
|
|
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9, value: 3 });
|
|
|
|
|
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
|
|
|
|
|
a.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value: 4 });
|
|
|
|
|
|
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 0, 'ttu operand below threshold deny');
|
|
|
|
|
|
a.removeRelation('doc:0', 'owner', 'g:0');
|
|
|
|
|
|
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9 });
|
|
|
|
|
|
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 0, 'ttu operand without value denies');
|
|
|
|
|
|
}
|
|
|
|
|
|
// ---- modify changed_last_at override (value-changing modify honors the pin) ----
|
|
|
|
|
|
{
|
|
|
|
|
|
const a = mkArbiter();
|
|
|
|
|
|
a.addRelation('u:0', 'balance', 'doc:0', { value: 10, changed_last_at: 1000 });
|
|
|
|
|
|
a.relationManager.updateRelationsBatch([
|
|
|
|
|
|
{ operation: 'modify', srcKey: 'u:0', relation: 'balance', dstKey: 'doc:0', options: { value: 20, changed_last_at: 5000 } }
|
|
|
|
|
|
]);
|
|
|
|
|
|
const after = a.relationManager.getDirectRelation(a.resolveNodeId('u:0'), 'balance', a.resolveNodeId('doc:0'));
|
|
|
|
|
|
assert.equal(after.changed_last_at, 5000, 'value-changing modify honors changed_last_at override');
|
|
|
|
|
|
a.relationManager.updateRelationsBatch([
|
|
|
|
|
|
{ operation: 'modify', srcKey: 'u:0', relation: 'balance', dstKey: 'doc:0', options: { value: 20, changed_last_at: 9999 } }
|
|
|
|
|
|
]);
|
|
|
|
|
|
const unchanged = a.relationManager.getDirectRelation(a.resolveNodeId('u:0'), 'balance', a.resolveNodeId('doc:0'));
|
|
|
|
|
|
assert.equal(unchanged.changed_last_at, 5000, 'value-unchanged modify keeps old timestamp (override not a refresh)');
|
|
|
|
|
|
}
|
2026-08-01 09:52:31 -07:00
|
|
|
|
// ---- reliability propagation across kinds ----
|
|
|
|
|
|
{
|
|
|
|
|
|
// chain: product of edge reliabilities (0.9 * 0.8)
|
|
|
|
|
|
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, reliability: 0.9 });
|
|
|
|
|
|
a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7, reliability: 0.8 });
|
|
|
|
|
|
const c = a.check('u:0', 'can_access', 'doc:0');
|
|
|
|
|
|
assert.ok(Math.abs(c.reliability - 0.72) < 0.01, `chain reliability product, got ${c.reliability}`);
|
|
|
|
|
|
// multi_hop: product along the path
|
|
|
|
|
|
a.setRelationConfig('can_hop', { type: 'multi_hop', relation: 'member_of', maxDepth: 3 });
|
|
|
|
|
|
a.addNode('g:1', 'group');
|
|
|
|
|
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8, reliability: 0.9 });
|
|
|
|
|
|
a.addRelation('g:0', 'member_of', 'g:1', { possibility: 0.7, reliability: 0.8 });
|
|
|
|
|
|
const m = a.check('u:0', 'can_hop', 'g:1');
|
|
|
|
|
|
assert.ok(Math.abs(m.reliability - 0.72) < 0.01, `multi_hop reliability product, got ${m.reliability}`);
|
|
|
|
|
|
// union: the max child's reliability (editor 0.9 / reli 0.5)
|
|
|
|
|
|
a.setRelationConfig('can_union', { union: { rules: [{ relation: 'owner' }, { relation: 'editor' }] } });
|
|
|
|
|
|
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8, reliability: 0.9 });
|
|
|
|
|
|
a.addRelation('u:0', 'editor', 'doc:0', { possibility: 0.9, reliability: 0.5 });
|
|
|
|
|
|
const u = a.check('u:0', 'can_union', 'doc:0');
|
|
|
|
|
|
assert.equal(round4(u.reliability), 0.5, `union selected-child reliability, got ${u.reliability}`);
|
|
|
|
|
|
// intersection: the min child's reliability (verified 0.6)
|
|
|
|
|
|
a.setRelationConfig('can_intersect', { intersection: { rules: [{ relation: 'owner' }, { relation: 'verified' }] } });
|
|
|
|
|
|
a.addRelation('u:0', 'verified', 'doc:0', { possibility: 0.5, reliability: 0.6 });
|
|
|
|
|
|
const i = a.check('u:0', 'can_intersect', 'doc:0');
|
|
|
|
|
|
assert.equal(round4(i.reliability), 0.6, `intersection selected-child reliability, got ${i.reliability}`);
|
|
|
|
|
|
// exclusion: product of both legs (0.9 * 0.7)
|
|
|
|
|
|
a.setRelationConfig('can_excl', { exclusion: [{ relation: 'owner' }, { relation: 'banned' }] });
|
|
|
|
|
|
a.addRelation('u:0', 'banned', 'doc:0', { possibility: 0.5, reliability: 0.7 });
|
|
|
|
|
|
const e = a.check('u:0', 'can_excl', 'doc:0');
|
|
|
|
|
|
assert.ok(Math.abs(e.reliability - 0.63) < 0.01, `exclusion product reliability, got ${e.reliability}`);
|
|
|
|
|
|
// defeasible: when reli * unless reli (0.9 * 0.7)
|
|
|
|
|
|
a.setRelationConfig('can_def', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } });
|
|
|
|
|
|
const d = a.check('u:0', 'can_def', 'doc:0');
|
|
|
|
|
|
assert.ok(Math.abs(d.reliability - 0.63) < 0.01, `defeasible combined reliability, got ${d.reliability}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
// ---- multi_hop value collection through partial (no crash, values flow) ----
|
|
|
|
|
|
{
|
|
|
|
|
|
const a = mkArbiter();
|
|
|
|
|
|
a.addNode('g:1', 'group');
|
|
|
|
|
|
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, value: 42 });
|
|
|
|
|
|
const p = a.check('u:0', 'can_access', 'g:1', { collectValues: true });
|
|
|
|
|
|
assert.ok(Array.isArray(p.collectedValues) && p.collectedValues.length > 0, 'multi_hop persistent values collected');
|
|
|
|
|
|
assert.ok(typeof p.collectedValues[0].value?.min === 'number', 'multi_hop value interval present');
|
|
|
|
|
|
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', {
|
|
|
|
|
|
collectValues: true,
|
|
|
|
|
|
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, value: 42 }
|
|
|
|
|
|
] }
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.ok(Array.isArray(r.collectedValues) && r.collectedValues.length > 0, 'multi_hop partial values collected');
|
|
|
|
|
|
}
|
2026-07-31 17:09:46 -07:00
|
|
|
|
// ---- 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,
|
2026-08-01 22:34:00 -07:00
|
|
|
|
setEdge(rel, dst, p, reli, value, side) {
|
2026-07-31 17:09:46 -07:00
|
|
|
|
const src = rel === 'owner' ? 'doc:0' : 'u:0';
|
2026-08-01 22:34:00 -07:00
|
|
|
|
const opts = { possibility: p, reliability: reli };
|
|
|
|
|
|
if (rel === 'owner' && value !== null) opts.value = value; // values ride the tupleset leg
|
2026-07-31 17:09:46 -07:00
|
|
|
|
if (side === 'persistent') {
|
2026-08-01 22:34:00 -07:00
|
|
|
|
engine.addRelation(src, rel, dst, opts);
|
|
|
|
|
|
// add-on-existing is a modify: an absent new value preserves the
|
|
|
|
|
|
// stored one (mirror the engine).
|
|
|
|
|
|
const key = src + '|' + rel + '|' + dst;
|
|
|
|
|
|
const existing = persistent.get(key);
|
|
|
|
|
|
persistent.set(key, { p, r: reli, v: opts.value !== undefined ? opts.value : (existing ? existing.v : undefined) });
|
2026-07-31 17:09:46 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst);
|
|
|
|
|
|
if (idx >= 0) partialEdges.splice(idx, 1);
|
2026-08-01 22:34:00 -07:00
|
|
|
|
partialEdges.push({ src, relation: rel, dst, possibility: p, reliability: reli, value: opts.value });
|
2026-07-31 17:09:46 -07:00
|
|
|
|
}
|
|
|
|
|
|
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() } }
|
|
|
|
|
|
: {};
|
2026-08-01 22:34:00 -07:00
|
|
|
|
const r = engine.check('u:0', 'can_read', 'doc:0', { ...options, collectValues: true });
|
2026-07-31 17:09:46 -07:00
|
|
|
|
// TTU mirror: join both legs per intermediate — max over mids of
|
2026-08-01 11:18:20 -07:00
|
|
|
|
// min(tupleset.possibility, computed.possibility), with the winning
|
|
|
|
|
|
// intermediate's reliability = tupleset.reli * computed.reli. Edges
|
|
|
|
|
|
// come from persistent and/or partial; on same-tuple conflicts the
|
|
|
|
|
|
// overlay contract is persistent-wins (persistent outranks partial).
|
2026-08-01 22:34:00 -07:00
|
|
|
|
const tupleset = new Map(); // dst -> { p, r, v }
|
2026-08-01 11:18:20 -07:00
|
|
|
|
const computed = new Map(); // dst -> { p, r }
|
2026-07-31 17:09:46 -07:00
|
|
|
|
for (const [key, v] of persistent) {
|
|
|
|
|
|
const [src, rel, dst] = key.split('|');
|
2026-08-01 22:34:00 -07:00
|
|
|
|
if (rel === 'owner') tupleset.set(dst, v);
|
|
|
|
|
|
else computed.set(dst, v);
|
2026-07-31 17:09:46 -07:00
|
|
|
|
}
|
|
|
|
|
|
for (const e of partialEdges) {
|
2026-08-01 22:34:00 -07:00
|
|
|
|
if (e.relation === 'owner') {
|
|
|
|
|
|
if (!tupleset.has(e.dst)) tupleset.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0, v: e.value });
|
|
|
|
|
|
} else if (!computed.has(e.dst)) {
|
|
|
|
|
|
computed.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0 });
|
|
|
|
|
|
}
|
2026-07-31 17:09:46 -07:00
|
|
|
|
}
|
|
|
|
|
|
let best = 0;
|
2026-08-01 11:18:20 -07:00
|
|
|
|
let bestReliability = 0;
|
2026-08-01 22:34:00 -07:00
|
|
|
|
const expectedValues = [];
|
2026-07-31 17:09:46 -07:00
|
|
|
|
for (const [mid, tp] of tupleset) {
|
|
|
|
|
|
const cp = computed.get(mid);
|
2026-08-01 11:18:20 -07:00
|
|
|
|
if (cp === undefined) continue;
|
|
|
|
|
|
const combinedP = Math.min(tp.p, cp.p);
|
2026-08-01 22:34:00 -07:00
|
|
|
|
if (combinedP > 0) {
|
|
|
|
|
|
if (tp.v !== undefined) expectedValues.push(round4(tp.v));
|
|
|
|
|
|
}
|
2026-08-01 11:18:20 -07:00
|
|
|
|
if (combinedP > best) {
|
|
|
|
|
|
best = combinedP;
|
|
|
|
|
|
bestReliability = (tp.r ?? 1.0) * (cp.r ?? 1.0);
|
|
|
|
|
|
}
|
2026-07-31 17:09:46 -07:00
|
|
|
|
}
|
2026-08-01 22:34:00 -07:00
|
|
|
|
expectedValues.sort((a, b) => a - b);
|
|
|
|
|
|
const engineValues = (r.collectedValues || [])
|
|
|
|
|
|
.map(v => typeof v === 'number' ? round4(v) : (typeof v.value === 'number' ? round4(v.value) : null))
|
|
|
|
|
|
.filter(v => v !== null)
|
|
|
|
|
|
.sort((a, b) => a - b);
|
2026-08-01 11:18:20 -07:00
|
|
|
|
return {
|
|
|
|
|
|
engine: round4(r.possibility),
|
|
|
|
|
|
expected: round4(best),
|
|
|
|
|
|
engineReliability: round4(r.reliability ?? 0),
|
2026-08-01 22:34:00 -07:00
|
|
|
|
expectedReliability: round4(bestReliability),
|
|
|
|
|
|
engineValues,
|
|
|
|
|
|
expectedValues,
|
|
|
|
|
|
persistent: [...persistent.keys()],
|
|
|
|
|
|
partial: partialEdges.map(e => e.relation + ':' + e.dst)
|
2026-08-01 11:18:20 -07:00
|
|
|
|
};
|
2026-07-31 17:09:46 -07:00
|
|
|
|
},
|
|
|
|
|
|
clone() { return w; }
|
|
|
|
|
|
};
|
|
|
|
|
|
return w;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await rigor.campaign(
|
|
|
|
|
|
[rigor.object('graph', makeWrapper, [
|
2026-08-01 22:34:00 -07:00
|
|
|
|
rigor.method('setEdge', function (rel, dst, p, reli, value, side) { return this.setEdge(rel, dst, p, reli, value, side); },
|
2026-07-31 17:09:46 -07:00
|
|
|
|
rigor.args(
|
|
|
|
|
|
rigor.gen.oneOf(['owner', 'member_of']),
|
|
|
|
|
|
rigor.gen.oneOf(['g:0', 'g:1', 'g:2']),
|
|
|
|
|
|
rigor.gen.float(0.1, 1.0),
|
2026-08-01 11:18:20 -07:00
|
|
|
|
rigor.gen.oneOf([0.3, 0.6, 0.9]),
|
2026-08-01 22:34:00 -07:00
|
|
|
|
rigor.gen.oneOf([null, 5, 42]),
|
2026-07-31 17:09:46 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}),
|
2026-08-01 11:18:20 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}),
|
2026-08-01 22:34:00 -07:00
|
|
|
|
rigor.invariant('TTU collected-value parity', (ctx) => {
|
|
|
|
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
|
|
|
|
return JSON.stringify(ctx.actual.engineValues) === JSON.stringify(ctx.actual.expectedValues);
|
|
|
|
|
|
}),
|
2026-07-31 17:09:46 -07:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-01 22:34:00 -07:00
|
|
|
|
it('PROPERTY CAMPAIGN: defeasible differential under random persistent/partial splits', async () => {
|
|
|
|
|
|
function makeWrapper() {
|
|
|
|
|
|
const engine = new Arbiter();
|
|
|
|
|
|
engine.addNode('u:0', 'user');
|
|
|
|
|
|
engine.addNode('doc:0', 'doc');
|
|
|
|
|
|
engine.setRelationConfig('can_access', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } });
|
|
|
|
|
|
|
|
|
|
|
|
const persistent = new Map();
|
|
|
|
|
|
const partialEdges = [];
|
|
|
|
|
|
|
|
|
|
|
|
const w = {
|
|
|
|
|
|
engine,
|
|
|
|
|
|
setEdge(rel, p, reli, side) {
|
|
|
|
|
|
if (side === 'persistent') {
|
|
|
|
|
|
engine.addRelation('u:0', rel, 'doc:0', { possibility: p, reliability: reli });
|
|
|
|
|
|
persistent.set(rel, { p, r: reli });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const idx = partialEdges.findIndex(e => e.relation === rel);
|
|
|
|
|
|
if (idx >= 0) partialEdges.splice(idx, 1);
|
|
|
|
|
|
partialEdges.push({ src: 'u:0', relation: rel, dst: 'doc:0', possibility: p, reliability: reli });
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
},
|
|
|
|
|
|
clearAll() {
|
|
|
|
|
|
for (const rel of [...persistent.keys()]) engine.removeRelation('u:0', rel, 'doc:0');
|
|
|
|
|
|
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: P(when) * (1 - P(unless)) with reliability
|
|
|
|
|
|
// when.reli * unless.reli; persistent wins on same-tuple conflicts.
|
|
|
|
|
|
const when = persistent.get('owner') ?? partialEdges.find(e => e.relation === 'owner');
|
|
|
|
|
|
const unless = persistent.get('banned') ?? partialEdges.find(e => e.relation === 'banned');
|
|
|
|
|
|
const whenP = when ? (when.p ?? when.possibility) : 0;
|
|
|
|
|
|
const whenR = when ? (when.r ?? when.reliability ?? 1.0) : 1.0;
|
|
|
|
|
|
const unlessP = unless ? (unless.p ?? unless.possibility) : 0;
|
|
|
|
|
|
const unlessR = unless ? (unless.r ?? unless.reliability ?? 1.0) : 1.0;
|
|
|
|
|
|
const wp = whenP;
|
|
|
|
|
|
const wpR = whenR;
|
|
|
|
|
|
const up = unlessP;
|
|
|
|
|
|
const upR = unlessR;
|
|
|
|
|
|
const expected = round4(wp * (1 - up));
|
|
|
|
|
|
const expectedReliability = round4(wpR * upR);
|
|
|
|
|
|
return {
|
|
|
|
|
|
engine: round4(r.possibility),
|
|
|
|
|
|
expected,
|
|
|
|
|
|
engineReliability: round4(r.reliability ?? 0),
|
|
|
|
|
|
expectedReliability
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
clone() { return w; }
|
|
|
|
|
|
};
|
|
|
|
|
|
return w;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await rigor.campaign(
|
|
|
|
|
|
[rigor.object('graph', makeWrapper, [
|
|
|
|
|
|
rigor.method('setEdge', function (rel, p, reli, side) { return this.setEdge(rel, p, reli, side); },
|
|
|
|
|
|
rigor.args(
|
|
|
|
|
|
rigor.gen.oneOf(['owner', 'banned']),
|
|
|
|
|
|
rigor.gen.float(0.0, 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('defeasible decision parity', (ctx) => {
|
|
|
|
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
|
|
|
|
return ctx.actual.engine === ctx.actual.expected;
|
|
|
|
|
|
}),
|
|
|
|
|
|
rigor.invariant('defeasible 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: "defeasible-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
|
|
|
|
|
|
|
|
|
|
|
const inv = result.crucibleVerdict;
|
|
|
|
|
|
assert.equal(inv.passed, true, [
|
|
|
|
|
|
`defeasible 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: intersection differential under random persistent/partial splits', async () => {
|
|
|
|
|
|
function makeWrapper() {
|
|
|
|
|
|
const engine = new Arbiter();
|
|
|
|
|
|
engine.addNode('u:0', 'user');
|
|
|
|
|
|
engine.addNode('doc:0', 'doc');
|
|
|
|
|
|
engine.setRelationConfig('can_access', { intersection: { rules: [{ relation: 'owner' }, { relation: 'verified' }] } });
|
|
|
|
|
|
|
|
|
|
|
|
const persistent = new Map();
|
|
|
|
|
|
const partialEdges = [];
|
|
|
|
|
|
|
|
|
|
|
|
const w = {
|
|
|
|
|
|
engine,
|
|
|
|
|
|
setEdge(rel, p, reli, side) {
|
|
|
|
|
|
if (side === 'persistent') {
|
|
|
|
|
|
engine.addRelation('u:0', rel, 'doc:0', { possibility: p, reliability: reli });
|
|
|
|
|
|
persistent.set(rel, { p, r: reli });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const idx = partialEdges.findIndex(e => e.relation === rel);
|
|
|
|
|
|
if (idx >= 0) partialEdges.splice(idx, 1);
|
|
|
|
|
|
partialEdges.push({ src: 'u:0', relation: rel, dst: 'doc:0', possibility: p, reliability: reli });
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
},
|
|
|
|
|
|
clearAll() {
|
|
|
|
|
|
for (const rel of [...persistent.keys()]) engine.removeRelation('u:0', rel, 'doc:0');
|
|
|
|
|
|
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: min over children with the min child's reliability;
|
|
|
|
|
|
// persistent wins on same-tuple conflicts.
|
|
|
|
|
|
const edges = {};
|
|
|
|
|
|
for (const rel of ['owner', 'verified']) {
|
|
|
|
|
|
const p = persistent.get(rel);
|
|
|
|
|
|
if (p) edges[rel] = p;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const e of partialEdges) {
|
|
|
|
|
|
if (!edges[e.relation]) edges[e.relation] = { p: e.possibility, r: e.reliability ?? 1.0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
const present = Object.values(edges);
|
|
|
|
|
|
let expected = 0;
|
|
|
|
|
|
let expectedReliability = 0;
|
|
|
|
|
|
if (present.length === 2) {
|
|
|
|
|
|
const minP = Math.min(present[0].p, present[1].p);
|
|
|
|
|
|
const minIdx = present[0].p <= present[1].p ? 0 : 1;
|
|
|
|
|
|
expected = round4(minP);
|
|
|
|
|
|
expectedReliability = round4(present[minIdx].r ?? 1.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
engine: round4(r.possibility),
|
|
|
|
|
|
expected,
|
|
|
|
|
|
engineReliability: round4(r.reliability ?? 0),
|
|
|
|
|
|
expectedReliability
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
clone() { return w; }
|
|
|
|
|
|
};
|
|
|
|
|
|
return w;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await rigor.campaign(
|
|
|
|
|
|
[rigor.object('graph', makeWrapper, [
|
|
|
|
|
|
rigor.method('setEdge', function (rel, p, reli, side) { return this.setEdge(rel, p, reli, side); },
|
|
|
|
|
|
rigor.args(
|
|
|
|
|
|
rigor.gen.oneOf(['owner', 'verified']),
|
|
|
|
|
|
rigor.gen.float(0.0, 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('intersection decision parity', (ctx) => {
|
|
|
|
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
|
|
|
|
return ctx.actual.engine === ctx.actual.expected;
|
|
|
|
|
|
}),
|
|
|
|
|
|
rigor.invariant('intersection 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: "intersection-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
|
|
|
|
|
|
|
|
|
|
|
const inv = result.crucibleVerdict;
|
|
|
|
|
|
assert.equal(inv.passed, true, [
|
|
|
|
|
|
`intersection 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);
|
|
|
|
|
|
|
2026-08-02 08:14:39 -07:00
|
|
|
|
it('PROPERTY CAMPAIGN: OWA fusion differential (weights, aggregators, reliabilityWeighting)', async () => {
|
|
|
|
|
|
function makeWrapper(useCompiled) {
|
|
|
|
|
|
const engine = new Arbiter();
|
|
|
|
|
|
engine.addNode('u:0', 'user');
|
|
|
|
|
|
engine.addNode('doc:0', 'doc');
|
|
|
|
|
|
engine.setRelationConfig('can_access', { union: { rules: [
|
|
|
|
|
|
{ relation: 'owner' }, { relation: 'editor' }, { relation: 'verified' }
|
|
|
|
|
|
] } });
|
|
|
|
|
|
|
|
|
|
|
|
const persistent = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
const w = {
|
|
|
|
|
|
engine,
|
|
|
|
|
|
setConfig(aggregator, weights, weighting) {
|
|
|
|
|
|
const cfg = { union: { rules: [{ relation: 'owner' }, { relation: 'editor' }, { relation: 'verified' }] } };
|
|
|
|
|
|
if (aggregator) cfg.union.aggregator = aggregator;
|
|
|
|
|
|
if (weights) cfg.union.owaWeights = weights;
|
|
|
|
|
|
if (weighting) cfg.union.reliabilityWeighting = true;
|
|
|
|
|
|
engine.setRelationConfig('can_access', cfg);
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
},
|
|
|
|
|
|
setEdge(rel, p, reli) {
|
|
|
|
|
|
engine.removeRelation('u:0', rel, 'doc:0');
|
|
|
|
|
|
engine.addRelation('u:0', rel, 'doc:0', { possibility: p, reliability: reli });
|
|
|
|
|
|
persistent.set(rel, { p, r: reli });
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
},
|
|
|
|
|
|
check() {
|
|
|
|
|
|
const r = engine.check('u:0', 'can_access', 'doc:0', { useCompiled });
|
|
|
|
|
|
// Mirror: OWA = sum(weight_i * sorted_desc(value_i)); with
|
|
|
|
|
|
// reliabilityWeighting the values are pre-scaled by reliability.
|
|
|
|
|
|
const values = [];
|
|
|
|
|
|
for (const rel of ['owner', 'editor', 'verified']) {
|
|
|
|
|
|
const e = persistent.get(rel);
|
|
|
|
|
|
values.push({ v: e ? e.p : 0, r: e ? e.r : 1.0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
const engineCfg = engine.relationConfigs.get('can_access').union;
|
|
|
|
|
|
const scaled = engineCfg.reliabilityWeighting
|
|
|
|
|
|
? values.map(x => x.v * x.r)
|
|
|
|
|
|
: values.map(x => x.v);
|
|
|
|
|
|
scaled.sort((a, b) => b - a);
|
|
|
|
|
|
let weights;
|
|
|
|
|
|
if (engineCfg.owaWeights && engineCfg.owaWeights.length === scaled.length) {
|
|
|
|
|
|
weights = engineCfg.owaWeights;
|
|
|
|
|
|
} else if ((engineCfg.aggregator || 'max') === 'min') {
|
|
|
|
|
|
weights = [...Array(scaled.length - 1).fill(0), 1];
|
|
|
|
|
|
} else if ((engineCfg.aggregator || 'max') === 'average' || (engineCfg.aggregator || 'max') === 'mean') {
|
|
|
|
|
|
weights = Array(scaled.length).fill(1 / scaled.length);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
weights = [1, ...Array(scaled.length - 1).fill(0)];
|
|
|
|
|
|
}
|
|
|
|
|
|
const expected = round4(scaled.reduce((s, v, i) => s + (weights[i] ?? 0) * v, 0));
|
|
|
|
|
|
return { engine: round4(r.possibility), expected };
|
|
|
|
|
|
},
|
|
|
|
|
|
clone() { return w; }
|
|
|
|
|
|
};
|
|
|
|
|
|
return w;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const weightSets = [[0.7, 0.3, 0], [0.2, 0.3, 0.5], [1, 0, 0], [0, 0, 1]];
|
|
|
|
|
|
const result = await rigor.campaign(
|
|
|
|
|
|
[rigor.object('graph', makeWrapper.bind(null, true), [
|
|
|
|
|
|
rigor.method('setConfig', function (agg, wIdx, weighting) { return this.setConfig(agg, wIdx !== null ? weightSets[wIdx] : null, weighting); },
|
|
|
|
|
|
rigor.args(
|
|
|
|
|
|
rigor.gen.oneOf([null, 'max', 'min', 'average']),
|
|
|
|
|
|
rigor.gen.oneOf([null, 0, 1, 2, 3]),
|
|
|
|
|
|
rigor.gen.oneOf([false, true])
|
|
|
|
|
|
)),
|
|
|
|
|
|
rigor.method('setEdge', function (rel, p, reli) { return this.setEdge(rel, p, reli); },
|
|
|
|
|
|
rigor.args(
|
|
|
|
|
|
rigor.gen.oneOf(['owner', 'editor', 'verified']),
|
|
|
|
|
|
rigor.gen.float(0.0, 1.0),
|
|
|
|
|
|
rigor.gen.oneOf([0.3, 0.6, 0.9])
|
|
|
|
|
|
)),
|
|
|
|
|
|
rigor.method('check', function () { return this.check(); })
|
|
|
|
|
|
])],
|
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
|
rigor.invariant('OWA value 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: "owa-fusion-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
|
|
|
|
|
|
|
|
|
|
|
const inv = result.crucibleVerdict;
|
|
|
|
|
|
assert.equal(inv.passed, true, [
|
|
|
|
|
|
`OWA 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('FIXED: multi_hop pathAggregation owa with custom weights', () => {
|
|
|
|
|
|
const a = new Arbiter();
|
|
|
|
|
|
a.addNode('u:0', 'user');
|
|
|
|
|
|
a.addNode('g:0', 'group'); a.addNode('g:1', 'group'); a.addNode('g:2', 'group');
|
|
|
|
|
|
a.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', maxDepth: 3, pathAggregation: 'owa', owaWeights: [0.7, 0.3] });
|
|
|
|
|
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
|
|
|
|
|
a.addRelation('g:0', 'member_of', 'g:2', { possibility: 0.9 });
|
|
|
|
|
|
a.addRelation('u:0', 'member_of', 'g:1', { possibility: 0.5 });
|
|
|
|
|
|
a.addRelation('g:1', 'member_of', 'g:2', { possibility: 0.6 });
|
|
|
|
|
|
const p = a.check('u:0', 'can_access', 'g:2');
|
|
|
|
|
|
// paths 0.8 and 0.5 -> sorted [0.8, 0.5] -> 0.7*0.8 + 0.3*0.5
|
|
|
|
|
|
assert.equal(round4(p.possibility), 0.71, `multi_hop owa fusion, got ${p.possibility}`);
|
|
|
|
|
|
// reliability flows from the winning path
|
|
|
|
|
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8, reliability: 0.9 });
|
|
|
|
|
|
a.addRelation('g:0', 'member_of', 'g:2', { possibility: 0.9, reliability: 0.8 });
|
|
|
|
|
|
const p2 = a.check('u:0', 'can_access', 'g:2');
|
|
|
|
|
|
assert.ok(Math.abs(p2.reliability - 0.72) < 0.01, `multi_hop owa reliability, got ${p2.reliability}`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-01 11:18:20 -07:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-07-31 17:09:46 -07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|