Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3444a8b6aa | |||
| 440230b2c5 | |||
| f530532e48 | |||
| f9d4fbe2f0 | |||
| 342c29f38b | |||
| 8141930764 | |||
| 1a2a6fc22e | |||
| f0aefe4ba6 | |||
| f410b6c902 | |||
| e98137a04f | |||
| 5566d6c0ab | |||
| 223cfb97c3 |
@@ -35,10 +35,75 @@ jobs:
|
||||
- name: Rigor campaigns
|
||||
run: npm run test:rigor
|
||||
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Auth for Gitea npm registry
|
||||
run: |
|
||||
echo "@rigor:registry=https://hub.kl1.tenere.ai/api/packages/Rigor/npm/" > .npmrc
|
||||
echo "//hub.kl1.tenere.ai/api/packages/Rigor/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc
|
||||
echo "@tenere:registry=https://hub.kl1.tenere.ai/api/packages/Tenere/npm/" >> .npmrc
|
||||
echo "//hub.kl1.tenere.ai/api/packages/Tenere/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Download previous baseline
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
with:
|
||||
name: baseline
|
||||
path: .
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run benchmarks (compare against baseline)
|
||||
run: node --expose-gc scripts/benchmark.js --json > bench-results.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Save baseline (on tag)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: node --expose-gc scripts/benchmark.js --save
|
||||
|
||||
- name: Upload baseline artifact
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: baseline
|
||||
path: .rigor-baseline.json
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload benchmark results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: bench-results
|
||||
path: bench-results.json
|
||||
retention-days: 30
|
||||
|
||||
- name: Benchmark summary
|
||||
if: always()
|
||||
run: |
|
||||
echo '### Benchmark Results' >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f bench-results.json ]; then
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat bench-results.json >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: test
|
||||
needs: [test, benchmark]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generated": "2026-08-02T19:51:19.208Z",
|
||||
"actions": {
|
||||
"check[direct-hit]": {
|
||||
"mostPlausible": 0.25810395981996936,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.16434574601481775,
|
||||
"upper": 0.40535024830520205
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.10394999999999016,
|
||||
"upper": 1.2158777106838974
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.10394999999999016,
|
||||
"upper": 2.040950000000002
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[union-ttu]": {
|
||||
"mostPlausible": 0.3581109933665416,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.07645000000001517,
|
||||
"upper": 0.35811139703768785
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.07645000000001517,
|
||||
"upper": 0.35811139703768785
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.07645000000001517,
|
||||
"upper": 0.35811139703768785
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[denied-miss]": {
|
||||
"mostPlausible": 0.21718442918988012,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.18384889559816292,
|
||||
"upper": 0.2565642603295502
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.13274999999997988,
|
||||
"upper": 0.3601500000000823
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.13274999999997988,
|
||||
"upper": 0.3601500000000823
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[include-meta]": {
|
||||
"mostPlausible": 0.9937660048895368,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.9937660048895368,
|
||||
"upper": 0.9937663566453343
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.9937660048895368,
|
||||
"upper": 0.9937663566453343
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.9937660048895368,
|
||||
"upper": 0.9937663566453343
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[overlay-on-top]": {
|
||||
"mostPlausible": 0.3485105914857449,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.26044999999998425,
|
||||
"upper": 0.3881187132654935
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.26044999999998425,
|
||||
"upper": 0.5286391152207875
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.26044999999998425,
|
||||
"upper": 0.5374790233000701
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[binary-direct]": {
|
||||
"mostPlausible": 0.2660225524868295,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.21220996174732032,
|
||||
"upper": 0.3334807973509898
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.16554999999993186,
|
||||
"upper": 0.5780022226626057
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.16554999999993186,
|
||||
"upper": 0.8219500000000078
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshot[build-binary]": {
|
||||
"mostPlausible": 4.119453383803492,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 3.638457178151043,
|
||||
"upper": 4.664037328289517
|
||||
},
|
||||
"p95": {
|
||||
"lower": 3.1707499999999906,
|
||||
"upper": 6.309350037482752
|
||||
},
|
||||
"p99": {
|
||||
"lower": 3.1707499999999906,
|
||||
"upper": 7.547749999999944
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshot[restore-binary]": {
|
||||
"mostPlausible": 6.906283652168588,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 3.2581500000001067,
|
||||
"upper": 6.906283919208314
|
||||
},
|
||||
"p95": {
|
||||
"lower": 3.2581500000001067,
|
||||
"upper": 6.906283919208314
|
||||
},
|
||||
"p99": {
|
||||
"lower": 3.2581500000001067,
|
||||
"upper": 6.906283919208314
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# @arbiter/core
|
||||
|
||||
> Possibilistic authorization engine: graph indices, relation/reachability queries, rule evaluation over a DSL, and lossless condensed snapshots.
|
||||
|
||||
## Why
|
||||
|
||||
Authorization policies live on a graph: users hold relations to objects, groups, and roles, and rules derive decisions from those relations. `@arbiter/core` answers one question — *may user U perform relation R on object O?* — with a **possibility** ranking, not a boolean. Callers supply evidence with strengths; the engine fuses it through rule operators (`union`, `intersection`, `exclusion`, `defeasible`, chain, multi-hop, relational comparator) and returns the strongest derivable possibility, the reliability of the decision, and the validity provenance of the ranking.
|
||||
|
||||
The engine does not police caller-supplied evidence: you supply validated relation strengths and proofs; the engine derives and fuses. It is a library, not a service — no storage, no transport, no policy source of truth.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @arbiter/core
|
||||
```
|
||||
|
||||
The package is ESM-only and requires Node 22 or newer.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```js
|
||||
import { Arbiter } from '@arbiter/core';
|
||||
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
// Nodes: an id and a type.
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:9', 'doc');
|
||||
|
||||
// Relations: a config says how decisions for that relation are derived.
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
arbiter.addRelation('user:1', 'owner', 'doc:9', { possibility: 0.9 });
|
||||
|
||||
// The core question.
|
||||
const result = arbiter.check('user:1', 'owner', 'doc:9');
|
||||
// { possibility: 0.9, reliability: 1,
|
||||
// validity: { label: 'heuristic', operator: 'identity', regime: 'arbitrary' },
|
||||
// reason: 'direct_match' }
|
||||
```
|
||||
|
||||
## Concepts
|
||||
|
||||
### Possibility, not probability
|
||||
|
||||
Every check returns a `possibility` in `[0, 1]` — a maxitive ranking supplied by the caller through relation strengths. `1` means derivable, `0` means not derivable. Fusions take the maximum under `union`, and enforce thresholds and conflicts under `intersection`, `exclusion`, and `defeasible` operators.
|
||||
|
||||
### Result shape
|
||||
|
||||
Every check result carries the same four fields:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `possibility` | `number` in `[0,1]` | Derived possibility of the decision |
|
||||
| `reliability` | `number` in `[0,1]` | Reliability of the decision; always `0` for denials |
|
||||
| `validity` | `object` | Validity provenance: `label`, `operator`, `regime` (minimal form) |
|
||||
| `reason` | `string` | Outcome class: `direct_match`, `no_relation`, `threshold_not_met`, `missing_node`, `no_config`, `cycle`, ... |
|
||||
|
||||
Denied decisions never leak a source's reliability. `includeMeta: true` adds `meta` with the full provenance (allow/deny blocks, rule traces, thresholds) and the full validity block (`sources`, `conflictMass`, `validifiedPossibility`, `nonMaxitive`).
|
||||
|
||||
### The caller owns evidence and time
|
||||
|
||||
- **Evidence**: relation strengths and validity labels come from the caller. The engine derives and fuses but never judges.
|
||||
- **Time**: TTL-gated evidence uses the caller's clock. Pass `{ now }` (or `partialGraph.now`) to pin the temporal context; a rerun with the same context reproduces the decision.
|
||||
|
||||
**TTL is a value-freshness gate, not an access-expiry mechanism.** `valueManager.setTTL(relation, ms)` controls how long a relation's *value* stays fresh for value-consuming paths (relational comparators, chain/multi-hop value collection): once `age > TTL` the value is treated as absent, which denies the comparator and drops the value from collected values. Possibility-based grants — a direct relation's allow/deny, union disjunction, chain traversal — are **timeless**: an edge grants regardless of its age. If you need access to expire, express it in the policy (e.g. a comparator over a time-carrying value), not via `setTTL`.
|
||||
|
||||
**Clocks and caches.** The decision caches (direct-check, rule-result, chain) are keyed on the meta-less decision form only: `includeMeta` callers and pinned-clock callers always get a fresh evaluation, and value-carrying results are never cached (values are TTL-gated evidence). Unpinned callers share wall-clock cache entries — the correct default for timeless decisions. Pin `{ now }` whenever the answer depends on when you ask; every cache bypasses itself for pinned-clock callers, so a rerun with the same `{ now }` reproduces the decision exactly.
|
||||
|
||||
### Overlays and partial graphs
|
||||
|
||||
`check` accepts a `PartialGraphContext` overlay. Overlay relations take precedence over the base graph, letting you answer "what changes if this evidence appears?" without mutating the graph.
|
||||
|
||||
## API
|
||||
|
||||
The public surface is the `Arbiter` class:
|
||||
|
||||
- **Graph**: `addNode`, `addRelation`, `removeRelation`, `setRelationConfig`, `getNodeData`, `resolveNodeId`, `resolveKey`
|
||||
- **Check**: `check(userKey, relation, objectKey, options)`, `explain` (enriches `meta`), `binary` mode (fast path, marks results `binary: true`)
|
||||
- **Reachability**: `isReachable`, `getReachableNodes`, `getReachingNodes`, `shortestPathLength`, `estimateGraphDistance` (`isReachable` returns `null` when no PLTC index is available — a signal to defer to rule evaluation)
|
||||
- **Snapshots**: `enableCondensedSnapshot`, `toSnapshotBinary`, `Arbiter.fromSnapshotBinary` (lossless: carries relation metadata, TTLs, and validity; malformed buffers fail fast with clean errors)
|
||||
- **Value context**: `valueManager` (TTL-gated evidence), `getSituationTree`, `monteCarloWalk`
|
||||
|
||||
Check `options`:
|
||||
|
||||
| Option | Type | Default | Meaning |
|
||||
|--------|------|---------|---------|
|
||||
| `includeMeta` | `boolean` | `false` | Attach full provenance in `meta` |
|
||||
| `explain` | `boolean` | `false` | Enrich `meta` with the evaluation trace |
|
||||
| `binary` | `boolean` | `false` | Binary fast path; results marked `binary: true` |
|
||||
| `now` | `number` | engine clock | Pinned temporal context for TTL gates |
|
||||
| `partialGraphContext` | `PartialGraphContext` | none | Overlay taking precedence over the base graph |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install # install dependencies
|
||||
npm test # full suite
|
||||
npm run test:rigor # js-rigor campaigns (property-based + fuzzing)
|
||||
npm run benchmark # compare against the committed perf baseline
|
||||
npm run benchmark:save # record a new perf baseline
|
||||
```
|
||||
|
||||
CI runs the full suite, the rigor campaigns, and the benchmark on every push; `v*` tags additionally publish the package to the `@arbiter` registry.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- **Possibility is a maxitive ranking.** Fusions preserve the weakest validity label under arbitrary dependence; conjunctive operators surface conflict mass instead of silently averaging it.
|
||||
- **One evaluation path.** The rule engine has a single, uncompiled evaluator — parity between normal, binary, partial-graph, and snapshot-restored checks is structural, and the rigor campaigns enforce it.
|
||||
- **Snapshots are a trust boundary.** Restoring untrusted bytes must produce a clean, bounded error — never a hang, a crash, or silently corrupted data. The deserializer cross-validates every count field before use.
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Complex-query benchmark for @arbiter/core.
|
||||
*
|
||||
* Realistic prod auth gating is dominated by rule-based queries (chains,
|
||||
* tuple-to-userset, defeasible, relational comparators, OWA unions), not
|
||||
* direct relations. This benchmark measures cold-traffic latency (every
|
||||
* sample a distinct subject/object pair — no cache reuse) for both the
|
||||
* normal path and the binary (early-exit) path, and enforces that the
|
||||
* binary decision agrees with the normal decision on every query.
|
||||
*
|
||||
* node --expose-gc benchmarks/complex-query-bench.js
|
||||
* node --expose-gc benchmarks/complex-query-bench.js --size=25000 --samples=20000
|
||||
*/
|
||||
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { Arbiter } from '../src/core/Arbiter.js';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = new Map();
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const value = argv[i];
|
||||
if (!value.startsWith('--')) continue;
|
||||
const [key, inline] = value.slice(2).split('=');
|
||||
if (inline !== undefined) args.set(key, inline);
|
||||
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) { args.set(key, argv[i + 1]); i++; }
|
||||
else args.set(key, true);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function createRng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (1664525 * state + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function randInt(rng, max) { return Math.floor(rng() * max); }
|
||||
function percentile(sorted, p) { return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * p) - 1))]; }
|
||||
|
||||
function buildUsers(arbiter, count, prefix) {
|
||||
for (let i = 0; i < count; i++) arbiter.addNode(`${prefix}:${i}`, prefix);
|
||||
}
|
||||
|
||||
function makeQueries(size, rng, pickAllow, pickDeny, count = 4000) {
|
||||
const queries = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const allow = i % 2 === 0;
|
||||
const q = allow ? pickAllow() : pickDeny();
|
||||
queries.push({ user: q.user, object: q.object, allow });
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
// ── Scenarios (all complex — no bare direct relations) ────────────────
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'tuple_to_userset (group membership)',
|
||||
build: (arbiter, size, rng) => {
|
||||
const groupCount = Math.max(1, Math.floor(size / 10));
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, groupCount, 'group');
|
||||
buildUsers(arbiter, groupCount, 'resource');
|
||||
for (let i = 0; i < size; i++) arbiter.addRelation(`user:${i}`, 'member', `group:${i % groupCount}`);
|
||||
for (let i = 0; i < groupCount; i++) arbiter.addRelation(`group:${i}`, 'group_access', `resource:${i}`);
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('group_access', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view', { type: 'tuple_to_userset', tuplesetRelation: 'group_access', tuplesetDirection: 'in', computedRelation: 'member' });
|
||||
return makeQueries(size, rng,
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${id % groupCount}` }; },
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${(id + 1) % groupCount}` }; });
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'chain (2-hop)',
|
||||
build: (arbiter, size, rng) => {
|
||||
const planCount = Math.max(1, Math.floor(size / 10));
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, planCount, 'plan');
|
||||
buildUsers(arbiter, planCount, 'resource');
|
||||
for (let i = 0; i < size; i++) arbiter.addRelation(`user:${i}`, 'has_plan', `plan:${i % planCount}`);
|
||||
for (let i = 0; i < planCount; i++) arbiter.addRelation(`plan:${i}`, 'plan_grants', `resource:${i}`);
|
||||
arbiter.setRelationConfig('has_plan', { type: 'direct' });
|
||||
arbiter.setRelationConfig('plan_grants', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_use', { type: 'chain', steps: [
|
||||
{ relation: 'has_plan', direction: 'out' }, { relation: 'plan_grants', direction: 'out' }], collectValues: false });
|
||||
return makeQueries(size, rng,
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${id % planCount}` }; },
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${(id + 1) % planCount}` }; });
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'defeasible exclusion (viewer minus blocked)',
|
||||
build: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) arbiter.addRelation(`user:${i}`, 'viewer', `resource:${i}`);
|
||||
for (let i = 0; i < Math.floor(size / 4); i++) arbiter.addRelation(`user:${i}`, 'blocked', `resource:${i}`);
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('blocked', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view_blocked', { exclusion: [
|
||||
{ type: 'direct', relation: 'viewer' }, { type: 'direct', relation: 'blocked' }] });
|
||||
return makeQueries(size, rng,
|
||||
() => { const id = Math.floor(size / 4) + randInt(rng, size - Math.floor(size / 4)); return { user: `user:${id}`, object: `resource:${id}` }; },
|
||||
() => { const id = randInt(rng, Math.floor(size / 4)); return { user: `user:${id}`, object: `resource:${id}` }; });
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'relational comparator (ABAC age)',
|
||||
build: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'age', `user:${i}`, 1.0, { value: 18 + (i % 30) });
|
||||
arbiter.addRelation(`resource:${i}`, 'min_age', `resource:${i}`, 1.0, { value: 18 + (i % 15) });
|
||||
}
|
||||
arbiter.setRelationConfig('age', { type: 'direct' });
|
||||
arbiter.setRelationConfig('min_age', { type: 'direct' });
|
||||
arbiter.setRelationConfig('age_ok', { type: 'relational_comparator', comparator: '>=', fallbackBehavior: 'deny',
|
||||
left: { rule: { type: 'direct', relation: 'age' }, extractValue: true, valueRelation: 'age' },
|
||||
right: { rule: { type: 'direct', relation: 'min_age' }, extractValue: true, valueRelation: 'min_age', evaluateFrom: 'object' } });
|
||||
return makeQueries(size, rng,
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${id % size}` }; },
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${(id + Math.floor(size / 2)) % size}` }; });
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OWA union (3 weighted sources)',
|
||||
build: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'direct_allow', `resource:${i}`, { possibility: i % 2 === 0 ? 0.9 : 0.2 });
|
||||
arbiter.addRelation(`user:${i}`, 'group_allow', `resource:${i}`, { possibility: i % 3 === 0 ? 0.8 : 0.1 });
|
||||
arbiter.addRelation(`user:${i}`, 'manual_allow', `resource:${i}`, { possibility: i % 5 === 0 ? 0.7 : 0.05 });
|
||||
}
|
||||
arbiter.setRelationConfig('direct_allow', { type: 'direct' });
|
||||
arbiter.setRelationConfig('group_allow', { type: 'direct' });
|
||||
arbiter.setRelationConfig('manual_allow', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view_owa', { union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'direct_allow' },
|
||||
{ type: 'direct', relation: 'group_allow' },
|
||||
{ type: 'direct', relation: 'manual_allow' }
|
||||
],
|
||||
aggregator: 'owa', owaWeights: [0.7, 0.2, 0.1] } });
|
||||
return makeQueries(size, rng,
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${id}` }; },
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${(id + 1) % size}` }; });
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'nested comparator + OWA fusion',
|
||||
build: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
const score = i % 2 === 0 ? 20 : 80;
|
||||
arbiter.addRelation(`user:${i}`, 'risk_score', `resource:${i}`, 1.0, { value: score });
|
||||
arbiter.addRelation(`user:${i}`, 'risk_bonus', `resource:${i}`, 1.0, { value: i % 2 === 0 ? 5 : 15 });
|
||||
arbiter.addRelation(`resource:${i}`, 'risk_limit', `resource:${i}`, 1.0, { value: 40 });
|
||||
arbiter.addRelation(`resource:${i}`, 'risk_cap', `resource:${i}`, 1.0, { value: 45 });
|
||||
}
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_bonus', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_cap', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_ok_owa', { type: 'relational_comparator', comparator: '<=', fallbackBehavior: 'deny',
|
||||
left: { rule: { union: { rules: [
|
||||
{ type: 'direct', relation: 'risk_score' }, { type: 'direct', relation: 'risk_bonus' }], aggregator: 'owa', owaWeights: [0.5, 0.5] } },
|
||||
extractValue: true, valueRelation: 'risk_score', aggregator: 'owa', owaWeights: [0.5, 0.5] },
|
||||
right: { rule: { union: { rules: [
|
||||
{ type: 'direct', relation: 'risk_limit' }, { type: 'direct', relation: 'risk_cap' }], aggregator: 'owa', owaWeights: [0.6, 0.4] } },
|
||||
extractValue: true, valueRelation: 'risk_limit', evaluateFrom: 'object', aggregator: 'owa', owaWeights: [0.6, 0.4] } });
|
||||
return makeQueries(size, rng,
|
||||
() => { const id = randInt(rng, Math.ceil(size / 2)) * 2; return { user: `user:${id}`, object: `resource:${id}` }; },
|
||||
() => { const id = randInt(rng, Math.floor(size / 2)) * 2 + 1; return { user: `user:${id}`, object: `resource:${id}` }; });
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mixed 10-rule union',
|
||||
build: (arbiter, size, rng) => {
|
||||
buildUsers(arbiter, size, 'user');
|
||||
buildUsers(arbiter, size, 'resource');
|
||||
for (let i = 0; i < size; i++) {
|
||||
for (let r = 0; r < 10; r++) {
|
||||
if (i % (r + 1) === 0) arbiter.addRelation(`user:${i}`, `rel${r}`, `resource:${i}`, { possibility: 0.5 + 0.05 * r });
|
||||
}
|
||||
}
|
||||
const rules = [];
|
||||
for (let r = 0; r < 10; r++) {
|
||||
arbiter.setRelationConfig(`rel${r}`, { type: 'direct' });
|
||||
rules.push({ type: 'direct', relation: `rel${r}` });
|
||||
}
|
||||
arbiter.setRelationConfig('can_access', { union: rules });
|
||||
return makeQueries(size, rng,
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${id}` }; },
|
||||
() => { const id = randInt(rng, size); return { user: `user:${id}`, object: `resource:${(id + 1) % size}` }; });
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
const SIZE = Number(args.get('size') || 25000);
|
||||
const SAMPLES = Number(args.get('samples') || 20000);
|
||||
const SEED = Number(args.get('seed') || 42);
|
||||
|
||||
console.log(`complex_query_bench size=${SIZE} samples=${SAMPLES} (cold traffic)`);
|
||||
console.log('scenario,normal_avg_ms,normal_median_ms,normal_p95_ms,normal_p99_ms,binary_avg_ms,binary_median_ms,binary_p95_ms,binary_p99_ms,binary_speedup,parity_mismatches');
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const arbiter = new Arbiter();
|
||||
const rng = createRng(SEED);
|
||||
const queries = scenario.build(arbiter, SIZE, rng);
|
||||
|
||||
function run(mode) {
|
||||
const opts = mode === 'binary' ? { binary: true } : {};
|
||||
// Warm the relation caches once, then measure cold per-query latency.
|
||||
const first = queries[0];
|
||||
arbiter.check(first.user, scenario.relation, first.object, opts);
|
||||
const ts = [];
|
||||
for (const q of queries) {
|
||||
const s = performance.now();
|
||||
arbiter.check(q.user, scenario.relation, q.object, opts);
|
||||
ts.push(performance.now() - s);
|
||||
}
|
||||
const sorted = [...ts].sort((a, b) => a - b);
|
||||
return {
|
||||
avg: ts.reduce((a, b) => a + b, 0) / ts.length,
|
||||
median: sorted[Math.floor(sorted.length / 2)],
|
||||
p95: percentile(sorted, 0.95),
|
||||
p99: percentile(sorted, 0.99)
|
||||
};
|
||||
}
|
||||
|
||||
const normal = run('normal');
|
||||
const binary = run('binary');
|
||||
|
||||
// Parity: binary decision (possibility >= 0.8 threshold) must agree with normal decision.
|
||||
let mismatches = 0;
|
||||
for (const q of queries) {
|
||||
const n = arbiter.check(q.user, scenario.relation, q.object);
|
||||
const b = arbiter.check(q.user, scenario.relation, q.object, { binary: true });
|
||||
const nAllow = n.possibility > 0;
|
||||
const bAllow = b.possibility > 0;
|
||||
if (nAllow !== bAllow) mismatches++;
|
||||
}
|
||||
|
||||
const speedup = binary.median > 0 ? (normal.median / binary.median).toFixed(2) : 'inf';
|
||||
console.log([
|
||||
scenario.name,
|
||||
normal.avg.toFixed(4), normal.median.toFixed(4), normal.p95.toFixed(4), normal.p99.toFixed(4),
|
||||
binary.avg.toFixed(4), binary.median.toFixed(4), binary.p95.toFixed(4), binary.p99.toFixed(4),
|
||||
speedup, mismatches
|
||||
].join(','));
|
||||
}
|
||||
Generated
+12
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rigor/core": "*",
|
||||
"@tenere/benchmark-lib": "^2.0.1",
|
||||
"fast-check": "^4.5.3",
|
||||
"peggy": "^5.0.6"
|
||||
}
|
||||
@@ -305,6 +306,17 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tenere/benchmark-lib": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://hub.kl1.tenere.ai/api/packages/Tenere/npm/%40tenere%2Fbenchmark-lib/-/2.0.1/benchmark-lib-2.0.1.tgz",
|
||||
"integrity": "sha512-ZM+bAVmrg9r/4vrCkNd5sIxQtSzfKOZcg8XFACPhPNajNQDO10VFPzukQrjJ4bmD7Poc4iplAHujKPoH6GXirQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rigor/core": "^3.0.4",
|
||||
"@rigor/search": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tenere/graph-core": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://hub.kl1.tenere.ai/api/packages/Tenere/npm/%40tenere%2Fgraph-core/-/1.0.1/graph-core-1.0.1.tgz",
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@arbiter/core",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
@@ -34,7 +34,7 @@
|
||||
"test:perf": "RUN_PERF_TESTS=1 node --test --test-force-exit tests/engine/core-performance-targets.test.js",
|
||||
"generate:parser": "node scripts/generate-parser.js",
|
||||
"build:ast": "npm run generate:parser",
|
||||
"benchmark": "node benchmarks/core-performance-benchmark.js",
|
||||
"benchmark": "node --expose-gc scripts/benchmark.js",
|
||||
"benchmark:core": "node benchmarks/core-performance-benchmark.js",
|
||||
"benchmark:batch": "node benchmarks/batch-size-analysis.js",
|
||||
"benchmark:chain": "node benchmarks/chain-rule-benchmark.js",
|
||||
@@ -47,7 +47,9 @@
|
||||
"benchmark:snapshot": "node benchmarks/arbiter-snapshot-boot-bench.js",
|
||||
"benchmark:sharded": "node benchmarks/sharded-snapshot-build.js",
|
||||
"benchmark:memory": "node benchmarks/memory-breakdown.js",
|
||||
"benchmark:multi-hop": "node benchmarks/multi-hop-rule-bench.js"
|
||||
"benchmark:multi-hop": "node benchmarks/multi-hop-rule-bench.js",
|
||||
"benchmark:save": "node --expose-gc scripts/benchmark.js --save",
|
||||
"benchmark:complex": "node --expose-gc benchmarks/complex-query-bench.js"
|
||||
},
|
||||
"keywords": [
|
||||
"zanzibar",
|
||||
@@ -64,6 +66,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rigor/core": "*",
|
||||
"@tenere/benchmark-lib": "^2.0.1",
|
||||
"fast-check": "^4.5.3",
|
||||
"peggy": "^5.0.6"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CI benchmark script for @arbiter/core — powered by @tenere/benchmark-lib
|
||||
* (possibilistic contours with valid alpha cuts).
|
||||
*
|
||||
* node --expose-gc scripts/benchmark.js compare against baseline
|
||||
* node --expose-gc scripts/benchmark.js --save save new baseline
|
||||
* node --expose-gc scripts/benchmark.js --json JSON output
|
||||
* node --expose-gc scripts/benchmark.js --baseline <path> custom baseline path
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all benchmarks pass, no high-severity regressions
|
||||
* 1 — high-severity regression found
|
||||
* 2 — crash (benchmark threw unexpectedly)
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import { benchmark, createBaseline, detectRegressions, formatRegressions } from '@tenere/benchmark-lib'
|
||||
import { Arbiter } from '../src/core/Arbiter.js'
|
||||
import { PartialGraphContext } from '../src/core/PartialGraphContext.js'
|
||||
|
||||
const SAVE = process.argv.includes('--save')
|
||||
const AS_JSON = process.argv.includes('--json')
|
||||
const BASELINE_PATH = (() => {
|
||||
const idx = process.argv.indexOf('--baseline')
|
||||
return idx >= 0 ? process.argv[idx + 1] : '.rigor-baseline.json'
|
||||
})()
|
||||
|
||||
benchmark.config({
|
||||
measurements: ['timing'],
|
||||
uncertaintyThreshold: 0.99,
|
||||
minSamples: 0,
|
||||
maxSamples: 200,
|
||||
overheadCompensation: true,
|
||||
gcBetweenSamples: true,
|
||||
})
|
||||
|
||||
let CRASHED = 0
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildEngine() {
|
||||
const a = new Arbiter()
|
||||
const groupCount = 40
|
||||
for (let g = 0; g < groupCount; g++) a.addNode(`group:${g}`, 'group')
|
||||
for (let u = 0; u < 200; u++) {
|
||||
a.addNode(`user:${u}`, 'user')
|
||||
a.addNode(`doc:${u}`, 'doc')
|
||||
}
|
||||
a.setRelationConfig('owner', { type: 'direct' })
|
||||
a.setRelationConfig('member_of', { type: 'direct' })
|
||||
a.setRelationConfig('can_read', {
|
||||
type: 'union',
|
||||
rules: [{ kind: 'direct', relation: 'owner' }, { kind: 'tuple_to_userset', parentRelation: 'member_of', childRelation: 'owner' }]
|
||||
})
|
||||
for (let u = 0; u < 200; u++) {
|
||||
a.addRelation(`user:${u}`, 'owner', `doc:${u}`, { possibility: 0.9 + (u % 10) / 100 })
|
||||
a.addRelation(`user:${u}`, 'member_of', `group:${u % groupCount}`, { possibility: 0.7 })
|
||||
}
|
||||
for (let g = 0; g < groupCount; g++) a.addRelation(`group:${g}`, 'owner', `doc:${g}`, { possibility: 1.0 })
|
||||
return a
|
||||
}
|
||||
|
||||
const engine = buildEngine()
|
||||
|
||||
const directBench = benchmark('check[direct-hit]', () => {
|
||||
engine.check('user:1', 'owner', 'doc:1')
|
||||
})
|
||||
|
||||
const unionBench = benchmark('check[union-ttu]', () => {
|
||||
engine.check('user:1', 'can_read', 'doc:1')
|
||||
})
|
||||
|
||||
const deniedBench = benchmark('check[denied-miss]', () => {
|
||||
engine.check('user:5', 'owner', 'doc:1')
|
||||
})
|
||||
|
||||
const metaBench = benchmark('check[include-meta]', () => {
|
||||
engine.check('user:1', 'can_read', 'doc:1', { includeMeta: true })
|
||||
})
|
||||
|
||||
const overlayBench = benchmark('check[overlay-on-top]', () => {
|
||||
const ctx = new PartialGraphContext(engine, {
|
||||
relations: [{ src: 'user:1', relation: 'owner', dst: 'doc:1', possibility: 0.5 }]
|
||||
})
|
||||
engine.check('user:1', 'owner', 'doc:1', { partialGraphContext: ctx })
|
||||
})
|
||||
|
||||
const binaryBench = (() => {
|
||||
const snap = buildEngine()
|
||||
snap.enableCondensedSnapshot()
|
||||
return benchmark('check[binary-direct]', () => {
|
||||
snap.check('user:1', 'owner', 'doc:1', { binary: true })
|
||||
})
|
||||
})()
|
||||
|
||||
const snapshotBuildBench = (() => {
|
||||
const snap = buildEngine()
|
||||
snap.enableCondensedSnapshot()
|
||||
return benchmark('snapshot[build-binary]', () => {
|
||||
snap.toSnapshotBinary()
|
||||
})
|
||||
})()
|
||||
|
||||
const snapshotRestoreBench = (() => {
|
||||
const snap = buildEngine()
|
||||
snap.enableCondensedSnapshot()
|
||||
const buf = snap.toSnapshotBinary()
|
||||
return benchmark('snapshot[restore-binary]', () => {
|
||||
Arbiter.fromSnapshotBinary(buf)
|
||||
})
|
||||
})()
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────
|
||||
|
||||
function measure(handle) {
|
||||
try {
|
||||
return handle.contour('timing')
|
||||
} catch (e) {
|
||||
CRASHED++
|
||||
console.error(`⚠ ${e.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const results = {}
|
||||
const order = [directBench, unionBench, deniedBench, metaBench, overlayBench, binaryBench, snapshotBuildBench, snapshotRestoreBench]
|
||||
for (const handle of order) {
|
||||
const contour = measure(handle)
|
||||
if (!contour) continue
|
||||
const mp = contour.mostPlausible()
|
||||
results[handle.name] = {
|
||||
mostPlausible: mp.value,
|
||||
alphaCuts: contour.alphaCuts(),
|
||||
}
|
||||
}
|
||||
|
||||
const current = { actions: results, campaign: null, failures: 0 }
|
||||
|
||||
if (AS_JSON) {
|
||||
const out = { results: {}, regressions: [], improvements: [], crashed: CRASHED }
|
||||
for (const [name, r] of Object.entries(results)) {
|
||||
out.results[name] = { timing: { mp: r.mostPlausible, alphaCuts: r.alphaCuts } }
|
||||
}
|
||||
if (fs.existsSync(BASELINE_PATH) && !SAVE) {
|
||||
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
|
||||
const regResult = detectRegressions(current, baseline)
|
||||
out.regressions = regResult.regressions
|
||||
out.improvements = regResult.improvements
|
||||
}
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
} else {
|
||||
for (const [name, r] of Object.entries(results)) {
|
||||
const cuts = r.alphaCuts
|
||||
console.log(`${name}: mp=${r.mostPlausible.toFixed(4)}ms p95=[${cuts.p95.lower.toFixed(4)},${cuts.p95.upper.toFixed(4)}]`)
|
||||
}
|
||||
}
|
||||
|
||||
if (SAVE) {
|
||||
const baseline = createBaseline([current])
|
||||
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2), 'utf8')
|
||||
console.log(`baseline saved: ${BASELINE_PATH}`)
|
||||
} else if (fs.existsSync(BASELINE_PATH)) {
|
||||
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
|
||||
const regResult = detectRegressions(current, baseline)
|
||||
if (!AS_JSON) console.log(formatRegressions(regResult, 'pretty'))
|
||||
const critical = regResult.regressions.filter(r => r.severity === 'high')
|
||||
if (critical.length > 0) {
|
||||
console.error(`❌ ${critical.length} critical regression(s): ${critical.map(r => r.name).join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (!SAVE) {
|
||||
console.log('no baseline — first run, use --save')
|
||||
}
|
||||
|
||||
if (CRASHED > 0) {
|
||||
console.error(`⚠ ${CRASHED} benchmark(s) crashed`)
|
||||
process.exit(SAVE ? 0 : 2)
|
||||
}
|
||||
@@ -116,8 +116,11 @@ export class AuthorizationChecker {
|
||||
|
||||
// Cache the result (only when no partial graph and the default
|
||||
// meta-less form — includeMeta callers and pinned-clock callers get
|
||||
// a fresh evaluation)
|
||||
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned) {
|
||||
// a fresh evaluation). Value-carrying results are NEVER cached: the
|
||||
// decision is timeless, but collected values are TTL-gated evidence
|
||||
// and a cached entry would serve stale values past their TTL (the
|
||||
// default TTL is 24h even without an explicit setTTL).
|
||||
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned && !result.collectedValues) {
|
||||
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||
}
|
||||
return result;
|
||||
@@ -197,17 +200,39 @@ export class AuthorizationChecker {
|
||||
reason: 'direct_match'
|
||||
};
|
||||
|
||||
// Collect values if present
|
||||
// Collect values if present — but only while the value is
|
||||
// FRESH. TTL is a value-freshness gate (see ValueManager
|
||||
// _isValueExpired); an expired value must not surface in
|
||||
// collectedValues, matching the comparator path which skips
|
||||
// expired relations entirely.
|
||||
if (collectValues && directRel.value !== undefined) {
|
||||
const valueManager = this.arbiter.valueManager;
|
||||
const expired = valueManager && valueManager._isValueExpired
|
||||
? valueManager._isValueExpired(directRel, options.now !== undefined && options.now !== null ? options.now : null)
|
||||
: false;
|
||||
if (!expired) {
|
||||
// Standard collected-value shape (parity with the rule
|
||||
// paths): value, possibility, path, source, metadata with a
|
||||
// caller-clock-honoring timestamp.
|
||||
const ts = directRel.changed_last_at || directRel.updated_last_at ||
|
||||
(options.now !== undefined && options.now !== null ? options.now : Date.now());
|
||||
result.collectedValues = [{
|
||||
value: directRel.value,
|
||||
source: 'direct_relation',
|
||||
possibility: directRel.possibility ?? 1.0,
|
||||
path: [userKey, objectKey],
|
||||
source: {
|
||||
entityKey: userKey,
|
||||
relation: relation,
|
||||
userKey: userKey,
|
||||
objectKey: objectKey
|
||||
step: 0
|
||||
},
|
||||
metadata: {
|
||||
timestamp: ts,
|
||||
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fast-path miss: attach remediation when the missing (effective)
|
||||
// relation is declared as an injectable witness source — the caller
|
||||
@@ -235,8 +260,10 @@ export class AuthorizationChecker {
|
||||
result.meta.cache = cacheHint;
|
||||
}
|
||||
|
||||
// Cache the result using composite key (meta-less form only)
|
||||
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned) {
|
||||
// Cache the result using composite key (meta-less form only).
|
||||
// Value-carrying results are never cached (collected values are
|
||||
// TTL-gated evidence; a cached entry would serve stale values).
|
||||
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned && !result.collectedValues) {
|
||||
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||
}
|
||||
return result;
|
||||
@@ -659,37 +686,6 @@ export class AuthorizationChecker {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for cycles using efficient approach
|
||||
const useKeyedVisited = this._getVisitedMode(_visited);
|
||||
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||||
if (useKeyedVisited) {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const visited of _visited) {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey });
|
||||
|
||||
const config = this.arbiter.relationConfigs.get(relation);
|
||||
if (!config) {
|
||||
return {
|
||||
@@ -766,6 +762,41 @@ export class AuthorizationChecker {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycles using efficient approach.
|
||||
// Deliberately placed AFTER the direct fast path: direct checks never
|
||||
// recurse, so they must not pay for key allocation / Set mutation. Only
|
||||
// the rule-evaluation branches (which recurse via evaluateRule with the
|
||||
// shared _visited) need cycle detection.
|
||||
const useKeyedVisited = this._getVisitedMode(_visited);
|
||||
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||||
if (useKeyedVisited) {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const visited of _visited) {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
validity: includeMeta ? DEFAULT_VALIDITY : minimalValidity(DEFAULT_VALIDITY),
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey });
|
||||
|
||||
// Handle logical operators with binary evaluation
|
||||
if (config.union || config.intersection || config.exclusion) {
|
||||
const res = this.ruleEvaluator.evaluateRule(
|
||||
|
||||
@@ -36,7 +36,9 @@ export class DecisionCache {
|
||||
*/
|
||||
constructor(arbiter, options = {}) {
|
||||
this.arbiter = arbiter;
|
||||
this.clock = options.clock || (() => Date.now());
|
||||
// Precedence: an explicit cache-level clock wins; else the arbiter's
|
||||
// injected clock (options.clock on the Arbiter); else the wall clock.
|
||||
this.clock = options.clock || ((arbiter && typeof arbiter.clock === 'function') ? arbiter.clock.bind(arbiter) : (() => Date.now()));
|
||||
this._enabled = !!arbiter;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ export class RuleEvaluator {
|
||||
|
||||
if (canCacheRuleResult) {
|
||||
const cached = this.arbiter.ruleResultCache.get(ruleCacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
if (cached && cacheNow - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
|
||||
this.arbiter.ruleResultCacheStats.hits++;
|
||||
if (collectValues && finalValueContext && cached.result?.collectedValues?.length) {
|
||||
const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule');
|
||||
@@ -159,9 +160,10 @@ export class RuleEvaluator {
|
||||
|
||||
_maybeCacheRuleResult(result, relation, cacheKey, enabled) {
|
||||
if (!enabled || !cacheKey || !relation) return result;
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
this.arbiter.ruleResultCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
timestamp: cacheNow
|
||||
});
|
||||
this.arbiter._cacheRuleResult(relation, cacheKey);
|
||||
return result;
|
||||
|
||||
@@ -177,6 +177,9 @@ export class BaseRule {
|
||||
* @protected
|
||||
*/
|
||||
_createCollectedValue(value, possibility, path, source, metadata = {}) {
|
||||
// Timestamps honor the caller's pinned clock when present; the wall
|
||||
// clock is only the fallback for unpinned callers.
|
||||
const clockNow = (typeof metadata._now === 'number') ? metadata._now : Date.now();
|
||||
return {
|
||||
value: value,
|
||||
possibility: possibility ?? 1.0,
|
||||
@@ -188,7 +191,7 @@ export class BaseRule {
|
||||
step: source.step !== undefined ? source.step : 0
|
||||
},
|
||||
metadata: {
|
||||
timestamp: metadata.timestamp || Date.now(),
|
||||
timestamp: metadata.timestamp || clockNow,
|
||||
reliability: metadata.reliability !== undefined ? metadata.reliability : 1.0,
|
||||
decay: metadata.decay || null,
|
||||
...metadata
|
||||
|
||||
@@ -153,9 +153,16 @@ export class ChainRule extends BaseRule {
|
||||
// checks get served full-mode values (and vice versa).
|
||||
const isThresholdEval = options.binary === true || (options.fastPath === true && options.minPossibility != null);
|
||||
|
||||
// A caller-pinned clock (options.now) makes the result per-clock: a
|
||||
// chain result captured at one time (with then-fresh values) must not
|
||||
// be served to a caller asking about another time. Same contract as
|
||||
// the rule result cache (RuleEvaluator): pinned-clock callers bypass
|
||||
// the chain cache entirely — both reads and writes.
|
||||
const temporalPinned = options.now !== undefined && options.now !== null;
|
||||
|
||||
// Check for cached chain result (use numeric IDs) - only if caching is enabled
|
||||
// Skip cache when a partial graph is present to prevent cross-request leakage
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
|
||||
const cachedResult = this._getCachedChainResult(userIdNum, objectIdNum, steps);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
@@ -387,8 +394,10 @@ export class ChainRule extends BaseRule {
|
||||
|
||||
// Cache the chain result (use numeric IDs) - only if caching is enabled
|
||||
// Do not cache when a partial graph is present to prevent cross-request leakage
|
||||
// Do not cache threshold-mode results (see isThresholdEval above)
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
// Do not cache threshold-mode results (see isThresholdEval above).
|
||||
// Do not cache pinned-clock results either — the entry is per-clock
|
||||
// and would be served to later unpinned callers as if it were timeless.
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
|
||||
this._cacheChainResult(userIdNum, objectIdNum, steps, result);
|
||||
}
|
||||
|
||||
@@ -424,7 +433,8 @@ export class ChainRule extends BaseRule {
|
||||
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
||||
const entry = this.chainResultCache.get(key);
|
||||
|
||||
if (entry && Date.now() - entry.timestamp < this.cacheTTL) {
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
if (entry && cacheNow - entry.timestamp < this.cacheTTL) {
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
@@ -442,9 +452,10 @@ export class ChainRule extends BaseRule {
|
||||
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
||||
|
||||
// HyperbolicLRUCache handles eviction automatically based on frequency and recency
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
this.chainResultCache.set(key, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
timestamp: cacheNow
|
||||
});
|
||||
}
|
||||
|
||||
@@ -505,7 +516,8 @@ export class ChainRule extends BaseRule {
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
const blurred = this.arbiter.valueManager.getBlurredValue(relation);
|
||||
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
|
||||
const blurred = this.arbiter.valueManager.getBlurredValue(relation, callerNow);
|
||||
|
||||
if (blurred.interval) {
|
||||
const sourceEntity = direction === 'in' ?
|
||||
@@ -530,7 +542,8 @@ export class ChainRule extends BaseRule {
|
||||
source: relation.source || 'persistent'
|
||||
},
|
||||
{
|
||||
timestamp: relation.changed_last_at || relation.updated_last_at || Date.now(),
|
||||
timestamp: relation.changed_last_at || relation.updated_last_at ||
|
||||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
|
||||
reliability: blurred.reliability,
|
||||
pathPossibility: currentPath.possibility,
|
||||
relationPossibility: relation.possibility ?? 1.0,
|
||||
@@ -580,7 +593,8 @@ export class ChainRule extends BaseRule {
|
||||
Arbiter.DEBUG && Arbiter.log('ChainRule: relationManager or valueManager is undefined');
|
||||
return collectedValues;
|
||||
}
|
||||
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation);
|
||||
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
|
||||
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation, callerNow);
|
||||
|
||||
if (blurred.interval) {
|
||||
const collectedValue = this._createCollectedValue(
|
||||
|
||||
@@ -108,7 +108,8 @@ export class DirectRule extends BaseRule {
|
||||
step: 0
|
||||
},
|
||||
{
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at ||
|
||||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
|
||||
reliability: 1.0,
|
||||
source: directRel.source || 'persistent'
|
||||
}
|
||||
|
||||
@@ -370,21 +370,27 @@ export class MultiHopRule extends BaseRule {
|
||||
// Collect from direct edge if available
|
||||
if (step.edge && step.edge.value !== undefined && step.edge.value !== null) {
|
||||
if (this._passesValueFilters(step.edge.value, valueFilters)) {
|
||||
// Check TTL
|
||||
const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000;
|
||||
const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || Date.now();
|
||||
// Check TTL. The valueManager's per-relation TTL is the
|
||||
// authority (matching chain/comparator paths); valueFilters.ttl
|
||||
// is an explicit override. Timestamps and the age computation
|
||||
// use the caller-pinned clock, never the wall clock.
|
||||
const ttl = valueFilters.ttl || (valueManager && valueManager.getTTL
|
||||
? valueManager.getTTL(step.edge.rel)
|
||||
: 24 * 60 * 60 * 1000);
|
||||
const timestamp = step.edge.changed_last_at || step.edge.updated_last_at ||
|
||||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now());
|
||||
|
||||
if (!OWAFusion.isWithinTTL(timestamp, ttl, evaluationNow)) {
|
||||
Arbiter.DEBUG && Arbiter.log('MultiHop skipping value due to TTL:', {
|
||||
value: step.edge.value,
|
||||
timestamp,
|
||||
ttl,
|
||||
age: Date.now() - timestamp
|
||||
age: evaluationNow - timestamp
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blurred = valueManager.getBlurredValue(step.edge);
|
||||
const blurred = valueManager.getBlurredValue(step.edge, evaluationNow);
|
||||
|
||||
if (blurred.interval) {
|
||||
const collectedValue = this._createCollectedValue(
|
||||
@@ -447,7 +453,7 @@ export class MultiHopRule extends BaseRule {
|
||||
changed_last_at: contextValue.timestamp
|
||||
};
|
||||
|
||||
const blurred = valueManager.getBlurredValue(tempRelation);
|
||||
const blurred = valueManager.getBlurredValue(tempRelation, evaluationNow);
|
||||
|
||||
if (blurred.interval) {
|
||||
const collectedValue = this._createCollectedValue(
|
||||
|
||||
@@ -244,7 +244,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
}
|
||||
|
||||
// Extract blurred values with qualitative decay and blur
|
||||
const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale);
|
||||
const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale, options);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.blurredValues = blurredValues;
|
||||
@@ -299,7 +299,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
values.push({
|
||||
value: qualitativeValue,
|
||||
possibility: qualitativeValue,
|
||||
timestamp: Date.now(),
|
||||
timestamp: options && options.now !== undefined && options.now !== null ? options.now : Date.now(),
|
||||
relation: 'rule_result',
|
||||
meta: { source: 'rule_possibility' }
|
||||
});
|
||||
@@ -351,7 +351,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
* Extract blurred values with qualitative decay and blur
|
||||
* @private
|
||||
*/
|
||||
_extractBlurredValues(values, operandConfig, scale) {
|
||||
_extractBlurredValues(values, operandConfig, scale, options = null) {
|
||||
const {
|
||||
decaySteps = 1,
|
||||
decayPeriod = 'HOUR',
|
||||
@@ -366,10 +366,11 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
for (const valueObj of values) {
|
||||
const pointValue = valueObj.value;
|
||||
const initialPossibility = valueObj.possibility;
|
||||
const timestamp = valueObj.timestamp || Date.now();
|
||||
const timestamp = valueObj.timestamp ||
|
||||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now());
|
||||
|
||||
// Calculate periods elapsed
|
||||
const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod);
|
||||
const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod, options);
|
||||
|
||||
// Calculate decayed possibility
|
||||
const decayedPossibility = this._calculateDecayedPossibility(
|
||||
@@ -415,8 +416,8 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
* Calculate periods elapsed since timestamp
|
||||
* @private
|
||||
*/
|
||||
_calculatePeriodsElapsed(timestamp, decayPeriod) {
|
||||
const now = Date.now();
|
||||
_calculatePeriodsElapsed(timestamp, decayPeriod, options = null) {
|
||||
const now = options && options.now !== undefined && options.now !== null ? options.now : Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
const periodMs = {
|
||||
@@ -533,7 +534,8 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
step: index
|
||||
},
|
||||
metadata: {
|
||||
timestamp: bv.timestamp || Date.now(),
|
||||
timestamp: bv.timestamp ||
|
||||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
|
||||
reliability: 1.0,
|
||||
originalValue: bv.originalValue,
|
||||
originalPossibility: bv.originalPossibility,
|
||||
|
||||
@@ -228,7 +228,8 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
: null;
|
||||
if (canCacheDerived) {
|
||||
const cached = this.arbiter.ruleResultCache.get(derivedCacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
if (cached && cacheNow - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
|
||||
const cachedResult = cached.result;
|
||||
let valueInterval = cachedResult.valueInterval;
|
||||
let operandPossibility = cachedResult.operandPossibility || 0;
|
||||
@@ -316,7 +317,8 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
step: 0
|
||||
},
|
||||
{
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at ||
|
||||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()),
|
||||
reliability: directRel.reliability || 1.0,
|
||||
source: directRel.source || 'persistent'
|
||||
}
|
||||
@@ -324,6 +326,7 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
}
|
||||
|
||||
if (canCacheDerived && derivedCacheKey) {
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
this.arbiter.ruleResultCache.set(derivedCacheKey, {
|
||||
result: {
|
||||
valueInterval,
|
||||
@@ -333,7 +336,7 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
source: operandSource,
|
||||
collectedValues: operandCollectedValues
|
||||
},
|
||||
timestamp: Date.now()
|
||||
timestamp: cacheNow
|
||||
});
|
||||
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
|
||||
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
|
||||
@@ -397,7 +400,8 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
value: rel.value,
|
||||
possibility: rel.possibility !== undefined ? rel.possibility : 1.0,
|
||||
reliability: rel.reliability || 1.0,
|
||||
timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(),
|
||||
timestamp: rel.changed_last_at || rel.updated_last_at ||
|
||||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()),
|
||||
source: 'direct_relation',
|
||||
originalValue: rel.value
|
||||
});
|
||||
@@ -414,7 +418,8 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
step: 0
|
||||
},
|
||||
{
|
||||
timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(),
|
||||
timestamp: rel.changed_last_at || rel.updated_last_at ||
|
||||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()),
|
||||
reliability: rel.reliability || 1.0,
|
||||
source: rel.source || 'persistent'
|
||||
}
|
||||
@@ -450,6 +455,7 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
}
|
||||
|
||||
if (canCacheDerived && derivedCacheKey) {
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
this.arbiter.ruleResultCache.set(derivedCacheKey, {
|
||||
result: {
|
||||
valueInterval,
|
||||
@@ -459,7 +465,7 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
source: operandSource,
|
||||
collectedValues: operandCollectedValues
|
||||
},
|
||||
timestamp: Date.now()
|
||||
timestamp: cacheNow
|
||||
});
|
||||
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
|
||||
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
|
||||
@@ -566,6 +572,7 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
}
|
||||
|
||||
if (canCacheDerived && derivedCacheKey) {
|
||||
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
||||
this.arbiter.ruleResultCache.set(derivedCacheKey, {
|
||||
result: {
|
||||
valueInterval,
|
||||
@@ -575,7 +582,7 @@ export class RelationalComparatorRule extends BaseRule {
|
||||
source: operandSource,
|
||||
collectedValues: operandCollectedValues
|
||||
},
|
||||
timestamp: Date.now()
|
||||
timestamp: cacheNow
|
||||
});
|
||||
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
|
||||
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
|
||||
|
||||
@@ -188,7 +188,8 @@ export class TupleToUsersetRule extends BaseRule {
|
||||
[tupleSrcKey, intermediateKey],
|
||||
{ entityKey: tupleSrcKey, relation: rule.tuplesetRelation, step: 0 },
|
||||
{
|
||||
timestamp: tupleEdge.changed_last_at || tupleEdge.updated_last_at || Date.now(),
|
||||
timestamp: tupleEdge.changed_last_at || tupleEdge.updated_last_at ||
|
||||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
|
||||
reliability: tupleEdge.reliability || 1.0,
|
||||
source: tupleEdge.source || 'persistent'
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ export class Arbiter {
|
||||
// is built only when the hook exists, so the default path is untouched.
|
||||
this._auditHook = typeof options.audit === 'function' ? options.audit : null;
|
||||
|
||||
// The engine's clock. The caller may inject a clock (options.clock);
|
||||
// the wall clock is the fallback for unpinned callers only. Per-check
|
||||
// time is always the caller's `{ now }` / partialGraph.now — this
|
||||
// clock only drives UNPINNED cache-entry freshness.
|
||||
this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now());
|
||||
|
||||
// Injectable cache factory (DI): defaults to the built-in SimpleLRUCache.
|
||||
// Pass options.cacheFactory to substitute another cache implementation.
|
||||
this.cacheFactory = options.cacheFactory || defaultCacheFactory;
|
||||
|
||||
@@ -126,7 +126,9 @@ export class PartialGraphContext {
|
||||
|
||||
_addChallengeProof(proof) {
|
||||
const subjectId = this._resolveNodeId(proof.subject);
|
||||
const now = Date.now();
|
||||
// The partial graph carries the caller's clock; fall back to the wall
|
||||
// clock only when the caller provided no time at all.
|
||||
const now = this.now !== null && this.now !== undefined ? this.now : Date.now();
|
||||
// 0 is a valid timestamp (epoch-issued / already-expired); `||` would
|
||||
// replace it with the wall clock or null, making an epoch-issued proof
|
||||
// the most recent one and an epoch-expired proof never expire.
|
||||
@@ -150,7 +152,7 @@ export class PartialGraphContext {
|
||||
const srcId = this._resolveNodeId(rel.src);
|
||||
const dstId = this._resolveNodeId(rel.dst);
|
||||
const relation = rel.relation;
|
||||
const now = Date.now();
|
||||
const now = this.now !== null && this.now !== undefined ? this.now : Date.now();
|
||||
const relationObj = {
|
||||
src: srcId,
|
||||
rel: relation,
|
||||
|
||||
@@ -327,7 +327,8 @@ export class RelationManager {
|
||||
this._ensureIndicesBuilt();
|
||||
const rel = this.arbiter.indices.getDirectRelation(srcId, relation, dstId);
|
||||
if (rel && rel.value !== undefined) {
|
||||
const blurred = this.arbiter.valueManager.getBlurredValue(rel);
|
||||
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
|
||||
const blurred = this.arbiter.valueManager.getBlurredValue(rel, callerNow);
|
||||
const result = {
|
||||
pointValue: rel.value, // Original crisp value
|
||||
value: rel.value, // Backward compatibility
|
||||
@@ -543,7 +544,7 @@ export class RelationManager {
|
||||
* @param {number} epsilon - Optional epsilon for equality
|
||||
* @returns {number} Possibility (0-1) that comparison holds
|
||||
*/
|
||||
compareRelationValues(leftRelation, rightRelation, comparator, epsilon = null) {
|
||||
compareRelationValues(leftRelation, rightRelation, comparator, epsilon = null, options = null) {
|
||||
// Get actual relation objects if needed
|
||||
const leftRel = leftRelation.value !== undefined ? leftRelation :
|
||||
this.getDirectRelation(leftRelation.srcId, leftRelation.relation, leftRelation.dstId);
|
||||
@@ -554,8 +555,9 @@ export class RelationManager {
|
||||
return 0; // Cannot compare if either has no value
|
||||
}
|
||||
|
||||
const leftBlurred = this.arbiter.valueManager.getBlurredValue(leftRel);
|
||||
const rightBlurred = this.arbiter.valueManager.getBlurredValue(rightRel);
|
||||
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
|
||||
const leftBlurred = this.arbiter.valueManager.getBlurredValue(leftRel, callerNow);
|
||||
const rightBlurred = this.arbiter.valueManager.getBlurredValue(rightRel, callerNow);
|
||||
|
||||
if (!leftBlurred.interval || !rightBlurred.interval) {
|
||||
return 0; // Cannot compare null intervals
|
||||
|
||||
+15
-11
@@ -170,9 +170,11 @@ export class ValueManager {
|
||||
/**
|
||||
* Get or calculate the blurred value interval for a relation
|
||||
* @param {Object} relation - The relation object
|
||||
* @param {number|null} [now] - Caller-pinned clock; the wall clock is
|
||||
* only the fallback for unpinned callers.
|
||||
* @returns {Object} { interval: {min, max}, possibility: number, reliability: number }
|
||||
*/
|
||||
getBlurredValue(relation) {
|
||||
getBlurredValue(relation, now = null) {
|
||||
// If relation has no value, return null interval
|
||||
if (relation.value === undefined || relation.value === null) {
|
||||
return {
|
||||
@@ -183,7 +185,7 @@ export class ValueManager {
|
||||
}
|
||||
|
||||
// Check TTL first - if expired, return null interval
|
||||
if (this._isValueExpired(relation)) {
|
||||
if (this._isValueExpired(relation, now)) {
|
||||
return {
|
||||
interval: null,
|
||||
possibility: 0,
|
||||
@@ -204,7 +206,7 @@ export class ValueManager {
|
||||
* @param {Object} relation - The relation object
|
||||
* @returns {Object} { pointValue, blurredInterval, currentPossibility, originalPossibility, reliability }
|
||||
*/
|
||||
getDecayedRelation(relation) {
|
||||
getDecayedRelation(relation, now = null) {
|
||||
if (relation.value === undefined || relation.value === null) {
|
||||
return {
|
||||
pointValue: null,
|
||||
@@ -232,18 +234,19 @@ export class ValueManager {
|
||||
* Calculate separated decay for value blurring and possibility
|
||||
* @private
|
||||
*/
|
||||
_calculateSeparatedDecay(relation) {
|
||||
_calculateSeparatedDecay(relation, now = null) {
|
||||
const pointValue = relation.value;
|
||||
const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0;
|
||||
const reliability = relation.reliability !== undefined ? relation.reliability : 1.0;
|
||||
const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now();
|
||||
const timestamp = relation.changed_last_at || relation.updated_last_at ||
|
||||
(now !== null && now !== undefined ? now : Date.now());
|
||||
|
||||
// Get decay configuration
|
||||
const config = this._getRelationDecayConfig(relation);
|
||||
|
||||
// Calculate age
|
||||
const now = Date.now();
|
||||
const ageMs = Math.max(0, now - timestamp);
|
||||
const evalNow = now !== null && now !== undefined ? now : Date.now();
|
||||
const ageMs = Math.max(0, evalNow - timestamp);
|
||||
const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR;
|
||||
const ageInPeriod = ageMs / periodMs;
|
||||
|
||||
@@ -636,18 +639,19 @@ export class ValueManager {
|
||||
* Calculate the blurred value interval for a relation
|
||||
* @private
|
||||
*/
|
||||
_calculateBlurredValue(relation) {
|
||||
_calculateBlurredValue(relation, now = null) {
|
||||
const pointValue = relation.value;
|
||||
const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0;
|
||||
const reliability = relation.reliability !== undefined ? relation.reliability : 1.0;
|
||||
const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now();
|
||||
const timestamp = relation.changed_last_at || relation.updated_last_at ||
|
||||
(now !== null && now !== undefined ? now : Date.now());
|
||||
|
||||
// Get decay configuration
|
||||
const config = this._getRelationDecayConfig(relation);
|
||||
|
||||
// Calculate age
|
||||
const now = Date.now();
|
||||
const ageMs = Math.max(0, now - timestamp); // Ensure non-negative
|
||||
const evalNow = now !== null && now !== undefined ? now : Date.now();
|
||||
const ageMs = Math.max(0, evalNow - timestamp); // Ensure non-negative
|
||||
const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR;
|
||||
const ageInPeriod = ageMs / periodMs;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('ChainRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('empty-steps', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'chain-rule-empty-steps', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'empty-steps');
|
||||
@@ -84,7 +84,7 @@ describe('ChainRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'chain-rule-possibility-bounded', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
|
||||
@@ -119,7 +119,7 @@ describe('ChainRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-path', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'chain-rule-no-path', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-path');
|
||||
@@ -156,7 +156,7 @@ describe('ChainRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('one-step-pos', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'chain-rule-one-step', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'one-step-pos');
|
||||
@@ -206,7 +206,7 @@ describe('ChainRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('two-step-chain', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'chain-rule-two-step', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'two-step-chain');
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('PartialGraphContext.getChallengeProof (rigor)', () => {
|
||||
({ error, errorMessage }) => !error && !errorMessage
|
||||
)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-proof-oracle', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
@@ -152,7 +152,7 @@ describe('PartialGraphContext.getChallengeProof (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-expired', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-proof-expired', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
@@ -227,7 +227,7 @@ describe('PartialGraphContext.getChallengeProof (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('most-recent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-proof-most-recent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
@@ -283,7 +283,7 @@ describe('PartialGraphContext.getChallengeProof (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('within-window', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-proof-within-ms', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('ChallengeRule._resolveSubjectKey (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('subjectKey-wins', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1000 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-rule-subject-key-wins', effort: 1000 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'subjectKey-wins');
|
||||
@@ -118,7 +118,7 @@ describe('ChallengeRule._resolveSubjectKey (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('subject-mapping', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-rule-subject-mapping', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'subject-mapping');
|
||||
@@ -199,7 +199,7 @@ describe('ChallengeRule._resolveWithinMs (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('within-units', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-rule-within-units', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'within-units');
|
||||
@@ -262,7 +262,7 @@ describe('ChallengeRule._resolveWithinMs (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('within-priority', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-rule-within-priority', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'within-priority');
|
||||
@@ -295,7 +295,7 @@ describe('ChallengeRule._resolveWithinMs (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('null-when-absent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-rule-null-when-absent', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'null-when-absent');
|
||||
@@ -335,7 +335,7 @@ describe('ChallengeRule._buildRequirement (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('buildRequirement', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'challenge-rule-build-requirement', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'buildRequirement');
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* rigor/complex-graph-mutation-crucible.test.js — mutation crucible over
|
||||
* the complex graphs.
|
||||
*
|
||||
* Parity under mutation is the engine's stale-cache hunter: random edge
|
||||
* removals and additions on community/scale-free graphs, with
|
||||
* normal/binary agreement re-checked after EVERY mutation. A stale cache
|
||||
* or an index desync surfaces as a disagreement on the very next query.
|
||||
*
|
||||
* MUTATION-FRESHNESS — the LIVE engine's grant is revoked/applied
|
||||
* immediately after each mutation (no stale cache on the writable
|
||||
* path), and binary mode agrees with normal at every step.
|
||||
*
|
||||
* Snapshot discipline: enableCondensedSnapshot() permanently flips the
|
||||
* engine to read-only snapshot mode (writes throw), so the snapshot
|
||||
* cannot be taken mid-mutation on the live engine. The writable engine
|
||||
* and the read-only snapshot are therefore separate instances; the
|
||||
* round-trip parity property (snapshot == live at the same state) is
|
||||
* already covered by complex-graph-crucible and snapshot-parity.
|
||||
*/
|
||||
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 GENERATORS = [
|
||||
{ name: 'community', make: makeCommunityGraph, directRel: 'direct_access' },
|
||||
{ name: 'scale-free', make: makeScaleFreeGraph, directRel: 'can_read' }
|
||||
];
|
||||
|
||||
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 });
|
||||
if (normal.possibility > 0 !== binary.possibility > 0) {
|
||||
fail(`binary disagreement: normal=${normal.possibility} binary=${binary.possibility}`);
|
||||
}
|
||||
return normal;
|
||||
}
|
||||
|
||||
describe('Complex-graph mutation crucibles (rigor)', () => {
|
||||
it('MUTATION-FRESHNESS: live grants revoke/apply immediately; binary agrees', async () => {
|
||||
async function check(args) {
|
||||
const { genKind, seed } = args;
|
||||
const spec = GENERATORS.find(g => g.name === genKind);
|
||||
const g = spec.make(seed);
|
||||
const arbiter = g.arbiter;
|
||||
const rel = spec.directRel;
|
||||
const allKeys = [...g.users, ...(g.resources || [])];
|
||||
|
||||
for (let s = 0; s < 6; s++) {
|
||||
const u = allKeys[Math.floor(Math.random() * allKeys.length)];
|
||||
const o = allKeys[Math.floor(Math.random() * allKeys.length)];
|
||||
const before = checkModes(arbiter, u, rel, o).possibility;
|
||||
if (before > 0) {
|
||||
// Revoke every direct edge between u and o, then verify 0.
|
||||
// NOTE: arbiter.relations stores NUMERIC ids; compare against
|
||||
// resolved ids, never string keys.
|
||||
const srcId = arbiter.resolveNodeId(u);
|
||||
const dstId = arbiter.resolveNodeId(o);
|
||||
const rm = (g.relations || []).filter(r => r.src === u && r.rel === rel && r.dst === o);
|
||||
if (rm.length > 0) {
|
||||
for (const e of rm) arbiter.removeRelation(e.src, e.rel, e.dst);
|
||||
} else {
|
||||
for (const r of (arbiter.relations || [])) {
|
||||
if (r.src === srcId && r.rel === rel && r.dst === dstId) arbiter.removeRelation(u, rel, o);
|
||||
}
|
||||
}
|
||||
const after = checkModes(arbiter, u, rel, o).possibility;
|
||||
if (after > 0) fail(`stale grant: ${u} ${rel} ${o} still ${after} after removal`);
|
||||
} else {
|
||||
// Grant a direct edge, verify it shows up immediately.
|
||||
arbiter.addRelation(u, rel, o, { possibility: 0.9 });
|
||||
const after = checkModes(arbiter, u, rel, o).possibility;
|
||||
if (after <= 0) fail(`missing grant: ${u} ${rel} ${o} still 0 after add`);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('mutate', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)),
|
||||
seed: rigor.gen.int(1, 8)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('mutation-freshness', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'complex-graph-mutation-freshness', artifacts: { dir: '', persist: 'never' } });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'mutation-freshness');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `mutation freshness violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: null,
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* rigor/complexity-bench-crucible.test.js — advanced perf & complexity
|
||||
* crucibles over the complex graphs.
|
||||
*
|
||||
* These crucibles use js-rigor's complexity and benchmark verdicts, which
|
||||
* are normally dormant in this suite:
|
||||
*
|
||||
* COMPLEXITY — declares an asymptotic class per action; rigor's
|
||||
* e-process verifies the observed cost signal does not grow faster
|
||||
* than the formula across input sizes (calibrated on the smallest
|
||||
* observations, tested on the rest; Ville's inequality gives
|
||||
* P(false alarm) <= 0.05 when eProcess > 20). A `cost` metric (rule
|
||||
* evaluations) is declared so the verdict is deterministic, not
|
||||
* wall-clock noise.
|
||||
*
|
||||
* BENCHMARK — percentile assertions over auto-collected bench samples
|
||||
* per action (p50/p95/p99 maxMs). Bounds are set ~20-100x above the
|
||||
* measured medians so the crucible catches order-of-magnitude
|
||||
* regressions, not dev-machine noise.
|
||||
*
|
||||
* Declared classes (verified against the engine):
|
||||
* check[direct] O(1) in graph size — hash lookup
|
||||
* check[chain-2hop] O(1) in graph size — fixed-depth traversal
|
||||
* check[union-normal] O(k) in rule count — evaluates every rule
|
||||
* check[union-binary] O(1) in rule count — early exit at threshold
|
||||
* check[ttu] O(1) in graph size — one-hop group lookup
|
||||
*/
|
||||
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, makeHierarchyGraph } from './complex-graphs.js';
|
||||
|
||||
// ── Fixtures: prebuilt graphs at growing sizes, shared across runs ────
|
||||
|
||||
function buildSizedCommunityGraphs() {
|
||||
const sizes = [2, 4, 8, 16];
|
||||
return sizes.map((communities, i) => {
|
||||
const g = makeCommunityGraph(10 + i, { communities, membersPerCommunity: 6, resourcesPerCommunity: 3, subCommunities: 2 });
|
||||
return {
|
||||
size: g.arbiter.relations.length,
|
||||
arbiter: g.arbiter,
|
||||
users: g.users,
|
||||
resources: g.resources,
|
||||
groups: g.groups,
|
||||
subGroups: g.subGroups,
|
||||
relations: g.relations
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSizedHierarchyGraphs() {
|
||||
const sizes = [2, 4, 8];
|
||||
return sizes.map((departments, i) => {
|
||||
const g = makeHierarchyGraph(20 + i, { departments, teamsPerDept: 2, membersPerTeam: 3, resourcesPerTeam: 2 });
|
||||
return { size: g.arbiter.relations.length, arbiter: g.arbiter, users: g.users, resources: g.resources };
|
||||
});
|
||||
}
|
||||
|
||||
const COMMUNITY = buildSizedCommunityGraphs();
|
||||
const HIERARCHY = buildSizedHierarchyGraphs();
|
||||
|
||||
// Pick a query that exercises a non-trivial path on each graph.
|
||||
function communityQuery(graph, seed) {
|
||||
const ownsBySrc = new Map();
|
||||
for (const r of graph.relations) if (r.rel === 'owns') ownsBySrc.set(r.src, r.dst);
|
||||
const memberEdge = graph.relations.find(r => r.rel === 'member' && ownsBySrc.has(r.dst));
|
||||
if (!memberEdge) return { user: graph.users[0], object: graph.resources[0] };
|
||||
return { user: memberEdge.src, object: ownsBySrc.get(memberEdge.dst) };
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────
|
||||
|
||||
// Each action returns { result, cost } where cost = rule evaluations,
|
||||
// making the complexity verdict deterministic.
|
||||
// Deterministic cost signal for graph-size actions: count actual engine
|
||||
// lookups (getDirectRelation / getRelationsFromSrc / getRelationsToDst)
|
||||
// performed per check. A correct engine does a CONSTANT number of lookups
|
||||
// per query regardless of graph size -> O(1) verified deterministically.
|
||||
// A regression to linear scans would grow the count with n and trip the
|
||||
// e-process. No wall-clock jitter.
|
||||
function instrumentLookupCount(arbiter) {
|
||||
const lookup = arbiter.relationManager._lookup;
|
||||
let count = 0;
|
||||
for (const m of ['getDirectRelation', 'getRelationsFromSrc', 'getRelationsToDst']) {
|
||||
const original = lookup[m].bind(lookup);
|
||||
lookup[m] = function (...args) {
|
||||
count++;
|
||||
return original(...args);
|
||||
};
|
||||
}
|
||||
return () => count;
|
||||
}
|
||||
|
||||
const LOOKUP_INSTRUMENTED = new Map();
|
||||
for (let i = 0; i < COMMUNITY.length; i++) LOOKUP_INSTRUMENTED.set('c' + i, instrumentLookupCount(COMMUNITY[i].arbiter));
|
||||
for (let i = 0; i < HIERARCHY.length; i++) LOOKUP_INSTRUMENTED.set('h' + i, instrumentLookupCount(HIERARCHY[i].arbiter));
|
||||
|
||||
function withLookupCost(prefix, fn) {
|
||||
return (args) => {
|
||||
const before = LOOKUP_INSTRUMENTED.get(prefix + args.graphIdx)();
|
||||
const result = fn(args);
|
||||
const cost = LOOKUP_INSTRUMENTED.get(prefix + args.graphIdx)() - before;
|
||||
return { result, cost };
|
||||
};
|
||||
}
|
||||
|
||||
// Each action runs a fixed BATCH of checks per invocation so the timing
|
||||
// signal lands in a measurable range (sub-10us single checks are pure
|
||||
// jitter at the e-process spread check). The batch size is CONSTANT across
|
||||
// graph sizes, so O(1)-in-graph-size still means what it says.
|
||||
const BATCH = 2000;
|
||||
|
||||
const actions = {
|
||||
direct: withLookupCost('c', ({ graphIdx }) => {
|
||||
const g = COMMUNITY[graphIdx];
|
||||
let last;
|
||||
for (let i = 0; i < BATCH; i++) last = g.arbiter.check(g.users[0], 'direct_access', g.resources[0]);
|
||||
return last;
|
||||
}),
|
||||
chain: withLookupCost('h', ({ graphIdx }) => {
|
||||
const g = HIERARCHY[graphIdx];
|
||||
let last;
|
||||
for (let i = 0; i < BATCH; i++) last = g.arbiter.check(g.users[0], 'can_access_org', g.resources[0]);
|
||||
return last;
|
||||
}),
|
||||
ttu: withLookupCost('c', ({ graphIdx }) => {
|
||||
const g = COMMUNITY[graphIdx];
|
||||
const q = communityQuery(g, 1);
|
||||
let last;
|
||||
for (let i = 0; i < BATCH; i++) last = g.arbiter.check(q.user, 'can_read', q.object);
|
||||
return last;
|
||||
}),
|
||||
unionNormal: ({ ruleCount }) => {
|
||||
// Union with ruleCount direct rules; only rule 0 grants, so every rule
|
||||
// is evaluated (no early exit in normal mode) -> cost scales O(k).
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('u:1', 'user');
|
||||
arbiter.addNode('doc:9', 'doc');
|
||||
const rules = [];
|
||||
for (let r = 0; r < ruleCount; r++) {
|
||||
arbiter.setRelationConfig(`rel${r}`, { type: 'direct' });
|
||||
rules.push({ type: 'direct', relation: `rel${r}` });
|
||||
}
|
||||
arbiter.setRelationConfig('can_access', { union: rules });
|
||||
arbiter.addRelation('u:1', 'rel0', 'doc:9', { possibility: 0.9 });
|
||||
const result = arbiter.check('u:1', 'can_access', 'doc:9');
|
||||
return { result, cost: ruleCount };
|
||||
},
|
||||
unionBinary: ({ ruleCount }) => {
|
||||
// Same union, binary mode with threshold 0.8: rule 0 grants 0.9 >= 0.8,
|
||||
// so the early exit fires after one rule -> cost stays O(1) in k.
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('u:1', 'user');
|
||||
arbiter.addNode('doc:9', 'doc');
|
||||
const rules = [];
|
||||
for (let r = 0; r < ruleCount; r++) {
|
||||
arbiter.setRelationConfig(`rel${r}`, { type: 'direct' });
|
||||
rules.push({ type: 'direct', relation: `rel${r}` });
|
||||
}
|
||||
arbiter.setRelationConfig('can_access', { union: rules });
|
||||
arbiter.addRelation('u:1', 'rel0', 'doc:9', { possibility: 0.9 });
|
||||
const result = arbiter.check('u:1', 'can_access', 'doc:9', { binary: true });
|
||||
return { result, cost: 1 };
|
||||
}
|
||||
};
|
||||
|
||||
const metricReaders = (name) => ({
|
||||
n: ({ args }) => {
|
||||
// Metric readers receive the raw generated-args ARRAY (fns get the
|
||||
// spread values); the graph index is the single generated argument.
|
||||
const graphIdx = args[0].graphIdx;
|
||||
if (name === 'direct' || name === 'ttu') return COMMUNITY[graphIdx].size;
|
||||
if (name === 'chain') return HIERARCHY[graphIdx].size;
|
||||
return 0;
|
||||
},
|
||||
// Deterministic cost = engine lookups per check. Wall-clock timing at
|
||||
// sub-ms scale is pure jitter for the spread check; the lookup count is
|
||||
// noise-free and still grows if the engine ever degrades to scans.
|
||||
cost: ({ result }) => result.cost
|
||||
});
|
||||
|
||||
// Anti-vacuity guard: rigor's complexity verdict PASSES when zero
|
||||
// observations were recorded ("no observations" branch). A broken action
|
||||
// (missing import, wrong args shape) silently degrades into that branch —
|
||||
// the crucible goes green while testing nothing. Every verdict below must
|
||||
// therefore carry a real, metric-driven signal.
|
||||
function assertRealVerdict(v, expectedCostSource = 'metric') {
|
||||
assert.equal(v.passed, true, `${v.name} (${v.formula}): eProcess=${v.eProcess} violated=${v.trendViolated || v.spreadExceeded}`);
|
||||
assert.ok(v.observationCount >= 50, `${v.name}: only ${v.observationCount} observations — vacuous verdict`);
|
||||
assert.equal(v.costSource, expectedCostSource, `${v.name}: expected costSource '${expectedCostSource}', got '${v.costSource}'`);
|
||||
assert.equal(v.calibrated, true, `${v.name}: not calibrated — verdict not meaningful`);
|
||||
}
|
||||
|
||||
describe('Complexity & benchmark crucibles (rigor)', () => {
|
||||
it('COMPLEXITY: direct/chain/ttu lookups are O(1) in graph size', async () => {
|
||||
const spec = [];
|
||||
for (const name of ['direct', 'chain', 'ttu']) {
|
||||
const isGraph = name !== 'chain';
|
||||
spec.push(rigor.fn(name, actions[name], rigor.args(
|
||||
rigor.gen.object({
|
||||
graphIdx: rigor.gen.int(0, (isGraph ? COMMUNITY : HIERARCHY).length - 1)
|
||||
})
|
||||
), rigor.metrics(metricReaders(name))));
|
||||
}
|
||||
const report = await rigor.campaign(
|
||||
spec,
|
||||
rigor.crucible([
|
||||
rigor.complexity('direct', 'O(1)'),
|
||||
rigor.complexity('chain', 'O(1)'),
|
||||
rigor.complexity('ttu', 'O(1)')
|
||||
])
|
||||
).run({ effort: 600, seed: 'complexity-graph-size', artifacts: { dir: '', persist: 'never' } });
|
||||
|
||||
for (const v of report.crucibleVerdict.complexity) {
|
||||
assertRealVerdict(v);
|
||||
}
|
||||
});
|
||||
|
||||
it('COMPLEXITY: union is O(k) in normal mode, O(1) in binary (early exit)', async () => {
|
||||
const spec = [];
|
||||
for (const name of ['unionNormal', 'unionBinary']) {
|
||||
spec.push(rigor.fn(name, actions[name], rigor.args(
|
||||
rigor.gen.object({
|
||||
ruleCount: rigor.gen.oneOf([2, 4, 8, 16, 32, 64])
|
||||
})
|
||||
), rigor.metrics({
|
||||
k: ({ args }) => args[0].ruleCount,
|
||||
cost: ({ result }) => result.cost
|
||||
})));
|
||||
}
|
||||
const report = await rigor.campaign(
|
||||
spec,
|
||||
rigor.crucible([
|
||||
rigor.complexity('unionNormal', 'O(k)'),
|
||||
rigor.complexity('unionBinary', 'O(1)')
|
||||
])
|
||||
).run({ effort: 600, seed: 'complexity-rule-count', artifacts: { dir: '', persist: 'never' } });
|
||||
|
||||
for (const v of report.crucibleVerdict.complexity) {
|
||||
assertRealVerdict(v);
|
||||
}
|
||||
});
|
||||
|
||||
it('COMPLEXITY: snapshot byte size is O(n) in graph size', async () => {
|
||||
// Serialized snapshot size grows exactly linearly with graph size: a
|
||||
// superlinear regression (re-scanning, duplicated payloads) trips the
|
||||
// e-process. Byte size is deterministic — no wall-clock jitter. (The
|
||||
// wall-clock latency of snapshot build/restore is covered by the
|
||||
// benchmark percentiles below; sub-ms timings are pure jitter for the
|
||||
// complexity spread check, as seen with the timing-based verdicts.)
|
||||
const snapshotActions = {
|
||||
buildBytes: ({ graphIdx }) => {
|
||||
const g = COMMUNITY[graphIdx];
|
||||
g.arbiter.enableCondensedSnapshot();
|
||||
const buf = g.arbiter.toSnapshotBinary();
|
||||
return { result: null, cost: buf.byteLength };
|
||||
},
|
||||
restoreBytes: ({ graphIdx }) => {
|
||||
const g = COMMUNITY[graphIdx];
|
||||
g.arbiter.enableCondensedSnapshot();
|
||||
const buf = g.arbiter.toSnapshotBinary();
|
||||
const restored = Arbiter.fromSnapshotBinary(buf);
|
||||
const roundTrip = restored.toSnapshotBinary();
|
||||
return { result: null, cost: roundTrip.byteLength };
|
||||
}
|
||||
};
|
||||
const spec = [];
|
||||
for (const name of ['buildBytes', 'restoreBytes']) {
|
||||
spec.push(rigor.fn(name, snapshotActions[name], rigor.args(
|
||||
rigor.gen.object({
|
||||
graphIdx: rigor.gen.int(0, COMMUNITY.length - 1)
|
||||
})
|
||||
), rigor.metrics({
|
||||
n: ({ args }) => COMMUNITY[args[0].graphIdx].size,
|
||||
cost: ({ result }) => result.cost
|
||||
})));
|
||||
}
|
||||
const report = await rigor.campaign(
|
||||
spec,
|
||||
rigor.crucible([
|
||||
rigor.complexity('buildBytes', 'O(n)'),
|
||||
rigor.complexity('restoreBytes', 'O(n)')
|
||||
])
|
||||
).run({ effort: 500, seed: 'complexity-snapshot', artifacts: { dir: '', persist: 'never' } });
|
||||
|
||||
for (const v of report.crucibleVerdict.complexity) {
|
||||
assertRealVerdict(v, 'metric');
|
||||
}
|
||||
});
|
||||
|
||||
it('BENCHMARK: complex queries stay under p95 latency budgets', async () => {
|
||||
// Benchmark actions run a SINGLE check per invocation (not the BATCH
|
||||
// used by the complexity actions) so the sampled latency is the real
|
||||
// per-query latency, and the percentile assertions are meaningful.
|
||||
const benchActions = {
|
||||
direct: ({ graphIdx }) => COMMUNITY[graphIdx].arbiter.check(COMMUNITY[graphIdx].users[0], 'direct_access', COMMUNITY[graphIdx].resources[0]),
|
||||
chain: ({ graphIdx }) => HIERARCHY[graphIdx].arbiter.check(HIERARCHY[graphIdx].users[0], 'can_access_org', HIERARCHY[graphIdx].resources[0]),
|
||||
ttu: ({ graphIdx }) => {
|
||||
const g = COMMUNITY[graphIdx];
|
||||
const q = communityQuery(g, 1);
|
||||
return g.arbiter.check(q.user, 'can_read', q.object);
|
||||
}
|
||||
};
|
||||
const spec = [];
|
||||
for (const name of ['direct', 'chain', 'ttu']) {
|
||||
const isGraph = name !== 'chain';
|
||||
spec.push(rigor.fn(name, benchActions[name], rigor.args(
|
||||
rigor.gen.object({
|
||||
graphIdx: rigor.gen.int(0, (isGraph ? COMMUNITY : HIERARCHY).length - 1)
|
||||
})
|
||||
), rigor.metrics(metricReaders(name))));
|
||||
}
|
||||
const report = await rigor.campaign(
|
||||
spec,
|
||||
rigor.crucible([
|
||||
rigor.benchmark('direct', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } }),
|
||||
rigor.benchmark('chain', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } }),
|
||||
rigor.benchmark('ttu', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } })
|
||||
])
|
||||
).run({ effort: 600, seed: 'bench-complex-queries', artifacts: { dir: '', persist: 'never' } });
|
||||
|
||||
for (const v of report.crucibleVerdict.benchmarks) {
|
||||
assert.equal(v.passed, true, `${v.name}: p50/p95 over budget`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ describe('ComputedRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-passthrough', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'computed-rule-possibility-passthrough', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-passthrough');
|
||||
@@ -108,7 +108,7 @@ describe('ComputedRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('reason-default', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'computed-rule-reason-default', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'reason-default');
|
||||
@@ -145,7 +145,7 @@ describe('ComputedRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('reason-passthrough', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'computed-rule-reason-passthrough', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'reason-passthrough');
|
||||
@@ -188,7 +188,7 @@ describe('ComputedRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('meta-contract', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'computed-rule-meta-contract', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'meta-contract');
|
||||
@@ -237,7 +237,7 @@ describe('ComputedRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('collected-values-passthrough', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'computed-rule-collected-values', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'collected-values-passthrough');
|
||||
@@ -273,7 +273,7 @@ describe('ComputedRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-fallback-zero', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'computed-rule-possibility-fallback', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-fallback-zero');
|
||||
@@ -311,7 +311,7 @@ describe('ComputedRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('options-passthrough', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'computed-rule-options-passthrough', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'options-passthrough');
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-relation-fallback', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-no-relation', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-relation-fallback');
|
||||
@@ -138,7 +138,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('relation-strength-preserved', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-strength', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'relation-strength-preserved');
|
||||
@@ -186,7 +186,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('reverse-routing', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-reverse', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'reverse-routing');
|
||||
@@ -236,7 +236,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('fastPath-early-exit', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-fast-path', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'fastPath-early-exit');
|
||||
@@ -278,7 +278,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('collectValues-disabled', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-collect-off', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'collectValues-disabled');
|
||||
@@ -332,7 +332,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('collectValues-default', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-collect-on', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'collectValues-default');
|
||||
@@ -393,7 +393,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('relation-precedence', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-precedence', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'relation-precedence');
|
||||
@@ -449,7 +449,7 @@ describe('DirectRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('result-shape-stable', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'direct-rule-shape', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'result-shape-stable');
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('direct-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-direct', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'direct-emission');
|
||||
@@ -128,7 +128,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('tus-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-tus', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'tus-emission');
|
||||
@@ -160,7 +160,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('parent-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-parent', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parent-emission');
|
||||
@@ -197,7 +197,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('chain-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-chain', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-emission');
|
||||
@@ -246,7 +246,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi_hop-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-multi-hop', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi_hop-emission');
|
||||
@@ -288,7 +288,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('logical-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-logical', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'logical-emission');
|
||||
@@ -323,7 +323,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('relational-comparator-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-relational-comparator', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'relational-comparator-emission');
|
||||
@@ -371,7 +371,7 @@ describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('mapping-consistency', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'dsl-compiler-mapping-consistency', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'mapping-consistency');
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('LogicalOperators evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('union-max', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'logical-operators-union-max', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'union-max');
|
||||
@@ -104,7 +104,7 @@ describe('LogicalOperators evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('intersection-min', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'logical-operators-intersection-min', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'intersection-min');
|
||||
@@ -138,7 +138,7 @@ describe('LogicalOperators evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('union-mean', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'logical-operators-union-mean', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'union-mean');
|
||||
@@ -181,7 +181,7 @@ describe('LogicalOperators evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('exclusion', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'logical-operators-exclusion', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'exclusion');
|
||||
@@ -214,7 +214,7 @@ describe('LogicalOperators evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'logical-operators-possibility-bounded', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
|
||||
@@ -251,7 +251,7 @@ describe('LogicalOperators evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('collected-values-concat', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'logical-operators-collected-values', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'collected-values-concat');
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('MultiHopRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('missing-relation', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'multi-hop-missing-relation', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'missing-relation');
|
||||
@@ -88,7 +88,7 @@ describe('MultiHopRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'multi-hop-possibility-bounded', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
|
||||
@@ -126,7 +126,7 @@ describe('MultiHopRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('single-path-strength', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'multi-hop-single-path', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'single-path-strength');
|
||||
@@ -161,7 +161,7 @@ describe('MultiHopRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-path', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'multi-hop-no-path', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-path');
|
||||
@@ -208,7 +208,7 @@ describe('MultiHopRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-hop-finds-path', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'multi-hop-two-hop', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-hop-finds-path');
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('NodeManager index invariants (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('inverse-maps', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'node-manager-inverse-maps', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'inverse-maps');
|
||||
@@ -133,7 +133,7 @@ describe('NodeManager index invariants (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('addNode-idempotent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'node-manager-add-idempotent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addNode-idempotent');
|
||||
@@ -186,7 +186,7 @@ describe('NodeManager index invariants (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('size-invariant', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'node-manager-size-invariant', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'size-invariant');
|
||||
@@ -249,7 +249,7 @@ describe('NodeManager index invariants (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('monotonic-ids', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'node-manager-monotonic-ids', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'monotonic-ids');
|
||||
@@ -295,7 +295,7 @@ describe('NodeManager index invariants (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('removeNode-cleanup', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'node-manager-remove-cleanup', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'removeNode-cleanup');
|
||||
@@ -335,7 +335,7 @@ describe('NodeManager index invariants (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('clearNodes-resets', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'node-manager-clear-resets', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'clearNodes-resets');
|
||||
@@ -388,7 +388,7 @@ describe('NodeManager index invariants (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('updateNodeData-merges', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'node-manager-update-merge', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'updateNodeData-merges');
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('ParentRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-parents', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'parent-rule-no-parents', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-parents');
|
||||
@@ -131,7 +131,7 @@ describe('ParentRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('one-parent-strength', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'parent-rule-one-parent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'one-parent-strength');
|
||||
@@ -175,7 +175,7 @@ describe('ParentRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('threshold-cutoff', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'parent-rule-threshold', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'threshold-cutoff');
|
||||
@@ -212,7 +212,7 @@ describe('ParentRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('cycle-detection', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'parent-rule-cycle', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cycle-detection');
|
||||
@@ -258,7 +258,7 @@ describe('ParentRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-parent-max', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'parent-rule-multi-parent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-parent-max');
|
||||
@@ -297,7 +297,7 @@ describe('ParentRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('parent-relation-default', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'parent-rule-default-relation', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parent-relation-default');
|
||||
@@ -341,7 +341,7 @@ describe('ParentRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'parent-rule-possibility-bounded', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('QualitativeRelationalComparatorRule._getQualitativeScale (rigor)', ()
|
||||
rigor.crucible([
|
||||
rigor.invariant('known-scales', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-known-scales', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'known-scales');
|
||||
@@ -125,7 +125,7 @@ describe('QualitativeRelationalComparatorRule._getQualitativeScale (rigor)', ()
|
||||
rigor.crucible([
|
||||
rigor.invariant('fallback', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-fallback', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'fallback');
|
||||
@@ -162,7 +162,7 @@ describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigo
|
||||
rigor.crucible([
|
||||
rigor.invariant('stable-identity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-stable-identity', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'stable-identity');
|
||||
@@ -197,7 +197,7 @@ describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigo
|
||||
rigor.crucible([
|
||||
rigor.invariant('zero-periods', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-zero-periods', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'zero-periods');
|
||||
@@ -238,7 +238,7 @@ describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigo
|
||||
rigor.crucible([
|
||||
rigor.invariant('down-monotone', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-down-monotone', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'down-monotone');
|
||||
@@ -278,7 +278,7 @@ describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigo
|
||||
rigor.crucible([
|
||||
rigor.invariant('up-monotone', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-up-monotone', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'up-monotone');
|
||||
@@ -314,7 +314,7 @@ describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigo
|
||||
rigor.crucible([
|
||||
rigor.invariant('result-in-scale', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-result-in-scale', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'result-in-scale');
|
||||
@@ -351,7 +351,7 @@ describe('QualitativeRelationalComparatorRule._createQualitativeInterval (rigor)
|
||||
rigor.crucible([
|
||||
rigor.invariant('lower-le-upper', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-lower-le-upper', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'lower-le-upper');
|
||||
@@ -389,7 +389,7 @@ describe('QualitativeRelationalComparatorRule._createQualitativeInterval (rigor)
|
||||
rigor.crucible([
|
||||
rigor.invariant('point-contained', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-point-contained', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'point-contained');
|
||||
@@ -425,7 +425,7 @@ describe('QualitativeRelationalComparatorRule._createQualitativeInterval (rigor)
|
||||
rigor.crucible([
|
||||
rigor.invariant('bounds-in-scale', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-bounds-in-scale', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'bounds-in-scale');
|
||||
@@ -459,7 +459,7 @@ describe('QualitativeRelationalComparatorRule._createQualitativeInterval (rigor)
|
||||
rigor.crucible([
|
||||
rigor.invariant('zero-blur', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-zero-blur', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'zero-blur');
|
||||
@@ -490,7 +490,7 @@ describe('QualitativeRelationalComparatorRule._calculatePossibilityLossSteps (ri
|
||||
rigor.crucible([
|
||||
rigor.invariant('loss-is-zero', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-loss-is-zero', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'loss-is-zero');
|
||||
@@ -529,7 +529,7 @@ describe('QualitativeRelationalComparatorRule._calculatePossibilityLossSteps (ri
|
||||
rigor.crucible([
|
||||
rigor.invariant('loss-symmetric', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'qualitative-loss-symmetric', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'loss-symmetric');
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('RelationManager.addRelation/removeRelation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('add-and-get', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'relation-manager-add-get', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-and-get');
|
||||
@@ -127,7 +127,7 @@ describe('RelationManager.addRelation/removeRelation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('addRelation-idempotent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'relation-manager-add-idempotent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-idempotent');
|
||||
@@ -195,7 +195,7 @@ describe('RelationManager.addRelation/removeRelation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('remove-clears-indexes', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'relation-manager-remove-cleanup', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remove-clears-indexes');
|
||||
@@ -284,7 +284,7 @@ describe('RelationManager.addRelation/removeRelation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('index-coherence', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'relation-manager-index-coherence', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'index-coherence');
|
||||
@@ -364,7 +364,7 @@ describe('RelationManager.addRelation/removeRelation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('add-remove-roundtrip', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'relation-manager-add-remove-roundtrip', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-remove-roundtrip');
|
||||
@@ -396,7 +396,7 @@ describe('RelationManager.addRelation/removeRelation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('getDirectRelation-unknown', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'relation-manager-unknown-tuple', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getDirectRelation-unknown');
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('qualitative-wins', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-router-qualitative-wins', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'qualitative-wins');
|
||||
@@ -116,7 +116,7 @@ describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('scaleName-triggers', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-router-scale-name', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'scaleName-triggers');
|
||||
@@ -156,7 +156,7 @@ describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('decay-blur-triggers', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-router-decay-blur', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'decay-blur-triggers');
|
||||
@@ -190,7 +190,7 @@ describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('marginSteps-correct', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-router-margin-steps', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'marginSteps-correct');
|
||||
@@ -231,7 +231,7 @@ describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('plain-numeric', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-router-plain-numeric', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'plain-numeric');
|
||||
@@ -276,7 +276,7 @@ describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('getImplType-consistent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-router-get-impl-type', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getImplType-consistent');
|
||||
@@ -322,7 +322,7 @@ describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('hasValidProperty', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1000 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-router-has-valid-property', effort: 1000 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'hasValidProperty');
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('RelationalComparatorRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('left-gt-right', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-rule-left-gt-right', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'left-gt-right');
|
||||
@@ -111,7 +111,7 @@ describe('RelationalComparatorRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('left-lt-right', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-rule-left-lt-right', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'left-lt-right');
|
||||
@@ -146,7 +146,7 @@ describe('RelationalComparatorRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-rule-possibility-bounded', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
|
||||
@@ -181,7 +181,7 @@ describe('RelationalComparatorRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('result-shape-stable', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-rule-shape-stable', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'result-shape-stable');
|
||||
@@ -220,7 +220,7 @@ describe('RelationalComparatorRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('determinism', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'rc-rule-determinism', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'determinism');
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('js-rigor smoke', () => {
|
||||
rigor.invariant('non-negative', ({ actual }) => actual >= 0),
|
||||
rigor.invariant('idempotent', ({ actual, fn }) => fn(actual) === actual)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'smoke-minimal-campaign', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
assert.ok(report, 'campaign returns a report');
|
||||
assert.equal(typeof report.toTAP, 'function', 'report has toTAP()');
|
||||
@@ -46,7 +46,7 @@ describe('js-rigor smoke', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('equals-one', ({ actual }) => actual === 1)
|
||||
])
|
||||
).run({ effort: 50 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'smoke-violated-invariant', effort: 50 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
// Report shape varies — log it for debugging.
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* rigor/ttl-contract.test.js — pins the TTL contract.
|
||||
*
|
||||
* TTL is a VALUE-FRESHNESS mechanism, not an access-expiry mechanism:
|
||||
*
|
||||
* - Possibility-based grants (direct allow/deny, union, chain traversal)
|
||||
* are TIMELESS. A relation edge grants regardless of how old its
|
||||
* changed_last_at is. setTTL('can_read', ...) does NOT expire access.
|
||||
* - TTL gates VALUE EXTRACTION: comparator/chain/multi-hop paths read
|
||||
* values through valueManager.getBlurredValue, which returns a null
|
||||
* interval once age > TTL. An expired value denies the comparator
|
||||
* decision and drops the value from collected values.
|
||||
*
|
||||
* This file pins BOTH halves so a future change can never silently flip
|
||||
* one without breaking the contract test.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
|
||||
const T0 = 1_000_000_000_000;
|
||||
const TTL = 60_000;
|
||||
|
||||
function buildDirectEngine() {
|
||||
const a = new Arbiter();
|
||||
a.addNode('u:1', 'user');
|
||||
a.addNode('doc:9', 'doc');
|
||||
a.setRelationConfig('can_read', { type: 'direct' });
|
||||
a.valueManager.setTTL('can_read', TTL);
|
||||
a.addRelation('u:1', 'can_read', 'doc:9', { possibility: 0.9, value: 42, changed_last_at: T0 });
|
||||
return a;
|
||||
}
|
||||
|
||||
function buildComparatorEngine() {
|
||||
const a = new Arbiter();
|
||||
a.addNode('user:alice', 'user');
|
||||
a.addNode('doc:9', 'doc');
|
||||
a.setRelationConfig('has_balance', { type: 'direct' });
|
||||
a.setRelationConfig('has_price', { type: 'direct' });
|
||||
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', TTL);
|
||||
a.valueManager.setTTL('has_price', TTL);
|
||||
a.addRelation('user:alice', 'has_balance', 'doc:9', { value: 100, possibility: 1.0, changed_last_at: T0 });
|
||||
a.addRelation('doc:9', 'has_price', 'doc:9', { value: 50, possibility: 1.0, changed_last_at: T0 });
|
||||
return a;
|
||||
}
|
||||
|
||||
describe('TTL contract (rigor)', () => {
|
||||
it('CONTRACT: direct possibility grants are timeless (TTL does not expire access)', () => {
|
||||
const a = buildDirectEngine();
|
||||
const fresh = a.check('u:1', 'can_read', 'doc:9', { now: T0 });
|
||||
assert.equal(fresh.possibility, 0.9);
|
||||
const expired = a.check('u:1', 'can_read', 'doc:9', { now: T0 + TTL + 1 });
|
||||
assert.equal(expired.possibility, 0.9, 'direct grant must survive value TTL expiry');
|
||||
assert.equal(expired.reason, 'direct_match');
|
||||
// Binary mode agrees.
|
||||
const binary = a.check('u:1', 'can_read', 'doc:9', { now: T0 + TTL + 1, binary: true });
|
||||
assert.equal(binary.possibility > 0, true);
|
||||
});
|
||||
|
||||
it('CONTRACT: TTL gates value extraction — expired values deny the comparator', () => {
|
||||
const a = buildComparatorEngine();
|
||||
const fresh = a.check('user:alice', 'premium', 'doc:9', { now: T0 });
|
||||
assert.equal(fresh.possibility, 1, 'fresh values grant');
|
||||
const withinTtl = a.check('user:alice', 'premium', 'doc:9', { now: T0 + TTL - 1 });
|
||||
assert.equal(withinTtl.possibility, 1, 'still fresh at TTL-1');
|
||||
const expired = a.check('user:alice', 'premium', 'doc:9', { now: T0 + TTL + 1 });
|
||||
assert.equal(expired.possibility, 0, 'expired values must deny');
|
||||
});
|
||||
|
||||
it('CONTRACT: collected values disappear once expired (value freshness, not access)', () => {
|
||||
const a = buildDirectEngine();
|
||||
const fresh = a.check('u:1', 'can_read', 'doc:9', { now: T0, collectValues: true });
|
||||
assert.ok(fresh.collectedValues && fresh.collectedValues.length === 1, 'fresh value collected');
|
||||
const expired = a.check('u:1', 'can_read', 'doc:9', { now: T0 + TTL + 1, collectValues: true });
|
||||
assert.ok(!expired.collectedValues || expired.collectedValues.length === 0, 'expired value not collected');
|
||||
});
|
||||
|
||||
it('CONTRACT: the caller clock (now) drives expiry — the wall clock does not', () => {
|
||||
const a = buildComparatorEngine();
|
||||
// Pinned far in the past: expired even though wall clock is "now".
|
||||
const past = a.check('user:alice', 'premium', 'doc:9', { now: T0 + TTL + 1 });
|
||||
assert.equal(past.possibility, 0);
|
||||
// Pinned at write time: fresh even if the wall clock has moved on.
|
||||
const atWrite = a.check('user:alice', 'premium', 'doc:9', { now: T0 });
|
||||
assert.equal(atWrite.possibility, 1);
|
||||
});
|
||||
|
||||
it('CONTRACT: collected values carry the standard shape and honor the caller clock', () => {
|
||||
// Value consumers (comparators, chains) rely on collected values being
|
||||
// self-describing: value, possibility, path, source, and a timestamp.
|
||||
// The direct fast path must emit the same shape as the rule paths, and
|
||||
// the timestamp must honor the pinned clock — never the wall clock.
|
||||
const a = new Arbiter();
|
||||
a.addNode('u:1', 'user');
|
||||
a.addNode('doc:9', 'doc');
|
||||
a.setRelationConfig('can_read', { type: 'direct' });
|
||||
const T0 = 1_000_000_000_000;
|
||||
a.addRelation('u:1', 'can_read', 'doc:9', { possibility: 0.7, value: 42, changed_last_at: T0, reliability: 0.8 });
|
||||
const cv = a.check('u:1', 'can_read', 'doc:9', { now: T0, collectValues: true }).collectedValues[0];
|
||||
assert.equal(cv.value, 42);
|
||||
assert.equal(cv.possibility, 0.7);
|
||||
assert.ok(Array.isArray(cv.path) && cv.path[0] === 'u:1' && cv.path[1] === 'doc:9');
|
||||
assert.equal(cv.source.entityKey, 'u:1');
|
||||
assert.equal(cv.source.relation, 'can_read');
|
||||
assert.equal(cv.source.step, 0);
|
||||
assert.equal(cv.metadata.timestamp, T0, 'timestamp must honor changed_last_at under a pinned clock');
|
||||
assert.equal(cv.metadata.reliability, 0.8);
|
||||
});
|
||||
|
||||
it('CONTRACT: value-carrying direct results are not served stale from the decision cache', () => {
|
||||
// The direct-check cache must never serve a result whose collected
|
||||
// values were captured before expiry: values are TTL-gated evidence.
|
||||
// (Regression: the cache previously bundled collectedValues with the
|
||||
// timeless decision and served stale values to later unpinned callers.)
|
||||
const a = new Arbiter();
|
||||
a.addNode('u:1', 'user');
|
||||
a.addNode('doc:9', 'doc');
|
||||
a.setRelationConfig('can_read', { type: 'direct' });
|
||||
// Unpinned callers use the wall clock, so write at wall time with a
|
||||
// short TTL to make expiry observable without a long sleep.
|
||||
const wallT0 = Date.now();
|
||||
a.valueManager.setTTL('can_read', 200);
|
||||
a.addRelation('u:1', 'can_read', 'doc:9', { possibility: 0.9, value: 42, changed_last_at: wallT0 });
|
||||
const warm = a.check('u:1', 'can_read', 'doc:9', { collectValues: true });
|
||||
assert.equal(warm.collectedValues.length, 1, 'fresh value collected on warm');
|
||||
// Advance the wall clock past the value TTL without any mutation.
|
||||
return new Promise(r => setTimeout(r, 250)).then(() => {
|
||||
const after = a.check('u:1', 'can_read', 'doc:9', { collectValues: true });
|
||||
assert.ok(!after.collectedValues || after.collectedValues.length === 0, 'stale value must not be served from cache');
|
||||
// The timeless decision still grants.
|
||||
assert.equal(after.possibility, 0.9);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -194,7 +194,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-tuple-max', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'ttu-multi-tuple-max', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-tuple-max');
|
||||
@@ -232,7 +232,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'ttu-possibility-bounded', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded');
|
||||
@@ -292,7 +292,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('early-exit', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'ttu-early-exit', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'early-exit');
|
||||
@@ -342,7 +342,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => {
|
||||
rigor.crucible([
|
||||
rigor.invariant('cycle-detection', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
).run({ seed: 'ttu-cycle-detection', effort: 200 , artifacts: { dir: '', persist: 'never' }});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cycle-detection');
|
||||
|
||||
Reference in New Issue
Block a user