Files
core/tests/rigor/validity-parity.test.js
John Dvorak ed34df4474
CI / benchmark (push) Successful in 48s
CI / test (push) Successful in 5m26s
CI / publish (push) Has been skipped
feat: intermediate chain condition steps, graph-version cache invalidation, rolling-hash chain keys; fix vacuous rigor invariants
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
  position is now EXPANDED from the current node — the rule's base edges'
  destinations, filtered by its defeaters/requirements — and traversal
  continues from each discovered node. Adds _expandRuleFromSrc / direct /
  logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
  entity IS the object parameter (trusted(other) inside peer_trusted(user,
  other)); previously only subject-var unary calls (_subjectAsObject) were
  handled, so object-var unary defeaters never fired.

Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
  mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
  DecisionCache rule cache now stamp entries with the graph version and treat
  any mismatch as a miss — graph mutations can no longer serve stale
  chain/authorization results.

Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
  FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
  JSON.stringify — no string allocation or serialization on the chain-cache
  hot path. Composite keys stay structured strings because the direct-check
  cache pattern-invalidates by relation ID.

Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
  !error && !errorMessage) never saw fn throws — vacuous. Migrated to
  ({ actual }) => actual !== undefined, which fails on any thrown violation
  while passing legitimate null-skips. The migration immediately surfaced
  two latent bugs, now fixed:
    * node-manager/graph-indices skip paths returned bare undefined (falsy
      sentinel) — return { skipped: true }.
    * complex-graph-values-crucible expiry section rewrote values equal to the
      mutation loop's last write; the engine (by design) keeps the old
      timestamp on same-value rewrites so the pre-expiry grant never
      materialized. Now writes guaranteed-different values.
2026-08-03 13:26:42 -07:00

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', ({ actual }) => !!actual && 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');
});
});