ed34df4474
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.
170 lines
7.6 KiB
JavaScript
170 lines
7.6 KiB
JavaScript
/**
|
|
* 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.
|
|
// Use values NOT in VALUE_SET (137 > 30): the engine keeps the OLD
|
|
// timestamp when a rewrite does not change the value, so a value equal
|
|
// to whatever the mutation loop last wrote would NOT refresh freshness
|
|
// and the pre-expiry grant would (correctly) not materialize. Writing
|
|
// guaranteed-different values forces a timestamp refresh.
|
|
const kExp = keys[keys.length - 1];
|
|
engineNow = BASE_NOW + 2_000_000;
|
|
write(kExp, 'balance', 137);
|
|
write(kExp, 'price', 30);
|
|
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', ({ actual }) => actual !== undefined),
|
|
rigor.invariant('value-mutation', ({ actual }) => actual !== undefined),
|
|
rigor.invariant('ttl-expiry-on-comparator', ({ actual }) => actual !== undefined)
|
|
])
|
|
).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`);
|
|
}
|
|
});
|
|
});
|