0cb0d7c8cb
The partial graph is the caller's self-contained view of the world — so the time belongs in it. partialGraph.now becomes the evaluation clock for that check (TTL gates, proof expiry, decay all honor it), with an explicit options.now taking precedence. The explain rerun replays the SAME partial graph object and reproduces the original decision; the serializer records request.temporal.now for the record. Pins: partialGraph.now driving a challenge decision both sides of the window, the explicit-override precedence, and the explain rerun with the same object.
314 lines
17 KiB
JavaScript
314 lines
17 KiB
JavaScript
/**
|
|
* rigor/validity-parity.test.js — possibilistic validity metadata.
|
|
*
|
|
* Pins the Cella-FVN-inspired validity layer:
|
|
* - every check result carries a validity block {label, operator, regime,
|
|
* sources, conflictMass, validifiedPossibility, nonMaxitive}
|
|
* - unlabeled relations default to heuristic; a labeled relation
|
|
* propagates its label through identity/max fusion
|
|
* - max (disjunctive) fusion preserves the weakest source label
|
|
* - interior OWA/averaging is non-maxitive and always heuristic
|
|
* - min (conjunctive) fusion is approximate at best, surfaces the
|
|
* conflict mass (1 - possibility), and exposes the arbitrary-regime
|
|
* validification min(1, K*possibility)
|
|
* - product-style operators (exclusion, defeasible) are always heuristic
|
|
* - reliability and validity are distinct: reliability stays the scalar
|
|
* confidence; validity tracks the epistemic label
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { Arbiter } from '../../src/index.js';
|
|
import { buildValidity, mergeValidity, weakestValidity, DEFAULT_VALIDITY } from '../../src/core/validity.js';
|
|
|
|
const child = (r) => ({ type: 'direct', relation: r });
|
|
|
|
function mk() {
|
|
const a = new Arbiter();
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('d:0', 'doc');
|
|
a.addNode('g:0', 'group');
|
|
return a;
|
|
}
|
|
|
|
describe('Possibilistic validity metadata (rigor)', () => {
|
|
it('FIXED: labels, operators, conflict mass, validification per kind', () => {
|
|
// direct unlabeled -> heuristic identity; default carries the minimal
|
|
// public block (label/operator), the full detail is includeMeta-only
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { type: 'direct', relation: 'r1' });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8 });
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(r.validity.label, 'heuristic');
|
|
assert.equal(r.validity.operator, 'identity');
|
|
const r2 = a.check('u:0', 't', 'd:0', { includeMeta: true });
|
|
assert.deepEqual(r2.validity.sources, ['r1']);
|
|
assert.equal(r2.validity.conflictMass, 0);
|
|
}
|
|
// labeled direct propagates its label
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { type: 'direct', relation: 'r1' });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
|
|
assert.equal(a.check('u:0', 't', 'd:0').validity.label, 'finite_sample');
|
|
}
|
|
// max fusion preserves the weakest source label
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { union: [child('r1'), child('r2')] });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
|
|
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'conformal' });
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(r.validity.label, 'conformal', 'weakest label wins through max');
|
|
assert.equal(r.validity.operator, 'max');
|
|
assert.equal(a.check('u:0', 't', 'd:0', { includeMeta: true }).validity.nonMaxitive, false);
|
|
}
|
|
// interior OWA is non-maxitive heuristic
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { union: [child('r1'), child('r2')], aggregator: 'average' });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
|
|
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(r.validity.operator, 'owa');
|
|
assert.equal(a.check('u:0', 't', 'd:0', { includeMeta: true }).validity.nonMaxitive, true);
|
|
assert.equal(r.validity.label, 'heuristic', 'averaging never claims validity');
|
|
}
|
|
// min fusion: approximate, conflict mass, validification
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { intersection: [child('r1'), child('r2')] });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
|
|
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(r.validity.operator, 'min');
|
|
assert.equal(r.validity.label, 'approximate', 'unvalidified conjunctive is approximate at best');
|
|
const rDetail = a.check('u:0', 't', 'd:0', { includeMeta: true });
|
|
assert.ok(Math.abs(rDetail.validity.conflictMass - (1 - 0.5)) < 1e-9, 'conflict mass = 1 - possibility');
|
|
assert.equal(rDetail.validity.validifiedPossibility, 1, 'min(1, K*gamma) with K=2, gamma=0.5');
|
|
}
|
|
// product operators are heuristic even with labeled sources
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { exclusion: [child('r1'), child('r2')] });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
|
|
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
|
|
assert.equal(a.check('u:0', 't', 'd:0').validity.label, 'heuristic');
|
|
a.setRelationConfig('t2', { type: 'defeasible', when: child('r1'), unless: child('r2') });
|
|
assert.equal(a.check('u:0', 't2', 'd:0').validity.label, 'heuristic');
|
|
}
|
|
// chain: conjunctive ranking with conflict surfacing
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' }] });
|
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.9 });
|
|
a.addRelation('g:0', 'viewer', 'd:0', { possibility: 0.6 });
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(r.validity.operator, 'min');
|
|
assert.ok(Math.abs(a.check('u:0', 't', 'd:0', { includeMeta: true }).validity.conflictMass - 0.4) < 1e-9, 'chain conflict mass');
|
|
}
|
|
// reliability and validity stay distinct
|
|
{
|
|
const a = mk();
|
|
a.setRelationConfig('t', { type: 'direct', relation: 'r1' });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, reliability: 0.42, validity: 'finite_sample' });
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(r.reliability, 0.42, 'reliability unchanged');
|
|
assert.equal(r.validity.label, 'finite_sample', 'validity independent of reliability');
|
|
}
|
|
});
|
|
|
|
it('PROPERTY CAMPAIGN: helper semantics (weakest, merge, default identity)', async () => {
|
|
const result = await rigor.campaign(
|
|
[rigor.fn('helpers', (labels) => {
|
|
const weakest = weakestValidity(labels);
|
|
const merged = mergeValidity(labels.map(l => ({
|
|
label: l, operator: 'max', regime: 'arbitrary', sources: ['r'], nonMaxitive: false, conflictMass: 0, validifiedPossibility: null
|
|
})));
|
|
const single = mergeValidity([{
|
|
label: labels[0], operator: 'max', regime: 'arbitrary', sources: ['r'], nonMaxitive: false, conflictMass: 0, validifiedPossibility: null
|
|
}]);
|
|
return {
|
|
weakest: weakestValidity([labels[0], weakest]),
|
|
mergedWeakest: merged.label === weakest,
|
|
singlePass: single === undefined ? false : single.label === labels[0],
|
|
defaultIsFrozen: Object.isFrozen(DEFAULT_VALIDITY),
|
|
buildPositional: buildValidity('min', ['a'], ['finite_sample'], 2, 0.5).validifiedPossibility === 1
|
|
};
|
|
}, rigor.args(rigor.gen.array(rigor.gen.oneOf(['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown']), 1, 4)))],
|
|
rigor.crucible([
|
|
rigor.invariant('helper invariants', ({ error, errorMessage, actual }) => !error && !errorMessage && Object.values(actual).every(Boolean))
|
|
])
|
|
).run({ effort: 200, seed: 'validity-helpers-2026', artifacts: { dir: '', persist: 'never' } });
|
|
|
|
const inv = result.crucibleVerdict?.invariants?.find(i => i.name === 'helper invariants');
|
|
assert.ok(inv && inv.passed, `validity helpers violated in ${inv?.failureCount} cases`);
|
|
});
|
|
});
|
|
|
|
describe('Security affordances (rigor)', () => {
|
|
it('FIXED: default results carry only the minimal validity; detail is opt-in', () => {
|
|
const a = mk();
|
|
a.setRelationConfig('t', { intersection: [child('r1'), child('r2')] });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
|
|
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
|
|
// default: minimal block — no conflict mass, no sources, no validified value
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(r.validity.label, 'approximate');
|
|
assert.equal(r.validity.operator, 'min');
|
|
assert.ok(!('conflictMass' in r.validity), 'conflict mass is not on the default result');
|
|
assert.ok(!('validifiedPossibility' in r.validity), 'validified value is not on the default result');
|
|
assert.ok(!('sources' in r.validity), 'sources are not on the default result');
|
|
// includeMeta: full debugging detail
|
|
const r2 = a.check('u:0', 't', 'd:0', { includeMeta: true });
|
|
assert.equal(r2.validity.conflictMass, 0.5);
|
|
assert.equal(r2.validity.validifiedPossibility, 1);
|
|
assert.deepEqual(r2.validity.sources, ['r1', 'r2']);
|
|
// explain (internal surface) carries the full block
|
|
const e = a.explain('u:0', 't', 'd:0');
|
|
assert.equal(e.decision?.validity?.conflictMass, 0.5, 'explain carries full validity');
|
|
});
|
|
|
|
it('FIXED: audit hook emits one record per check; absent by default', () => {
|
|
const records = [];
|
|
const a = new Arbiter({ audit: (entry) => records.push(entry) });
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('d:0', 'doc');
|
|
a.setRelationConfig('t', { type: 'direct', relation: 'r1' });
|
|
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
|
|
const r = a.check('u:0', 't', 'd:0');
|
|
assert.equal(records.length, 1, 'one audit record per check');
|
|
assert.equal(records[0].decision, 'allow');
|
|
assert.equal(records[0].possibility, 0.8);
|
|
assert.equal(records[0].validityLabel, 'finite_sample');
|
|
assert.deepEqual(records[0].sources, ['r1']);
|
|
assert.equal(records[0].partialGraphUsed, false);
|
|
// overlay checks flag partial usage
|
|
a.check('u:0', 't', 'd:0', { partialGraph: { relations: [{ src: 'u:0', relation: 'r1', dst: 'd:0', possibility: 0.9 }] } });
|
|
assert.equal(records[1].partialGraphUsed, true);
|
|
// no hook -> no records, no crash
|
|
const plain = new Arbiter();
|
|
plain.addNode('u:0', 'user');
|
|
plain.addNode('d:0', 'doc');
|
|
plain.setRelationConfig('t', { type: 'direct', relation: 'r1' });
|
|
plain.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8 });
|
|
assert.equal(plain.check('u:0', 't', 'd:0').possibility, 0.8);
|
|
});
|
|
|
|
it('FIXED: oversized partial graphs are rejected before allocation', () => {
|
|
const a = new Arbiter({ partialGraphPolicy: { maxRelations: 2, maxNodes: 3 } });
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('d:0', 'doc');
|
|
a.setRelationConfig('t', { type: 'direct', relation: 'owner' });
|
|
assert.throws(
|
|
() => a.check('u:0', 't', 'd:0', { partialGraph: { relations: [{}, {}, {}, {}] } }),
|
|
/exceeds max relations/,
|
|
'relation limit enforced pre-allocation'
|
|
);
|
|
assert.throws(
|
|
() => a.check('u:0', 't', 'd:0', { partialGraph: { nodes: [{}, {}, {}, {}] } }),
|
|
/exceeds max nodes/,
|
|
'node limit enforced pre-allocation'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Persistence losslessness (rigor)', () => {
|
|
it('FIXED: validity, decay config, and TTLs survive the snapshot round trip', async () => {
|
|
const a = new Arbiter();
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('d:0', 'doc');
|
|
a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
|
a.addRelation('u:0', 'owner', 'd:0', {
|
|
possibility: 0.8,
|
|
reliability: 0.42,
|
|
value: 7,
|
|
validity: 'finite_sample',
|
|
decayConfig: { halfLifeMs: 60000 }
|
|
});
|
|
a.valueManager.setTTL('owner', 30000);
|
|
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 rel = restored.relationManager.getDirectRelation(restored.resolveNodeId('u:0'), 'owner', restored.resolveNodeId('d:0'));
|
|
assert.equal(rel.validity, 'finite_sample', 'validity label survives persistence');
|
|
assert.deepEqual(rel.decayConfig, { halfLifeMs: 60000 }, 'decay config survives persistence');
|
|
assert.equal(restored.valueManager.getTTL('owner'), 30000, 'per-relation TTL survives persistence');
|
|
const r = restored.check('u:0', 'can_read', 'd:0', { includeMeta: true });
|
|
assert.equal(r.validity.label, 'finite_sample', 'restored check carries the persisted label');
|
|
// and a second restore of the same buffer is byte-stable
|
|
const restored2 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(a), {}, () => new Arbiter());
|
|
assert.equal(restored2.relationManager.getDirectRelation(restored2.resolveNodeId('u:0'), 'owner', restored2.resolveNodeId('d:0')).validity, 'finite_sample');
|
|
});
|
|
});
|
|
|
|
describe('Temporal replay (re-entrant diagnostics) (rigor)', () => {
|
|
it('FIXED: pinned-clock checks are per-time, and the explain rerun reproduces the decision', () => {
|
|
const a = new Arbiter();
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('d:0', 'doc');
|
|
a.setRelationConfig('premium', {
|
|
type: 'relational_comparator', comparator: '>',
|
|
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true },
|
|
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
|
});
|
|
a.valueManager.setTTL('has_balance', 60000);
|
|
a.valueManager.setTTL('has_price', 60000);
|
|
const t0 = 1000000;
|
|
a.addRelation('u:0', 'has_balance', 'd:0', { value: 100, changed_last_at: t0 });
|
|
a.addRelation('d:0', 'has_price', 'd:0', { value: 50, changed_last_at: t0 });
|
|
|
|
// Interleaved pinned-clock checks must each reflect their own clock —
|
|
// no cross-contamination through any result cache.
|
|
assert.equal(a.check('u:0', 'premium', 'd:0', { now: t0 + 120000 }).possibility, 0, 'expired at +120s');
|
|
assert.equal(a.check('u:0', 'premium', 'd:0', { now: t0 + 1000 }).possibility, 1, 'fresh at +1s');
|
|
assert.equal(a.check('u:0', 'premium', 'd:0', { now: t0 + 120000 }).possibility, 0, 'expired again');
|
|
|
|
// The explain rerun reproduces the pinned decision and records the
|
|
// temporal context the caller must replay.
|
|
const e = a.explain('u:0', 'premium', 'd:0', { now: t0 + 120000 });
|
|
assert.equal(e.decision.possibility, 0, 'explain rerun reproduces the expired decision');
|
|
assert.deepEqual(e.request.temporal, { now: t0 + 120000 }, 'temporal context recorded for replay');
|
|
const e2 = a.explain('u:0', 'premium', 'd:0', { now: t0 + 1000 });
|
|
assert.equal(e2.decision.possibility, 1, 'explain rerun reproduces the fresh decision');
|
|
});
|
|
|
|
it('FIXED: challenge proof expiry is replayable via the pinned clock', () => {
|
|
const a = new Arbiter();
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('d:0', 'doc');
|
|
a.setRelationConfig('can_download', { type: 'challenge', challenge: 'mfa', subject: 'user', withinMinutes: 5 });
|
|
const issued = 500000;
|
|
const proof = [{ name: 'mfa', subject: 'u:0', issuedAt: issued, expiresAt: null }];
|
|
assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: { challenges: proof }, now: issued + 299000 }).possibility, 1, 'within window');
|
|
assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: { challenges: proof }, now: issued + 301000 }).possibility, 0, 'past window');
|
|
const e = a.explain('u:0', 'can_download', 'd:0', { partialGraph: { challenges: proof }, now: issued + 301000 });
|
|
assert.equal(e.decision.possibility, 0, 'explain reproduces the expired proof');
|
|
assert.deepEqual(e.request.temporal, { now: issued + 301000 }, 'challenge temporal recorded');
|
|
});
|
|
});
|
|
|
|
describe('Partial graph carries the temporal context (rigor)', () => {
|
|
it('FIXED: partialGraph.now drives the decision, and the rerun replays the same object', () => {
|
|
const a = new Arbiter();
|
|
a.addNode('u:0', 'user');
|
|
a.addNode('d:0', 'doc');
|
|
a.setRelationConfig('can_download', { type: 'challenge', challenge: 'mfa', subject: 'user', withinMinutes: 5 });
|
|
const issued = 500000;
|
|
// One self-contained request object: evidence + the caller's time.
|
|
const pg = { challenges: [{ name: 'mfa', subject: 'u:0', issuedAt: issued, expiresAt: null }], now: issued + 299000 };
|
|
assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: pg }).possibility, 1, 'within window via partialGraph.now');
|
|
pg.now = issued + 301000;
|
|
assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: pg }).possibility, 0, 'past window via partialGraph.now');
|
|
// an explicit options.now overrides the partial graph's time
|
|
assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: pg, now: issued + 299000 }).possibility, 1, 'explicit now overrides');
|
|
// the rerun replays the same object and reproduces the decision
|
|
pg.now = issued + 299000;
|
|
const e = a.explain('u:0', 'can_download', 'd:0', { partialGraph: pg });
|
|
assert.equal(e.decision.possibility, 1, 'explain rerun with the same partial graph reproduces');
|
|
assert.deepEqual(e.request.temporal, { now: issued + 299000 }, 'temporal recorded from the partial graph');
|
|
});
|
|
});
|