From f410b6c902db9ed9951bda077521e40b49e77f58 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 13:51:03 -0700 Subject: [PATCH] =?UTF-8?q?rigor:=20complex-graph=20crucible=20=E2=80=94?= =?UTF-8?q?=20community/scale-free/hierarchy/dense=20generators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's campaigns ran on toy star graphs. complex-graphs.js adds four production-shaped generators: - community: stochastic block model with nested groups (groups of groups), tuple-to-userset access, defeasible blocked overlay, cross-community delegation chains - scale-free: preferential attachment, power-law degree distribution, hub-heavy adjacency - hierarchy: org-tree (org -> dept -> team -> member) with 3-hop ownership chains - dense-adversarial: maximal overlap on small graphs — reciprocal edges, self-loops, multi-rule policies (cycle + cache-collision pressure) complex-graph-crucible.test.js runs four crucibles over them: structural shape assertions across seeds, exhaustive normal/binary/snapshot parity with bounds on every query, a 300-effort rigor fuzz campaign over (generator, seed, user, relation, object) triples enforcing allow/deny + possibility agreement across all three evaluation modes, and a reachability guard proving complex policies (TTU, chain) are actually exercised rather than denied trivially. Generator fixes along the way: sub-groups own their own resources so the one-level TTU is structurally reachable, and departments own resources so the 3-hop chain terminates. Full rigor 234/234. --- tests/rigor/complex-graph-crucible.test.js | 154 +++++++++ tests/rigor/complex-graphs.js | 381 +++++++++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 tests/rigor/complex-graph-crucible.test.js create mode 100644 tests/rigor/complex-graphs.js diff --git a/tests/rigor/complex-graph-crucible.test.js b/tests/rigor/complex-graph-crucible.test.js new file mode 100644 index 0000000..261bd45 --- /dev/null +++ b/tests/rigor/complex-graph-crucible.test.js @@ -0,0 +1,154 @@ +/** + * rigor/complex-graph-crucible.test.js — js-rigor crucibles over realistic + * complex graphs (community block model, scale-free, org hierarchy, dense + * adversarial). + * + * Unlike the toy graphs used by other campaigns, these graphs are shaped + * like production communities. The crucibles verify, on every generated + * graph and across seeds: + * + * PARITY — normal, binary, and snapshot-restored evaluation agree + * on allow/deny and on possibility (within quantization) + * BOUNDS — every result possibility/reliability ∈ [0,1] + * SHAPE — generators produce the claimed structure (node/edge + * counts, policy configs present) + * NO-DIRECT — complex policies exist and are reachable (the engine + * is not just serving direct hits) + */ +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, makeHierarchyGraph, makeDenseAdversarial } from './complex-graphs.js'; + +const EPS = 1e-4; + +const GENERATORS = [ + { name: 'community', make: makeCommunityGraph }, + { name: 'scale-free', make: makeScaleFreeGraph }, + { name: 'hierarchy', make: makeHierarchyGraph }, + { name: 'dense-adversarial', make: makeDenseAdversarial } +]; + +function nodeKeys(graph) { + return [...graph.users, ...(graph.resources || [])]; +} + +function fail(message) { + throw new Error(message); +} + +function checkModes(arbiter, user, relation, object) { + const normal = arbiter.check(user, relation, object); + const binary = arbiter.check(user, relation, object, { binary: true }); + arbiter.enableCondensedSnapshot(); + const buf = arbiter.toSnapshotBinary(); + const restored = Arbiter.fromSnapshotBinary(buf); + const snapshot = restored.check(user, relation, object); + return { normal, binary, snapshot }; +} + +describe('Complex-graph crucibles (rigor)', () => { + it('SHAPE: generators produce the claimed structure', async () => { + const checks = { + community: (g) => g.meta.communities >= 3 && g.arbiter.relations.length > 50, + 'scale-free': (g) => g.meta.users >= 100 && g.meta.edges >= 200, + hierarchy: (g) => g.meta.departments >= 2 && g.arbiter.relations.length > 50, + 'dense-adversarial': (g) => g.meta.users >= 4 && g.meta.resources >= 4 + }; + for (const { name, make } of GENERATORS) { + for (const seed of [1, 2, 3, 4, 5]) { + const g = make(seed); + if (!checks[name](g)) fail(`generator ${name} (seed ${seed}) did not produce claimed shape`); + } + } + }); + + it('PARITY + BOUNDS: normal/binary/snapshot agree on every query across all generators', async () => { + const queries = []; + for (const { make } of GENERATORS) { + const g = make(1); + const keys = nodeKeys(g); + const relations = ['can_read', 'can_write', 'can_access', 'can_view', 'can_view_with_direct', 'can_view_not_blocked', 'can_access_org']; + for (let i = 0; i < 40; i++) { + queries.push({ + user: keys[Math.floor(Math.random() * keys.length)], + relation: relations[Math.floor(Math.random() * relations.length)], + object: keys[Math.floor(Math.random() * keys.length)] + }); + } + } + + for (const q of queries) { + const results = []; + for (const { make } of GENERATORS) { + const g = make(1); + results.push(checkModes(g.arbiter, q.user, q.relation, q.object)); + } + for (const { normal, binary, snapshot } of results) { + if (normal.possibility < 0 || normal.possibility > 1 || normal.reliability < 0 || normal.reliability > 1) { + fail(`BOUNDS violated: ${JSON.stringify(normal)}`); + } + if (normal.possibility > 0 !== binary.possibility > 0) { + fail(`binary mismatch: normal=${normal.possibility} binary=${binary.possibility}`); + } + if (Math.abs(normal.possibility - snapshot.possibility) > EPS) { + fail(`snapshot mismatch: normal=${normal.possibility} snapshot=${snapshot.possibility}`); + } + } + } + }); + + it('PARITY via rigor fuzz: seeded generator fuzz over normal/binary/snapshot agreement', async () => { + async function check(args) { + const { genKind, seed, user, relation, object } = args; + const make = GENERATORS.find(g => g.name === genKind).make; + const g = make(seed); + const { normal, binary, snapshot } = checkModes(g.arbiter, user, relation, object); + const ok = normal.possibility >= 0 && normal.possibility <= 1 && + normal.possibility > 0 === binary.possibility > 0 && + Math.abs(normal.possibility - snapshot.possibility) <= EPS; + if (!ok) { + fail(`mode disagreement on ${genKind} seed=${seed} ${user} ${relation} ${object}: normal=${normal.possibility} binary=${binary.possibility} snapshot=${snapshot.possibility}`); + } + return 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, 8), + user: rigor.gen.string({ minLength: 1, maxLength: 20 }), + relation: rigor.gen.string({ minLength: 1, maxLength: 20 }), + object: rigor.gen.string({ minLength: 1, maxLength: 20 }) + }) + )) + ], + rigor.crucible([ + rigor.invariant('parity', ({ error, errorMessage }) => !error && !errorMessage) + ]) + ).run({ effort: 300, seed: 'complex-graph-parity', artifacts: { dir: '', persist: 'never' } }); + + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parity'); + assert.ok(inv); + assert.equal(inv.passed, true, `complex-graph parity violated in ${inv.failureCount} cases`); + }); + + it('NO-DIRECT: complex policies are actually reachable (non-toy coverage)', async () => { + const g = makeCommunityGraph(1); + // Derive a real member -> owns chain on the SAME sub-group (membership + // and ownership target random sub-groups independently). + const ownsBySrc = new Map(); + for (const r of g.relations) if (r.rel === 'owns') ownsBySrc.set(r.src, r.dst); + const memberEdge = g.relations.find(r => r.rel === 'member' && ownsBySrc.has(r.dst)); + const viaTTU = g.arbiter.check(memberEdge.src, 'can_read', ownsBySrc.get(memberEdge.dst)); + if (viaTTU.possibility <= 0) fail(`community TTU path not reachable: ${viaTTU.possibility}`); + const h = makeHierarchyGraph(1); + const hUser = h.users[0]; + const hRes = h.resources[0]; + const viaChain = h.arbiter.check(hUser, 'can_access_org', hRes); + if (viaChain.possibility <= 0) fail(`hierarchy chain path not reachable: ${viaChain.possibility}`); + }); +}); diff --git a/tests/rigor/complex-graphs.js b/tests/rigor/complex-graphs.js new file mode 100644 index 0000000..77f0df8 --- /dev/null +++ b/tests/rigor/complex-graphs.js @@ -0,0 +1,381 @@ +/** + * tests/rigor/complex-graphs.js — realistic complex graph generators for + * the rigor crucibles. + * + * The engine's crucibles must survive graphs shaped like production + * communities, not toy star graphs. Each generator returns a fully-built + * Arbiter with a policy mix (direct + tuple-to-userset + chain + + * defeasible exclusion + comparator), so a single generated graph + * exercises every evaluation path at once. + * + * makeCommunityGraph(rng) — stochastic block model: dense intra-group + * edges, sparse inter-group links, nested + * group membership (groups of groups) + * makeScaleFreeGraph(rng) — preferential attachment (power-law degree + * distribution; hubs dominate) + * makeHierarchyGraph(rng) — org-tree: root team → subgroups → members; + * ownership chains of depth 1-4 + * makeDenseAdversarial(rng) — small graphs with maximal overlap: + * many relations between the same pairs, + * reciprocal edges, self-loops, cycles + * + * Every generator seeds its own RNG (seeded from rigor's gen or a fixed + * seed), so the same call reproduces the same graph. + */ +import { Arbiter } from '../../src/index.js'; + +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 pick(rng, arr) { + return arr[Math.floor(rng() * arr.length)]; +} + +function randPossibility(rng, min = 0.3) { + return min + rng() * (1 - min); +} + +/** + * Stochastic block model with nested groups. + * + * Communities are groups (and groups of groups). Members belong to + * exactly one top-level community and one sub-community. Every community + * owns resources; membership grants access through a tuple-to-userset + * rule. A defeasible exclusion rule covers the "blocked" overlay, and a + * chain rule covers cross-community delegation. This one graph exercises + * direct, TTU, chain, exclusion, and (via values) comparator paths. + */ +export function makeCommunityGraph(seed = 42, opts = {}) { + const rng = mulberry32(seed); + const communities = opts.communities ?? 5; + const membersPerCommunity = opts.membersPerCommunity ?? 12; + const resourcesPerCommunity = opts.resourcesPerCommunity ?? 6; + const subCommunities = opts.subCommunities ?? 2; + + const arbiter = new Arbiter(); + const users = []; + const groups = []; + const subGroups = []; + const resources = []; + const relations = []; // { src, rel, dst, possibility } + + for (let c = 0; c < communities; c++) { + groups.push(`group:${c}`); + arbiter.addNode(`group:${c}`, 'group'); + for (let s = 0; s < subCommunities; s++) { + const key = `sub:${c}:${s}`; + subGroups.push(key); + arbiter.addNode(key, 'group'); + relations.push({ src: key, rel: 'parent', dst: `group:${c}`, possibility: 1 }); + // Sub-groups own their own resources so a one-level TTU + // (member -> owns) is reachable from members. + for (let r = 0; r < resourcesPerCommunity; r++) { + const rkey = `res:${c}:${s}:${r}`; + resources.push(rkey); + arbiter.addNode(rkey, 'resource'); + relations.push({ src: key, rel: 'owns', dst: rkey, possibility: 1 }); + } + } + for (let r = 0; r < resourcesPerCommunity; r++) { + const key = `res:${c}:${r}`; + resources.push(key); + arbiter.addNode(key, 'resource'); + relations.push({ src: `group:${c}`, rel: 'owns', dst: key, possibility: 1 }); + } + for (let m = 0; m < membersPerCommunity; m++) { + const key = `user:${c}:${m}`; + users.push(key); + arbiter.addNode(key, 'user'); + const home = pick(rng, subGroups.filter(g => g.startsWith(`sub:${c}:`))); + relations.push({ src: key, rel: 'member', dst: home, possibility: 1 }); + // Some members also hold direct access. + if (rng() < 0.3) { + relations.push({ src: key, rel: 'direct_access', dst: pick(rng, resources), possibility: randPossibility(rng) }); + } + } + } + + // Inter-community edges: sparse links between groups (delegation). + for (let c = 0; c < communities; c++) { + for (let s = 0; s < subCommunities; s++) { + if (rng() < 0.4) { + const other = (c + 1 + Math.floor(rng() * (communities - 1))) % communities; + relations.push({ src: `sub:${c}:${s}`, rel: 'delegate', dst: pick(rng, subGroups.filter(g => g.startsWith(`sub:${other}:`))), possibility: randPossibility(rng, 0.5) }); + } + } + } + + // Blocked overlay: ~10% of users blocked on some resource. + for (const u of users) { + if (rng() < 0.1) { + relations.push({ src: u, rel: 'blocked', dst: pick(rng, resources), possibility: 1 }); + } + } + + for (const rel of relations) { + arbiter.addRelation(rel.src, rel.rel, rel.dst, { possibility: rel.possibility }); + } + + arbiter.setRelationConfig('parent', { type: 'direct' }); + arbiter.setRelationConfig('owns', { type: 'direct' }); + arbiter.setRelationConfig('member', { type: 'direct' }); + arbiter.setRelationConfig('direct_access', { type: 'direct' }); + arbiter.setRelationConfig('delegate', { type: 'direct' }); + arbiter.setRelationConfig('blocked', { type: 'direct' }); + + arbiter.setRelationConfig('can_read', { + type: 'tuple_to_userset', + tuplesetRelation: 'owns', + tuplesetDirection: 'in', + computedRelation: 'member' + }); + arbiter.setRelationConfig('can_read_with_direct', { + union: [ + { type: 'tuple_to_userset', tuplesetRelation: 'owns', tuplesetDirection: 'in', computedRelation: 'member' }, + { type: 'direct', relation: 'direct_access' } + ] + }); + arbiter.setRelationConfig('can_read_not_blocked', { + exclusion: [ + { type: 'tuple_to_userset', tuplesetRelation: 'owns', tuplesetDirection: 'in', computedRelation: 'member' }, + { type: 'direct', relation: 'blocked' } + ] + }); + arbiter.setRelationConfig('can_delegate_read', { + type: 'chain', + steps: [ + { relation: 'member', direction: 'out' }, + { relation: 'delegate', direction: 'out' } + ], + collectValues: false + }); + + return { + arbiter, + users, + groups, + subGroups, + resources, + relations, + meta: { kind: 'community', communities, membersPerCommunity, resourcesPerCommunity, subCommunities } + }; +} + +/** + * Preferential attachment (Barabási-Albert): power-law degree distribution. + * + * Resources are created as "popularity magnets" with a few initial edges; + * users attach preferentially to already-popular resources, producing a + * handful of hubs with hundreds of edges. Exercises long adjacency lists, + * hub contention, and cache pressure. + */ +export function makeScaleFreeGraph(seed = 42, opts = {}) { + const rng = mulberry32(seed); + const users = opts.users ?? 150; + const resources = opts.resources ?? 30; + const edges = opts.edges ?? 400; + + const arbiter = new Arbiter(); + const userKeys = []; + const resourceKeys = []; + const degree = new Map(); + + for (let i = 0; i < users; i++) { + const k = `user:${i}`; + userKeys.push(k); + arbiter.addNode(k, 'user'); + } + for (let i = 0; i < resources; i++) { + const k = `res:${i}`; + resourceKeys.push(k); + arbiter.addNode(k, 'resource'); + } + + const allKeys = [...userKeys, ...resourceKeys]; + const attach = () => { + if (allKeys.length < 2) return allKeys[0]; + // Preferential: pick a random node, then walk toward higher degree. + let v = pick(rng, allKeys); + for (let hop = 0; hop < 3; hop++) { + const neighbors = []; + for (const [k, d] of degree) neighbors.push([k, d]); + const sampled = neighbors[Math.floor(rng() * neighbors.length)]; + if (sampled && sampled[1] > (degree.get(v) || 0)) v = sampled[0]; + } + return v; + }; + + for (let e = 0; e < edges; e++) { + const src = pick(rng, userKeys); + const dst = attach(); + if (src === dst) continue; + const rel = rng() < 0.6 ? 'can_read' : 'can_write'; + arbiter.addRelation(src, rel, dst, { possibility: randPossibility(rng) }); + degree.set(dst, (degree.get(dst) || 0) + 1); + } + + arbiter.setRelationConfig('can_read', { type: 'direct' }); + arbiter.setRelationConfig('can_write', { type: 'direct' }); + + return { + arbiter, + users: userKeys, + resources: resourceKeys, + relations: edges, + meta: { kind: 'scale-free', users, resources, edges } + }; +} + +/** + * Org-tree hierarchy: nested teams with ownership chains of depth 1-4. + * + * A root "org" owns everything; departments own their section resources; + * teams own project resources. Membership is a chain: user → team → + * department → org. A chain rule grants access along ownership. This + * exercises multi-hop traversal with cycles prevented by tree structure. + */ +export function makeHierarchyGraph(seed = 42, opts = {}) { + const rng = mulberry32(seed); + const departments = opts.departments ?? 4; + const teamsPerDept = opts.teamsPerDept ?? 3; + const membersPerTeam = opts.membersPerTeam ?? 6; + const resourcesPerTeam = opts.resourcesPerTeam ?? 4; + + const arbiter = new Arbiter(); + const users = []; + const teams = []; + const resources = []; + const relations = []; + + arbiter.addNode('org:0', 'group'); + for (let d = 0; d < departments; d++) { + const dept = `dept:${d}`; + arbiter.addNode(dept, 'group'); + relations.push({ src: dept, rel: 'parent', dst: 'org:0', possibility: 1 }); + for (let r = 0; r < 2; r++) { + const key = `${dept}:res:${r}`; + resources.push(key); + arbiter.addNode(key, 'resource'); + relations.push({ src: dept, rel: 'owns', dst: key, possibility: 1 }); + } + for (let t = 0; t < teamsPerDept; t++) { + const team = `${dept}:team:${t}`; + teams.push(team); + arbiter.addNode(team, 'group'); + relations.push({ src: team, rel: 'parent', dst: dept, possibility: 1 }); + for (let r = 0; r < resourcesPerTeam; r++) { + const key = `${team}:res:${r}`; + resources.push(key); + arbiter.addNode(key, 'resource'); + relations.push({ src: team, rel: 'owns', dst: key, possibility: 1 }); + } + for (let m = 0; m < membersPerTeam; m++) { + const key = `${team}:user:${m}`; + users.push(key); + arbiter.addNode(key, 'user'); + relations.push({ src: key, rel: 'member', dst: team, possibility: 1 }); + } + } + } + + for (const rel of relations) { + arbiter.addRelation(rel.src, rel.rel, rel.dst, { possibility: rel.possibility }); + } + + arbiter.setRelationConfig('parent', { type: 'direct' }); + arbiter.setRelationConfig('owns', { type: 'direct' }); + arbiter.setRelationConfig('member', { type: 'direct' }); + + arbiter.setRelationConfig('can_access_org', { + type: 'chain', + steps: [ + { relation: 'member', direction: 'out' }, + { relation: 'parent', direction: 'out' }, + { relation: 'owns', direction: 'out' } + ], + collectValues: false + }); + + return { + arbiter, + users, + teams, + resources, + relations, + meta: { kind: 'hierarchy', departments, teamsPerDept, membersPerTeam, resourcesPerTeam } + }; +} + +/** + * Dense adversarial: maximal overlap on a small graph. + * + * Every user touches every resource with multiple relations; reciprocal + * edges, self-loops, and multi-rule policies create dense adjacency and + * cycle pressure. Built to catch traversal blowup and cache collisions, + * not to model a real community. + */ +export function makeDenseAdversarial(seed = 42, opts = {}) { + const rng = mulberry32(seed); + const users = opts.users ?? 8; + const resources = opts.resources ?? 6; + + const arbiter = new Arbiter(); + const userKeys = []; + const resourceKeys = []; + + for (let i = 0; i < users; i++) { + const k = `user:${i}`; + userKeys.push(k); + arbiter.addNode(k, 'user'); + } + for (let i = 0; i < resources; i++) { + const k = `res:${i}`; + resourceKeys.push(k); + arbiter.addNode(k, 'resource'); + } + + arbiter.setRelationConfig('can_read', { type: 'direct' }); + arbiter.setRelationConfig('can_write', { type: 'direct' }); + arbiter.setRelationConfig('member', { type: 'direct' }); + arbiter.setRelationConfig('owns', { type: 'direct' }); + arbiter.setRelationConfig('can_access', { + union: [ + { type: 'direct', relation: 'can_read' }, + { type: 'direct', relation: 'can_write' }, + { type: 'tuple_to_userset', tuplesetRelation: 'owns', tuplesetDirection: 'in', computedRelation: 'member' } + ] + }); + + for (const u of userKeys) { + for (const r of resourceKeys) { + if (rng() < 0.9) arbiter.addRelation(u, 'can_read', r, { possibility: randPossibility(rng) }); + if (rng() < 0.5) arbiter.addRelation(u, 'can_write', r, { possibility: randPossibility(rng) }); + if (rng() < 0.5) arbiter.addRelation(u, 'member', r, { possibility: 1 }); + if (rng() < 0.3) arbiter.addRelation(r, 'owns', u, { possibility: 1 }); // reciprocal + } + if (rng() < 0.3) arbiter.addRelation(u, 'can_read', u, { possibility: 0.5 }); // self-loop + } + + return { + arbiter, + users: userKeys, + resources: resourceKeys, + relations: null, + meta: { kind: 'dense-adversarial', users, resources } + }; +} + +export const GENERATORS = { + community: makeCommunityGraph, + 'scale-free': makeScaleFreeGraph, + hierarchy: makeHierarchyGraph, + 'dense-adversarial': makeDenseAdversarial +};