fb258035f9
Probe sweep of the OWA surfaces found three real defects:
- reliabilityWeighting was a silent no-op everywhere: every implementation
scaled possibilities by metas[i].reliability, but no child meta ever
carried a reliability field (the compiled direct omitted it and the
DirectRule handler omitted it too), so the weighting was always x1.0.
All weighting branches now use the tracked child reliabilities, and the
DirectRule handler + its meta now carry the relation's reliability.
- The compiled union and the direct_list fast path had no
reliabilityWeighting branch at all; both now apply it.
- Shorthand children ({ relation: 'editor' }) dispatch to the direct
handler but carry no type, so _getRuleResultCacheKey derived the generic
'rule' suffix for every shorthand child of a logical rule — the first
child's cached result was served for all of them (the fallback path
returned the owner's 0.8 for the editor). The key derivation now matches
the shorthand dispatch. The RuleEvaluator also treats shorthand operands
as direct rules instead of unknown_rule_type on the non-compiled path.
New pins: an OWA differential property (custom weights, max/min/average
aggregators, reliabilityWeighting, compiled path) and a multi_hop
pathAggregation=owa fixed pin with reliability propagation.
1256 lines
62 KiB
JavaScript
1256 lines
62 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');
|
||
}
|
||
// ---- 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)');
|
||
}
|
||
// ---- 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');
|
||
}
|
||
// ---- 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, reli, value, side) {
|
||
const src = rel === 'owner' ? 'doc:0' : 'u:0';
|
||
const opts = { possibility: p, reliability: reli };
|
||
if (rel === 'owner' && value !== null) opts.value = value; // values ride the tupleset leg
|
||
if (side === 'persistent') {
|
||
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) });
|
||
} 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, value: opts.value });
|
||
}
|
||
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, collectValues: true });
|
||
// TTU mirror: join both legs per intermediate — max over mids of
|
||
// 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).
|
||
const tupleset = new Map(); // dst -> { p, r, v }
|
||
const computed = new Map(); // dst -> { p, r }
|
||
for (const [key, v] of persistent) {
|
||
const [src, rel, dst] = key.split('|');
|
||
if (rel === 'owner') tupleset.set(dst, v);
|
||
else computed.set(dst, v);
|
||
}
|
||
for (const e of partialEdges) {
|
||
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 });
|
||
}
|
||
}
|
||
let best = 0;
|
||
let bestReliability = 0;
|
||
const expectedValues = [];
|
||
for (const [mid, tp] of tupleset) {
|
||
const cp = computed.get(mid);
|
||
if (cp === undefined) continue;
|
||
const combinedP = Math.min(tp.p, cp.p);
|
||
if (combinedP > 0) {
|
||
if (tp.v !== undefined) expectedValues.push(round4(tp.v));
|
||
}
|
||
if (combinedP > best) {
|
||
best = combinedP;
|
||
bestReliability = (tp.r ?? 1.0) * (cp.r ?? 1.0);
|
||
}
|
||
}
|
||
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);
|
||
return {
|
||
engine: round4(r.possibility),
|
||
expected: round4(best),
|
||
engineReliability: round4(r.reliability ?? 0),
|
||
expectedReliability: round4(bestReliability),
|
||
engineValues,
|
||
expectedValues,
|
||
persistent: [...persistent.keys()],
|
||
partial: partialEdges.map(e => e.relation + ':' + e.dst)
|
||
};
|
||
},
|
||
clone() { return w; }
|
||
};
|
||
return w;
|
||
}
|
||
|
||
const result = await rigor.campaign(
|
||
[rigor.object('graph', makeWrapper, [
|
||
rigor.method('setEdge', function (rel, dst, p, reli, value, side) { return this.setEdge(rel, dst, p, reli, value, 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([0.3, 0.6, 0.9]),
|
||
rigor.gen.oneOf([null, 5, 42]),
|
||
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('TTU reliability parity', (ctx) => {
|
||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||
if (ctx.actual.expected === 0) return true; // denied: reliability 0
|
||
return ctx.actual.engineReliability === ctx.actual.expectedReliability;
|
||
}),
|
||
rigor.invariant('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);
|
||
}),
|
||
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: 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);
|
||
|
||
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}`);
|
||
});
|
||
|
||
it('PROPERTY CAMPAIGN: chain reliability differential under random edge splits', async () => {
|
||
function makeWrapper() {
|
||
const engine = new Arbiter();
|
||
engine.addNode('u:0', 'user');
|
||
engine.addNode('doc:0', 'doc');
|
||
engine.addNode('g:0', 'group');
|
||
engine.addNode('g:1', 'group');
|
||
engine.setRelationConfig('can_access', { type: 'chain', steps: [
|
||
{ relation: 'member_of', direction: 'out' },
|
||
{ relation: 'reads', direction: 'out' }
|
||
] });
|
||
|
||
const persistent = new Map();
|
||
const partialEdges = [];
|
||
|
||
const w = {
|
||
engine,
|
||
setEdge(rel, dst, p, reli, side) {
|
||
const src = rel === 'member_of' ? 'u:0' : 'g:0';
|
||
if (rel === 'reads') dst = 'doc:0'; // the chain's second leg must point at the target
|
||
if (side === 'persistent') {
|
||
engine.addRelation(src, rel, dst, { possibility: p, reliability: reli });
|
||
persistent.set(src + '|' + rel + '|' + dst, { p, r: reli });
|
||
} else {
|
||
const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst);
|
||
if (idx >= 0) partialEdges.splice(idx, 1);
|
||
partialEdges.push({ src, relation: rel, dst, possibility: p, reliability: reli });
|
||
}
|
||
return { ok: true };
|
||
},
|
||
clearAll() {
|
||
for (const key of [...persistent.keys()]) {
|
||
const [src, rel, dst] = key.split('|');
|
||
engine.removeRelation(src, rel, dst);
|
||
}
|
||
persistent.clear();
|
||
partialEdges.length = 0;
|
||
return { ok: true };
|
||
},
|
||
check() {
|
||
const options = partialEdges.length > 0
|
||
? { partialGraph: { relations: partialEdges.slice() } }
|
||
: {};
|
||
const r = engine.check('u:0', 'can_access', 'doc:0', options);
|
||
// Mirror: chain = max over intermediates of min(legs); the winning
|
||
// path's reliability is the product of its edges' reliabilities.
|
||
// Same-tuple conflicts: persistent wins.
|
||
const memberOf = new Map(); // dst -> { p, r }
|
||
const reads = new Map(); // src -> { p, r } (edge src ->reads-> doc:0)
|
||
for (const [key, v] of persistent) {
|
||
const [src, rel, dst] = key.split('|');
|
||
if (rel === 'member_of') memberOf.set(dst, v);
|
||
else reads.set(src, v);
|
||
}
|
||
for (const e of partialEdges) {
|
||
if (e.relation === 'member_of') {
|
||
if (!memberOf.has(e.dst)) memberOf.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0 });
|
||
} else {
|
||
if (!reads.has(e.src)) reads.set(e.src, { p: e.possibility, r: e.reliability ?? 1.0 });
|
||
}
|
||
}
|
||
let best = 0;
|
||
let bestReliability = 0;
|
||
for (const [mid, m] of memberOf) {
|
||
const rd = reads.get(mid);
|
||
if (rd === undefined) continue;
|
||
const combinedP = Math.min(m.p, rd.p);
|
||
if (combinedP > best) {
|
||
best = combinedP;
|
||
bestReliability = (m.r ?? 1.0) * (rd.r ?? 1.0);
|
||
}
|
||
}
|
||
return {
|
||
engine: round4(r.possibility),
|
||
expected: round4(best),
|
||
engineReliability: round4(r.reliability ?? 0),
|
||
expectedReliability: round4(bestReliability)
|
||
};
|
||
},
|
||
clone() { return w; }
|
||
};
|
||
return w;
|
||
}
|
||
|
||
const result = await rigor.campaign(
|
||
[rigor.object('graph', makeWrapper, [
|
||
rigor.method('setEdge', function (rel, dst, p, reli, side) { return this.setEdge(rel, dst, p, reli, side); },
|
||
rigor.args(
|
||
rigor.gen.oneOf(['member_of', 'reads']),
|
||
rigor.gen.oneOf(['g:0', 'g:1', 'doc:0']),
|
||
rigor.gen.float(0.1, 1.0),
|
||
rigor.gen.oneOf([0.3, 0.6, 0.9]),
|
||
rigor.gen.oneOf(['persistent', 'partial'])
|
||
)),
|
||
rigor.method('clearAll', function () { return this.clearAll(); }),
|
||
rigor.method('check', function () { return this.check(); })
|
||
])],
|
||
rigor.crucible([
|
||
rigor.invariant('chain decision parity', (ctx) => {
|
||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||
return ctx.actual.engine === ctx.actual.expected;
|
||
}),
|
||
rigor.invariant('chain reliability parity', (ctx) => {
|
||
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
||
if (ctx.actual.expected === 0) return true; // denied: reliability 0
|
||
return ctx.actual.engineReliability === ctx.actual.expectedReliability;
|
||
}),
|
||
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
||
])
|
||
).run({ effort: 400, seed: "chain-reliability-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } });
|
||
|
||
const inv = result.crucibleVerdict;
|
||
assert.equal(inv.passed, true, [
|
||
`chain reliability violated in ${inv.failureCount} cases:`,
|
||
...result.failures.slice(0, 3).map((f) =>
|
||
` [${f.name}] action=${f.actionName} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} error=${f.error}`
|
||
)
|
||
].join('\n'));
|
||
}, 90000);
|
||
|
||
it('PROPERTY CAMPAIGN: exclusion differential under random split edges', async () => {
|
||
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);
|
||
}); |