diff --git a/tests/rigor/complex-graph-batch-crucible.test.js b/tests/rigor/complex-graph-batch-crucible.test.js new file mode 100644 index 0000000..31d84fd --- /dev/null +++ b/tests/rigor/complex-graph-batch-crucible.test.js @@ -0,0 +1,170 @@ +/** + * rigor/complex-graph-batch-crucible.test.js — batch-loading and decision + * cache parity on the community graph. + * + * Two engines are rebuilt from the same makeCommunityGraph(seed) fixture: + * one loaded relation-by-relation, one loaded through + * relationManager.addRelationsBatch (which expects { srcKey, relation, + * dstKey, options } — the generator's { src, rel, dst, possibility } + * objects are mapped into that shape). + * + * BATCH-SEQUENTIAL-PARITY — identical check answers on both engines for + * sampled (user, relation, object) triples + * across direct, TTU, union, exclusion, and + * chain policies. + * BATCH-MUTATION — after the same edge mutation on both engines, + * parity holds and the post-mutation answer is + * fresh (the decision cache is invalidated, not + * served stale) — even right after a warm read. + * FIXTURE-SIZE — the community fixture actually carries a + * batch-sized relation set (> 50 edges). + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { Arbiter } from '../../src/index.js'; +import { makeCommunityGraph } from './complex-graphs.js'; + +const EPS = 1e-9; +const POLICIES = ['direct_access', 'can_read', 'can_read_with_direct', 'can_read_not_blocked', 'can_delegate_read']; + +function fail(message) { + throw new Error(message); +} + +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function rebuildForBatch(g) { + const arb = new Arbiter(); + for (const node of g.arbiter.nodes.values()) arb.addNode(node.key, node.type); + for (const [name, config] of g.arbiter.relationConfigs.entries()) arb.setRelationConfig(name, config); + const edges = g.relations.map(r => ({ + srcKey: r.src, + relation: r.rel, + dstKey: r.dst, + options: { possibility: r.possibility } + })); + arb.relationManager.addRelationsBatch(edges); + return arb; +} + +function sampleTriples(g, rng) { + const triples = []; + // Real graph edges exercise the actual membership/ownership structure. + for (const r of g.relations.slice(0, 40)) triples.push([r.src, r.rel, r.dst]); + for (let i = 0; i < 40; i++) { + const u = g.users[Math.floor(rng() * g.users.length)]; + const o = g.resources[Math.floor(rng() * g.resources.length)]; + triples.push([u, POLICIES[Math.floor(rng() * POLICIES.length)], o]); + } + return triples; +} + +function assertParity(seq, batch, triples, tag) { + for (const [u, rel, o] of triples) { + const a = seq.check(u, rel, o).possibility; + const b = batch.check(u, rel, o).possibility; + if (Math.abs(a - b) > EPS) { + fail(`[parity] ${tag} ${u} ${rel} ${o}: seq=${a} batch=${b}`); + } + } +} + +describe('Complex-graph batch/cache crucibles (rigor)', () => { + it('BATCH-SEQUENTIAL-PARITY + BATCH-MUTATION + CACHE-FRESHNESS hold on the community graph', async () => { + async function check(args) { + const { seed, mode } = args; + const g = makeCommunityGraph(seed); + const seq = g.arbiter; + if (seq.relations.length <= 50) { + fail(`[fixture] community fixture has only ${seq.relations.length} relations`); + } + const batch = rebuildForBatch(g); + const rng = mulberry32(seed * 101); + + // BATCH-SEQUENTIAL-PARITY on the untouched graph. + const triples = sampleTriples(g, rng); + assertParity(seq, batch, triples, 'initial'); + + // BATCH-MUTATION + CACHE-INTERACTION. mode 0 removes an existing + // direct_access edge; mode 1 adds one to a triple that has none. + let pair; + if (mode === 0) { + pair = g.relations.find(r => r.rel === 'direct_access'); + if (!pair) fail(`[fixture] no direct_access edge on seed=${seed}`); + } else { + const clean = () => { + for (let i = 0; i < 200; i++) { + const u = g.users[Math.floor(rng() * g.users.length)]; + const o = g.resources[Math.floor(rng() * g.resources.length)]; + if (g.relations.some(r => r.src === u && r.rel === 'direct_access' && r.dst === o)) continue; + if (seq.check(u, 'direct_access', o).possibility !== 0) continue; + return { src: u, rel: 'direct_access', dst: o }; + } + return null; + }; + pair = clean(); + if (!pair) fail(`[fixture] no clean direct_access pair on seed=${seed}`); + } + + // Warm the decision cache on both engines before mutating. + seq.check(pair.src, 'direct_access', pair.dst); + batch.check(pair.src, 'direct_access', pair.dst); + batch.check(pair.src, 'direct_access', pair.dst); + + const expectedAfter = mode === 0 ? 0 : 0.77; + if (mode === 0) { + seq.removeRelation(pair.src, 'direct_access', pair.dst); + batch.removeRelation(pair.src, 'direct_access', pair.dst); + } else { + seq.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter }); + batch.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter }); + } + const sa = seq.check(pair.src, 'direct_access', pair.dst).possibility; + const ba = batch.check(pair.src, 'direct_access', pair.dst).possibility; + if (Math.abs(sa - ba) > EPS) fail(`[mutation] seq=${sa} batch=${ba} diverge after mutation`); + if (Math.abs(ba - expectedAfter) > EPS) { + fail(`[cache] batched engine returned ${ba}, expected ${expectedAfter} after mutation (stale cache)`); + } + if (Math.abs(sa - expectedAfter) > EPS) { + fail(`[cache] sequential engine returned ${sa}, expected ${expectedAfter} after mutation`); + } + + // No divergence on the broader policy surface after the mutation. + assertParity(seq, batch, triples, 'post-mutation'); + return { relations: seq.relations.length, triples: triples.length }; + } + + const report = await rigor.campaign( + [ + rigor.fn('check', check, rigor.args( + rigor.gen.object({ + seed: rigor.gen.int(1, 6), + mode: rigor.gen.oneOf([0, 1]) + }) + )) + ], + rigor.crucible([ + rigor.invariant('batch-sequential-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[parity]')), + rigor.invariant('batch-mutation', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')), + rigor.invariant('cache-interaction', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[cache]')), + rigor.invariant('fixture-size', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[fixture]')) + ]) + ).run({ effort: 150, seed: 'complex-graph-batch-crucible', artifacts: { dir: '', persist: 'never' } }); + + for (const name of ['batch-sequential-parity', 'batch-mutation', 'cache-interaction', 'fixture-size']) { + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name); + assert.ok(inv, `invariant ${name} missing`); + assert.equal(inv.passed, true, `batch ${name} violated in ${inv.failureCount} cases`); + } + }); +}); diff --git a/tests/rigor/complex-graph-overlay-crucible.test.js b/tests/rigor/complex-graph-overlay-crucible.test.js new file mode 100644 index 0000000..c2a69c8 --- /dev/null +++ b/tests/rigor/complex-graph-overlay-crucible.test.js @@ -0,0 +1,161 @@ +/** + * rigor/complex-graph-overlay-crucible.test.js — partial-graph overlay over + * the complex graphs (community block model + org hierarchy). + * + * An overlay is a caller-supplied set of facts consulted alongside the + * persistent graph. The check option key for a pre-built + * PartialGraphContext is `partialGraphContext` (AuthorizationChecker + * reads that key; passing the context under `partialGraph` would be + * re-ingested as a raw spec and silently empty). The persistent relation + * is ORed into the direct lookup, so it wins when both are present. + * + * OVERLAY-SURFACES — with no persistent edge, an overlay fact + * grants exactly its possibility on a direct + * relation. + * PERSISTENT-WINS — persistent + overlay -> persistent value; + * removing the persistent edge surfaces the + * overlay. + * OVERLAY-BINARY-PARITY — binary and normal agree on the same overlay + * (binary.allow === (normal >= 0.8), and the + * direct path returns the overlay possibility + * in both modes). + * OVERLAY-ON-COMPLEX — an overlay fact on a DIRECT relation that a + * complex policy consumes (union: direct_access; + * chain: member/parent/owns) surfaces through + * that policy. Overlay facts on a relation name + * that is itself configured tuple_to_userset + * are ignored by the TTU evaluator, so the + * overlay must ride the direct edge. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { PartialGraphContext } from '../../src/core/PartialGraphContext.js'; +import { makeCommunityGraph, makeHierarchyGraph } from './complex-graphs.js'; + +const EPS = 1e-9; +const P_OVERLAY = [0.2, 0.4, 0.6, 0.8, 0.9]; +const P_PERSISTENT = 0.85; + +const GENERATORS = [ + { name: 'community', make: makeCommunityGraph, directRel: 'direct_access', complexRel: 'can_read_with_direct' }, + { name: 'hierarchy', make: makeHierarchyGraph, directRel: 'owns', complexRel: 'can_access_org' } +]; + +function fail(message) { + throw new Error(message); +} + +describe('Complex-graph overlay crucibles (rigor)', () => { + it('OVERLAY-SURFACES / PERSISTENT-WINS / BINARY-PARITY / ON-COMPLEX hold across complex graphs', async () => { + async function check(args) { + const { genKind, seed, layer } = args; + const spec = GENERATORS.find(g => g.name === genKind); + const g = spec.make(seed); + const arbiter = g.arbiter; + const u = g.users[0]; + const pOverlay = P_OVERLAY[layer % P_OVERLAY.length]; + + let o; + if (genKind === 'community') { + // Pick a resource with NO persistent can_read_with_direct path, so + // the overlay is the only source for the complex-policy check. + o = g.resources.find(r => arbiter.check(u, spec.complexRel, r).possibility === 0); + if (!o) fail(`[surfaces] no zero-persistent resource on community seed=${seed}`); + // Drop any persistent direct_access edge on the triple. + for (const r of (g.relations || [])) { + if (r.src === u && r.rel === 'direct_access' && r.dst === o) arbiter.removeRelation(u, 'direct_access', o); + } + } else { + // Hierarchy: users never hold persistent 'owns' edges, so any + // resource is overlay-clean on the direct relation. + o = g.resources[0]; + } + + const ctx = new PartialGraphContext(arbiter, { + relations: [{ src: u, relation: spec.directRel, dst: o, possibility: pOverlay }] + }); + const checkWithOverlay = (rel, object, ctxFor) => + arbiter.check(u, rel, object, { partialGraphContext: ctxFor }); + + // OVERLAY-SURFACES: no persistent edge -> overlay grants exactly pOverlay. + const surfaced = checkWithOverlay(spec.directRel, o, ctx).possibility; + if (Math.abs(surfaced - pOverlay) > EPS) { + fail(`[surfaces] overlay ${pOverlay} did not surface on ${genKind} ${u}->${o}: ${surfaced}`); + } + + // PERSISTENT-WINS: persistent edge wins; removal surfaces the overlay. + arbiter.addRelation(u, spec.directRel, o, { possibility: P_PERSISTENT }); + const withBoth = checkWithOverlay(spec.directRel, o, ctx).possibility; + if (Math.abs(withBoth - P_PERSISTENT) > EPS) { + fail(`[wins] persistent ${P_PERSISTENT} did not win over overlay ${pOverlay}: ${withBoth}`); + } + arbiter.removeRelation(u, spec.directRel, o); + const resurfaced = checkWithOverlay(spec.directRel, o, ctx).possibility; + if (Math.abs(resurfaced - pOverlay) > EPS) { + fail(`[wins] overlay did not resurface after persistent removal: ${resurfaced}`); + } + + // OVERLAY-BINARY-PARITY on the direct relation. + const normal = checkWithOverlay(spec.directRel, o, ctx); + const binary = arbiter.check(u, spec.directRel, o, { partialGraphContext: ctx, binary: true }); + if (Math.abs(binary.possibility - normal.possibility) > EPS) { + fail(`[binary] direct overlay binary=${binary.possibility} normal=${normal.possibility} disagree`); + } + if (binary.allow !== (normal.possibility >= 0.8)) { + fail(`[binary] binary.allow=${binary.allow} != normal>=0.8 (${normal.possibility})`); + } + + // OVERLAY-ON-COMPLEX: the overlay rides a direct relation the policy + // consumes and surfaces through the complex relation. + if (genKind === 'community') { + const viaUnion = checkWithOverlay(spec.complexRel, o, ctx).possibility; + if (Math.abs(viaUnion - pOverlay) > EPS) { + fail(`[complex] overlay ${pOverlay} did not surface through union ${spec.complexRel}: ${viaUnion}`); + } + } else { + // can_access_org = member -> parent -> owns. The overlay provides the + // full chain; the last hop carries the overlay strength. + const team = g.teams[0]; + const dept = team.split(':team:')[0]; + const org = 'org:0'; + const chainCtx = new PartialGraphContext(arbiter, { + relations: [ + { src: u, relation: 'member', dst: team, possibility: 1 }, + { src: team, relation: 'parent', dst: dept, possibility: 1 }, + { src: dept, relation: 'owns', dst: org, possibility: pOverlay } + ] + }); + const viaChain = checkWithOverlay(spec.complexRel, org, chainCtx).possibility; + if (Math.abs(viaChain - pOverlay) > EPS) { + fail(`[complex] overlay ${pOverlay} did not surface through chain ${spec.complexRel}: ${viaChain}`); + } + } + return { ok: true }; + } + + const report = await rigor.campaign( + [ + rigor.fn('check', check, rigor.args( + rigor.gen.object({ + genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)), + seed: rigor.gen.int(1, 6), + layer: rigor.gen.int(0, 4) + }) + )) + ], + rigor.crucible([ + rigor.invariant('overlay-surfaces', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[surfaces]')), + rigor.invariant('persistent-wins', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[wins]')), + rigor.invariant('overlay-binary-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[binary]')), + rigor.invariant('overlay-on-complex', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[complex]')) + ]) + ).run({ effort: 200, seed: 'complex-graph-overlay-crucible', artifacts: { dir: '', persist: 'never' } }); + + for (const name of ['overlay-surfaces', 'persistent-wins', 'overlay-binary-parity', 'overlay-on-complex']) { + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name); + assert.ok(inv, `invariant ${name} missing`); + assert.equal(inv.passed, true, `overlay ${name} violated in ${inv.failureCount} cases`); + } + }); +}); diff --git a/tests/rigor/complex-graph-quantization-crucible.test.js b/tests/rigor/complex-graph-quantization-crucible.test.js new file mode 100644 index 0000000..808f991 --- /dev/null +++ b/tests/rigor/complex-graph-quantization-crucible.test.js @@ -0,0 +1,124 @@ +/** + * rigor/complex-graph-quantization-crucible.test.js — condensed-snapshot + * quantization parity over the community graph. + * + * The generator emits varied non-dyadic possibilities (0.3..1.0). The + * query set is drawn from the graph's OWN edges (direct_access, member-fed + * TTU can_read, delegate-fed chain can_delegate_read) so the sampled + * queries are granted and actually carry quantization error — random + * (user, resource) pairs are mostly denied (live == restored == 0) and + * would make the band vacuous. + * + * QUANT-BAND — |live - restored| <= 2 * QUANT_STEP (the chain path + * multiplies two quantized inputs, so twice the single + * value's band). + * DECISION — outside the quantization band of the threshold the + * allow/deny decision must agree; inside it a flip is + * allowed (the existing snapshot-quantization-parity rule). + * RE-SERIALIZE— snapshot-of-snapshot (serialize the restored engine, + * restore again) is semantically identical. + * + * Deterministic fixed loop over seeds — no campaign needed. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Arbiter } from '../../src/index.js'; +import { makeCommunityGraph } from './complex-graphs.js'; + +const QUANT_STEP = 0.5 / 65535; +const TOL = QUANT_STEP * 2; +const SAMPLES = 40; + +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function grantedQuerySet(g) { + const qs = []; + const ownsBySrc = new Map(); + for (const r of g.relations) if (r.rel === 'owns') ownsBySrc.set(r.src, r.dst); + for (const r of g.relations) { + if (r.rel === 'direct_access') qs.push([r.src, 'direct_access', r.dst]); + if (r.rel === 'member') { + const obj = ownsBySrc.get(r.dst); + if (obj) { + qs.push([r.src, 'can_read', obj]); + qs.push([r.src, 'can_read_not_blocked', obj]); + } + } + if (r.rel === 'delegate') { + for (const m of g.relations) { + if (m.rel === 'member' && m.dst === r.src) { + qs.push([m.src, 'can_delegate_read', r.dst]); + break; + } + } + } + } + return qs; +} + +function sample(querySet, seed) { + const rng = mulberry32(seed * 997); + const out = []; + for (let i = 0; i < SAMPLES; i++) { + out.push(querySet[Math.floor(rng() * querySet.length)]); + } + return out; +} + +describe('Complex-graph snapshot quantization crucible', () => { + it('QUANT-BAND + DECISION + RE-SERIALIZE hold across seeds', () => { + for (const seed of [1, 2, 3, 4]) { + const g = makeCommunityGraph(seed); + const arbiter = g.arbiter; + const queries = sample(grantedQuerySet(g), seed); + assert.ok(queries.length > 0, `seed ${seed}: empty granted query set`); + + const live = queries.map(([u, rel, o]) => arbiter.check(u, rel, o).possibility); + + arbiter.enableCondensedSnapshot(); + const buf = arbiter.toSnapshotBinary(); + const restored = Arbiter.fromSnapshotBinary(buf); + const restored2 = Arbiter.fromSnapshotBinary(restored.toSnapshotBinary()); + + for (let i = 0; i < queries.length; i++) { + const [u, rel, o] = queries[i]; + const lv = live[i]; + const rv = restored.check(u, rel, o).possibility; + const r2v = restored2.check(u, rel, o).possibility; + + assert.ok( + Math.abs(lv - rv) <= TOL, + `seed ${seed} ${u} ${rel} ${o}: live=${lv} restored=${rv} exceeds ${TOL}` + ); + + // Allow/deny agreement on the 0.5 threshold, except inside the + // quantization band where a flip is permitted. + const inBand = Math.abs(lv - 0.5) < QUANT_STEP; + if (!inBand) { + assert.equal( + rv >= 0.5, lv >= 0.5, + `seed ${seed} ${u} ${rel} ${o}: decision flipped outside band (live=${lv} restored=${rv})` + ); + } + // Grant parity (possibility > 0): granted queries all carry + // possibility >= 0.3, so a flip here would be a real loss. + assert.equal(rv > 0, lv > 0, `seed ${seed} ${u} ${rel} ${o}: grant lost in restore`); + + // Snapshot-of-snapshot is semantically identical. + assert.ok( + Math.abs(r2v - rv) <= TOL, + `seed ${seed} ${u} ${rel} ${o}: re-serialized=${r2v} != first restore=${rv}` + ); + } + } + }); +}); diff --git a/tests/rigor/complex-graph-reachability-crucible.test.js b/tests/rigor/complex-graph-reachability-crucible.test.js new file mode 100644 index 0000000..6b46f9b --- /dev/null +++ b/tests/rigor/complex-graph-reachability-crucible.test.js @@ -0,0 +1,163 @@ +/** + * rigor/complex-graph-reachability-crucible.test.js — PLTC reachability + * over the scale-free and community graphs, compared against ground-truth + * directed DFS. + * + * The PLTC index is built from every relation in arbiter.relations (edge + * src -> dst, deduplicated by pair), so the ground truth is the same edge + * set: the generator's relations for community, and the live engine's + * relation rows for scale-free (whose generator returns `relations: + * null`). All of these relations are configured `direct`, so "direct + * relations" and "all relations" coincide. + * + * VERDICT-PARITY — a non-null isReachable verdict equals ground truth. + * FAST-FAIL-SOUNDNESS — isReachable(...) === true implies ground truth + * is true (PLTC must not manufacture reachability). + * NULL-DEFER — isReachable returns null when the PLTC index is + * unavailable; a null verdict is the documented "delegate to rules" + * contract and is skipped, never failed. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { makeCommunityGraph, makeScaleFreeGraph } from './complex-graphs.js'; + +const SAMPLES = 12; + +const GENERATORS = [ + { name: 'community', make: makeCommunityGraph, opts: {} }, + // Default scale-free (150 users / 400 edges) is slow to build per case; + // a 60-user power-law graph keeps the PLTC-vs-DFS comparison meaningful + // without dominating the suite. + { name: 'scale-free', make: makeScaleFreeGraph, opts: { users: 60, resources: 20, edges: 150 } } +]; + +function fail(message) { + throw new Error(message); +} + +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function allNodeKeys(g) { + const keys = [...g.users, ...(g.resources || []), ...(g.groups || []), ...(g.subGroups || [])]; + return keys.filter(k => g.arbiter.nodeIdByKey.has(k)); +} + +function groundTruthEdges(g) { + const seen = new Set(); + const edges = []; + const push = (s, d) => { + const k = `${s}\u0000${d}`; + if (!seen.has(k)) { seen.add(k); edges.push([s, d]); } + }; + if (g.relations) { + for (const r of g.relations) push(r.src, r.dst); + } else { + for (const r of g.arbiter.relations) { + const sk = g.arbiter.keyByNodeId.get(r.src); + const dk = g.arbiter.keyByNodeId.get(r.dst); + if (sk !== undefined && dk !== undefined) push(sk, dk); + } + } + return edges; +} + +function buildAdjacency(keys, edges) { + const adj = new Map(); + for (const k of keys) adj.set(k, []); + for (const [s, d] of edges) { + if (!adj.has(s)) adj.set(s, []); + if (!adj.has(d)) adj.set(d, []); + adj.get(s).push(d); + } + return adj; +} + +function dfsReachable(adj, src, dst) { + if (src === dst) return true; + const visited = new Set([src]); + const stack = [src]; + while (stack.length) { + const cur = stack.pop(); + for (const next of adj.get(cur) || []) { + if (next === dst) return true; + if (!visited.has(next)) { visited.add(next); stack.push(next); } + } + } + return false; +} + +describe('Complex-graph PLTC reachability crucibles (rigor)', () => { + it('VERDICT-PARITY + FAST-FAIL-SOUNDNESS against ground-truth DFS, with NULL-DEFER', async () => { + async function check(args) { + const { genKind, seed, srcIdx, dstIdx } = args; + const spec = GENERATORS.find(g => g.name === genKind); + const g = spec.make(seed, spec.opts); + const keys = allNodeKeys(g); + const adj = buildAdjacency(keys, groundTruthEdges(g)); + + await g.arbiter.initializeReachabilityChecker(); + + const rng = mulberry32(seed * 7919 + 17); + // The campaign's srcIdx/dstIdx seed the first pair; the rest are + // drawn from a per-case deterministic stream so every case samples + // more than one edge of the graph. + let nonNull = 0; + let verdictChecks = 0; + for (let i = 0; i < SAMPLES; i++) { + const a = i === 0 ? keys[srcIdx % keys.length] : keys[Math.floor(rng() * keys.length)]; + const b = i === 0 ? keys[dstIdx % keys.length] : keys[Math.floor(rng() * keys.length)]; + const v = g.arbiter.isReachable(a, b); + if (v === null) continue; // PLTC unavailable -> defer to rule eval + nonNull++; + const gt = dfsReachable(adj, a, b); + verdictChecks++; + if (v !== gt) { + fail(`[verdict] ${genKind} seed=${seed}: isReachable(${a},${b})=${v} != ground truth ${gt}`); + } + if (v === true && gt !== true) { + fail(`[soundness] ${genKind} seed=${seed}: PLTC false positive on ${a}->${b}`); + } + } + // Vacuity guard: a case whose sampled pairs all hit the NULL-DEFER + // path proves nothing about verdict parity. + if (verdictChecks === 0) { + fail(`[vacuity] no non-null PLTC verdicts sampled for ${genKind} seed=${seed}`); + } + return { nonNull, verdictChecks }; + } + + const report = await rigor.campaign( + [ + rigor.fn('check', check, rigor.args( + rigor.gen.object({ + genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)), + seed: rigor.gen.int(1, 6), + srcIdx: rigor.gen.int(0, 199), + dstIdx: rigor.gen.int(0, 199) + }) + )) + ], + rigor.crucible([ + rigor.invariant('verdict-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[verdict]')), + rigor.invariant('fast-fail-soundness', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[soundness]')), + rigor.invariant('null-defer-contract', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[vacuity]')) + ]) + ).run({ effort: 200, seed: 'complex-graph-reachability-crucible', artifacts: { dir: '', persist: 'never' } }); + + for (const name of ['verdict-parity', 'fast-fail-soundness', 'null-defer-contract']) { + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name); + assert.ok(inv, `invariant ${name} missing`); + assert.equal(inv.passed, true, `reachability ${name} violated in ${inv.failureCount} cases`); + } + }); +}); diff --git a/tests/rigor/complex-graph-ttl-crucible.test.js b/tests/rigor/complex-graph-ttl-crucible.test.js new file mode 100644 index 0000000..79bf5f2 --- /dev/null +++ b/tests/rigor/complex-graph-ttl-crucible.test.js @@ -0,0 +1,185 @@ +/** + * rigor/complex-graph-ttl-crucible.test.js — value-TTL expiry over the + * complex graphs, with an injected clock. + * + * The graph generators ship relations WITHOUT changed_last_at, so TTL + * gating is exercised on value-carrying edges written by the test with + * pinned `changed_last_at` timestamps (the same mirror-friendly strategy + * as ttl-expiry-parity). A relational-comparator policy consumes the + * values, because TTL expiry only gates VALUE extraction — a plain direct + * check returns the relation possibility regardless of age. + * + * FRESHNESS-PARITY — a value written at `now` grants immediately, + * still grants at TTL-1, and denies at TTL+1; + * the engine matches a mirror freshness rule + * (fresh iff age <= TTL). + * MUTATION-WITH-TIME — after every value rewrite (pinned + * changed_last_at), binary mode agrees with + * normal at the current pinned `now`, and both + * agree with the mirror. + * SNAPSHOT-PRESERVES-TTL — the condensed snapshot round-trip preserves + * the TTL config AND the expiry gate: both the + * original engine (against its pinned clock) and + * the restored engine (against its effective + * write clock) grant within TTL and deny past it. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { Arbiter } from '../../src/index.js'; +import { makeCommunityGraph, makeScaleFreeGraph } from './complex-graphs.js'; + +const TTL = 60_000; +const BASE_NOW = 1_000_000_000_000; +// The restored engine reports value edges as written at snapshot-restore +// time; the deny check must sit comfortably past that wall clock. +const RESTORE_BUFFER = 5_000; + +const GENERATORS = [ + { name: 'community', make: makeCommunityGraph, opts: {} }, + // Default scale-free (150 users / 400 edges) is ~6x slower to build; + // a smaller power-law graph exercises the same TTL contract. + { name: 'scale-free', make: makeScaleFreeGraph, opts: { users: 60, resources: 20, edges: 150 } } +]; + +function fail(message) { + throw new Error(message); +} + +function configureComparator(arbiter, graphRels) { + arbiter.setRelationConfig('balance', { type: 'direct' }); + arbiter.setRelationConfig('price', { type: 'direct' }); + arbiter.setRelationConfig('premium_access', { + type: 'relational_comparator', + comparator: '>', + left: { rule: { type: 'direct', relation: 'balance' }, extractValue: true }, + right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'price' }, extractValue: true } + }); + arbiter.valueManager.setTTL('balance', TTL); + arbiter.valueManager.setTTL('price', TTL); + // The graph's own direct relations carry no values, so a TTL on them + // only gates value extraction (never the plain check) — harmless, and + // it pins the snapshot's TTL-config round-trip for those names too. + for (const rel of graphRels) arbiter.valueManager.setTTL(rel, TTL); +} + +function buildSnapshotEngine(spec, seed) { + const g = spec.make(seed, spec.opts); + const arbiter = g.arbiter; + configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']); + const u = g.users[0]; + const o = (g.resources || g.subGroups || g.groups)[0]; + arbiter.addRelation(u, 'balance', o, { value: 100, possibility: 1.0, changed_last_at: BASE_NOW }); + // price evaluates from the object, so the edge is object -> object. + arbiter.addRelation(o, 'price', o, { value: 50, possibility: 1.0, changed_last_at: BASE_NOW }); + return { g, arbiter, u, o }; +} + +describe('Complex-graph value-TTL crucibles (rigor)', () => { + it('FRESHNESS / MUTATION / SNAPSHOT-TTL: TTL expiry holds across complex graphs', async () => { + async function check(args) { + const { genKind, seed, mutationCount } = args; + const spec = GENERATORS.find(g => g.name === genKind); + const g = spec.make(seed, spec.opts); + const arbiter = g.arbiter; + configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']); + const u = g.users[0]; + const o = (g.resources || g.subGroups || g.groups)[0]; + + let engineNow = BASE_NOW; + const bal = { value: 100, ts: engineNow }; + const prc = { value: 50, ts: engineNow }; + + const write = (kind, value) => { + const src = kind === 'balance' ? u : o; + arbiter.addRelation(src, kind, o, { value, possibility: 1.0, changed_last_at: engineNow }); + const target = kind === 'balance' ? bal : prc; + // The engine only refreshes changed_last_at when the value actually + // changes; the mirror must mirror that or it un-expires old values. + if (target.value !== value) { target.value = value; target.ts = engineNow; } + }; + const expected = () => { + const bFresh = engineNow - bal.ts <= TTL; + const pFresh = engineNow - prc.ts <= TTL; + return bFresh && pFresh && bal.value > prc.value ? 1 : 0; + }; + const checkAt = () => arbiter.check(u, 'premium_access', o, { now: engineNow }).possibility; + const checkAtBinary = () => arbiter.check(u, 'premium_access', o, { now: engineNow, binary: true }).possibility; + + write('balance', 100); + write('price', 50); + + // FRESHNESS-PARITY + if (checkAt() !== 1) fail(`[freshness] fresh write did not grant (${genKind} seed=${seed})`); + engineNow += TTL - 1; + if (checkAt() !== expected()) fail(`[freshness] TTL-1 mismatch (${genKind} seed=${seed})`); + engineNow += 2; // now exactly TTL+1 since the write + const expired = checkAt(); + if (expired !== 0) fail(`[freshness] expired value still grants (${genKind} seed=${seed}: ${expired})`); + if (expired !== expected()) fail(`[freshness] mirror mismatch at expiry (${genKind} seed=${seed})`); + + // MUTATION-WITH-TIME: refresh both operands, then mutate and check. + engineNow += 100_000; + write('balance', 130); + write('price', 40); + for (let m = 0; m < mutationCount; m++) { + engineNow += 1000 * (1 + m); + const kind = m % 2 === 0 ? 'balance' : 'price'; + write(kind, [20, 60, 120][m % 3]); + const normal = checkAt(); + const binary = checkAtBinary(); + if (normal !== binary) fail(`[mutation] binary=${binary} normal=${normal} disagree at now=${engineNow} (${genKind} seed=${seed})`); + if (normal !== expected()) fail(`[mutation] engine=${normal} mirror=${expected()} disagree at now=${engineNow} (${genKind} seed=${seed})`); + } + + // SNAPSHOT-PRESERVES-TTL on a dedicated never-mutated engine. + const { arbiter: sArb, u: su, o: so } = buildSnapshotEngine(spec, seed); + const expAt = BASE_NOW + TTL + 1; + if (sArb.check(su, 'premium_access', so, { now: BASE_NOW }).possibility !== 1) { + fail(`[snapshot] fresh snapshot engine did not grant (${genKind} seed=${seed})`); + } + if (sArb.check(su, 'premium_access', so, { now: expAt }).possibility !== 0) { + fail(`[snapshot] original engine did not expire at TTL+1 (${genKind} seed=${seed})`); + } + sArb.enableCondensedSnapshot(); + const buf = sArb.toSnapshotBinary(); + const restored = Arbiter.fromSnapshotBinary(buf); + if (restored.valueManager.getTTL('balance') !== TTL) { + fail(`[snapshot] TTL config lost across restore (${genKind} seed=${seed})`); + } + const restoredTs = restored.relationManager.getDirectRelation( + restored.nodeIdByKey.get(su), 'balance', restored.nodeIdByKey.get(so) + ).changed_last_at; + if (restored.check(su, 'premium_access', so, { now: restoredTs }).possibility !== 1) { + fail(`[snapshot] restored value not fresh at its own write clock (${genKind} seed=${seed})`); + } + if (restored.check(su, 'premium_access', so, { now: restoredTs + TTL + RESTORE_BUFFER }).possibility !== 0) { + fail(`[snapshot] restored value did not expire past TTL (${genKind} seed=${seed})`); + } + return { ok: true }; + } + + const report = await rigor.campaign( + [ + rigor.fn('check', check, rigor.args( + rigor.gen.object({ + genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)), + seed: rigor.gen.int(1, 6), + mutationCount: rigor.gen.int(2, 4) + }) + )) + ], + rigor.crucible([ + rigor.invariant('freshness-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[freshness]')), + rigor.invariant('mutation-with-time', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')), + rigor.invariant('snapshot-preserves-ttl', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[snapshot]')) + ]) + ).run({ effort: 250, seed: 'complex-graph-ttl-crucible', artifacts: { dir: '', persist: 'never' } }); + + for (const name of ['freshness-parity', 'mutation-with-time', 'snapshot-preserves-ttl']) { + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name); + assert.ok(inv, `invariant ${name} missing`); + assert.equal(inv.passed, true, `TTL ${name} violated in ${inv.failureCount} cases`); + } + }); +}); diff --git a/tests/rigor/complex-graph-values-crucible.test.js b/tests/rigor/complex-graph-values-crucible.test.js new file mode 100644 index 0000000..9ad61f1 --- /dev/null +++ b/tests/rigor/complex-graph-values-crucible.test.js @@ -0,0 +1,164 @@ +/** + * rigor/complex-graph-values-crucible.test.js — value-carrying relations + * and the relational-comparator path over a community graph, with an + * injected clock. + * + * The community graph supplies the node universe; the test writes + * value-carrying balance/price edges (pinned changed_last_at) on top and + * evaluates a relational_comparator policy. The mirror computes the + * comparator result from the raw values under the same freshness rule as + * the engine (fresh iff age <= TTL). + * + * COMPARATOR-PARITY — the comparator answer equals the plain value + * comparison at the pinned `now`, across a value + * matrix that includes denying combinations. + * VALUE-MUTATION — rewriting a value flips the decision immediately + * at the pinned `now`, and binary mode agrees. + * TTL-EXPIRY — once both operands age past TTL, the comparator + * denies; the mirror agrees. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { makeCommunityGraph } from './complex-graphs.js'; + +const TTL = 60_000; +const BASE_NOW = 1_000_000_000_000; +const VALUE_SET = [5, 20, 40, 60, 100, 130]; + +function fail(message) { + throw new Error(message); +} + +describe('Complex-graph value/comparator crucibles (rigor)', () => { + it('COMPARATOR-PARITY / VALUE-MUTATION / TTL-EXPIRY hold on the community graph', async () => { + async function check(args) { + const { seed, mutations } = args; + const g = makeCommunityGraph(seed); + const arbiter = g.arbiter; + const u = g.users[0]; + + arbiter.setRelationConfig('balance', { type: 'direct' }); + arbiter.setRelationConfig('price', { type: 'direct' }); + arbiter.setRelationConfig('premium_access', { + type: 'relational_comparator', + comparator: '>', + left: { rule: { type: 'direct', relation: 'balance' }, extractValue: true }, + right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'price' }, extractValue: true } + }); + arbiter.valueManager.setTTL('balance', TTL); + arbiter.valueManager.setTTL('price', TTL); + + const keys = g.resources.slice(0, 3); + let engineNow = BASE_NOW; + const values = new Map(); + for (const k of keys) values.set(k, { balance: { v: 0, ts: -Infinity }, price: { v: 0, ts: -Infinity } }); + + const write = (k, kind, v) => { + const src = kind === 'balance' ? u : k; + arbiter.addRelation(src, kind, k, { value: v, possibility: 1.0, changed_last_at: engineNow }); + const target = values.get(k)[kind]; + // The engine keeps the old timestamp when a rewrite does not change + // the value; the mirror mirrors that or it un-expires old values. + if (target.v !== v) { target.v = v; target.ts = engineNow; } + }; + const fresh = ts => engineNow - ts <= TTL; + const expected = k => { + const v = values.get(k); + return fresh(v.balance.ts) && fresh(v.price.ts) && v.balance.v > v.price.v ? 1 : 0; + }; + const checkAt = k => { + const normal = arbiter.check(u, 'premium_access', k, { now: engineNow }).possibility; + const binary = arbiter.check(u, 'premium_access', k, { now: engineNow, binary: true }).possibility; + return { normal, binary }; + }; + + // COMPARATOR-PARITY: value matrix at pinned clocks, including + // denying combinations. + const matrix = [ + [100, 50], // grant + [50, 100], // deny + [100, 100], // deny (not strictly greater) + [0, 10], // deny + [200, 5], // grant + [5, 5] // deny + ]; + for (let i = 0; i < matrix.length; i++) { + const k = keys[i % keys.length]; + engineNow = BASE_NOW + i * 1000; + write(k, 'balance', matrix[i][0]); + write(k, 'price', matrix[i][1]); + const { normal, binary } = checkAt(k); + if (normal !== expected(k)) { + fail(`[parity] engine=${normal} mirror=${expected(k)} for balance=${matrix[i][0]} price=${matrix[i][1]} (seed=${seed})`); + } + if (binary !== normal) fail(`[parity] binary=${binary} normal=${normal} disagree (seed=${seed})`); + } + + // VALUE-MUTATION: fresh grant, then flip by rewriting one operand. + const k0 = keys[0]; + engineNow = BASE_NOW + 1_000_000; + write(k0, 'balance', 100); + write(k0, 'price', 50); + if (checkAt(k0).normal !== 1) fail(`[mutation] fresh grant missing (seed=${seed})`); + engineNow += 1000; + write(k0, 'balance', 40); + const flipped = checkAt(k0); + if (flipped.normal !== 0 || flipped.binary !== 0) { + fail(`[mutation] value rewrite did not flip immediately (normal=${flipped.normal} binary=${flipped.binary} seed=${seed})`); + } + engineNow += 1000; + write(k0, 'price', 10); + const reGranted = checkAt(k0); + if (reGranted.normal !== 1 || reGranted.binary !== 1) { + fail(`[mutation] re-grant did not apply immediately (normal=${reGranted.normal} binary=${reGranted.binary} seed=${seed})`); + } + + // Random rewrites with binary + mirror agreement after every change. + for (let m = 0; m < mutations; m++) { + engineNow += 1000 * (1 + m); + const k = keys[m % keys.length]; + write(k, m % 2 === 0 ? 'balance' : 'price', VALUE_SET[(seed + m * 7) % VALUE_SET.length]); + const { normal, binary } = checkAt(k); + if (normal !== expected(k)) fail(`[mutation] engine=${normal} mirror=${expected(k)} (seed=${seed} m=${m})`); + if (binary !== normal) fail(`[mutation] binary=${binary} normal=${normal} disagree (seed=${seed} m=${m})`); + } + + // TTL-EXPIRY-ON-COMPARATOR: both operands past TTL -> deny, mirror agrees. + const kExp = keys[keys.length - 1]; + engineNow = BASE_NOW + 2_000_000; + write(kExp, 'balance', 100); + write(kExp, 'price', 50); + if (checkAt(kExp).normal !== 1) fail(`[expiry] pre-expiry grant missing (seed=${seed})`); + engineNow += TTL + 1; + const expired = checkAt(kExp); + if (expired.normal !== 0 || expired.binary !== 0) { + fail(`[expiry] comparator denied expected after TTL (normal=${expired.normal} binary=${expired.binary} seed=${seed})`); + } + if (expired.normal !== expected(kExp)) fail(`[expiry] mirror mismatch at expiry (seed=${seed})`); + return { ok: true }; + } + + const report = await rigor.campaign( + [ + rigor.fn('check', check, rigor.args( + rigor.gen.object({ + seed: rigor.gen.int(1, 6), + mutations: rigor.gen.int(2, 5) + }) + )) + ], + rigor.crucible([ + rigor.invariant('comparator-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[parity]')), + rigor.invariant('value-mutation', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')), + rigor.invariant('ttl-expiry-on-comparator', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[expiry]')) + ]) + ).run({ effort: 200, seed: 'complex-graph-values-crucible', artifacts: { dir: '', persist: 'never' } }); + + for (const name of ['comparator-parity', 'value-mutation', 'ttl-expiry-on-comparator']) { + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name); + assert.ok(inv, `invariant ${name} missing`); + assert.equal(inv.passed, true, `values ${name} violated in ${inv.failureCount} cases`); + } + }); +}); diff --git a/tests/rigor/tuple-to-userset-rule.test.js b/tests/rigor/tuple-to-userset-rule.test.js index ec56e61..5c4f716 100644 --- a/tests/rigor/tuple-to-userset-rule.test.js +++ b/tests/rigor/tuple-to-userset-rule.test.js @@ -92,7 +92,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => { rigor.crucible([ rigor.invariant('no-tuples', ({ error, errorMessage }) => !error && !errorMessage) ]) - ).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }}); + ).run({ effort: 500, seed: 'ttu-no-tuples', artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-tuples'); @@ -141,7 +141,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => { rigor.crucible([ rigor.invariant('min-fusion', ({ error, errorMessage }) => !error && !errorMessage) ]) - ).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }}); + ).run({ effort: 800, seed: 'ttu-min-fusion', artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'min-fusion');