12 Commits

Author SHA1 Message Date
John Dvorak 3444a8b6aa release: bump version to 1.0.1
CI / benchmark (push) Successful in 27s
CI / test (push) Successful in 5m23s
CI / publish (push) Successful in 10s
2026-08-02 18:46:22 -07:00
John Dvorak 440230b2c5 fix: caller clock everywhere — value TTL, decay, qualitative decay, cache TTL
CI / test (push) Successful in 5m52s
CI / benchmark (push) Successful in 22s
CI / publish (push) Has been skipped
The principle: time is caller-provided (options.now / partialGraph.now);
the wall clock is only the fallback for unpinned callers, never a hidden
decision input. Remaining clock leaks:

- getBlurredValue gained an optional now param threaded to _isValueExpired;
  ChainRule (2 sites), MultiHopRule (2 sites), RelationManager (3 sites)
  now pass the caller clock. Previously a pinned-clock caller's chain/
  multi-hop value TTL used the WALL clock (wall in 2026, pinned T0 in
  2001 -> values wrongly expired).
- MultiHopRule's TTL gate used valueFilters.ttl || 24h instead of the
  valueManager's per-relation TTL (inconsistent with chain/comparator);
  now valueManager.getTTL is the authority, valueFilters.ttl the override.
- QualitativeRelationalComparatorRule decay (_calculatePeriodsElapsed)
  and value timestamps used the wall clock, so qualitative possibility
  decay ignored the pinned clock; now threaded through _evaluateOperand.
- ValueManager decay internals (getDecayedRelation, _calculateSeparated
  Decay, _calculateBlurredValue) accept a now param (background worker
  still passes none -> wall clock is correct there).
- PartialGraphContext._addChallengeProof/_addRelation used Date.now()
  instead of the context's own this.now (the partial graph's time).
- Arbiter gained an injectable clock (options.clock) driving unpinned
  cache-entry freshness in DecisionCache, RuleEvaluator, ChainRule, and
  RelationalComparatorRule; DecisionCache explicit clock still wins.
- Collected-value timestamps in DirectRule and RelationalComparatorRule
  honor the caller clock.

Pinned-clock chain probe: values fresh at T0, expired at T0+61s, with the
wall clock in 2026. Rigor 251/251, full suite 853/791/0.
2026-08-02 17:50:24 -07:00
John Dvorak f530532e48 fix: standard collected-value shape on the direct fast path + caller-clock timestamps
Two remaining clock/shape inconsistencies from the audit:

1. The direct fast path emitted a bare collected-value object
   {value, source, relation, userKey, objectKey} — no possibility, no
   path, no metadata. Value consumers (comparators, chains) rely on the
   self-describing shape the rule paths emit. The fast path now emits
   the standard shape (value/possibility/path/source/metadata), matching
   DirectRule's existing _createCollectedValue contract.

2. Collected-value timestamps fell back to the WALL clock (Date.now())
   even for pinned-clock callers in BaseRule._createCollectedValue,
   ChainRule, and TupleToUsersetRule. The metadata timestamp now honors
   options.now when pinned (changed_last_at wins, then pinned now, then
   wall clock). ValueContext's collectedAt remains metadata-only.

Pinned by ttl-contract.test.js: the fast-path collected value carries
the full shape and its timestamp honors the pinned clock. Rigor 251/251,
full suite 853/791/0.
2026-08-02 17:07:20 -07:00
John Dvorak f9d4fbe2f0 test: seed all 98 unseeded rigor campaigns
The rigor suite had 98 campaigns drawing a random seed per process,
making the suite nondeterministic on a deterministic engine (one real
~1/15 flake already caught in tuple-to-userset-rule.test.js). Every
campaign now carries a fixed, per-test-unique seed:

- 16 files touched, 100 run() calls, all 100 seeded (2 were already done)
- seed names follow <file>-<purpose> kebab-case, unique within each file
- no other content changed (effort, invariants, assertions untouched)

Verification: full rigor suite green across 11 consecutive runs, full
suite 852/790/0. Determinism is now structural, not incidental.
2026-08-02 16:39:36 -07:00
John Dvorak 342c29f38b fix: pin TTL contract, gate caches on caller clock, stop caching stale values
Three related findings from the nervous-item audit:

1. TTL contract pinned (ttl-contract.test.js + README): TTL is a
   VALUE-FRESHNESS gate, not an access-expiry mechanism. Direct grants
   are timeless; expired values deny comparators and drop from collected
   values. The direct fast path collected values WITHOUT the TTL gate
   (comparators skipped expired relations, the direct path did not) —
   now gated identically.

2. ChainRule cache served pinned-clock callers (ChainRule.js): a chain
   result captured at one time (with then-fresh values) was served to
   callers asking about another time. The chain cache now bypasses
   reads AND writes when options.now is pinned, matching the rule-result
   cache contract.

3. Decision caches bundled stale values (AuthorizationChecker.js):
   the direct-check cache stored collectedValues alongside the timeless
   decision; an unpinned caller past wall-clock expiry got the stale
   value. Value-carrying results are now never cached (the decision is
   timeless, the values are not). The rule-result cache is unchanged —
   it serves snapshots under explicit write-invalidation (its own
   contract, asserted by cache-invalidation tests).

Rigor 250/250, full suite 852/790/0.
2026-08-02 16:07:55 -07:00
John Dvorak 8141930764 rigor: six complex-graph crucibles + seed the TTU campaigns
Extends the complex-graph suite to the remaining uncovered surfaces:

- complex-graph-ttl-crucible: value-TTL expiry over community/scale-free
  graphs (pinned changed_last_at writes, {now} reads, mirror freshness
  rule), mutation-with-time binary parity, snapshot round-trip preserves
  the TTL gate. Notable find: snapshot restore resets changed_last_at to
  access time (RelationSnapshotAccess.js:91), so TTL assertions compare
  each engine against its own effective write clock.
- complex-graph-overlay-crucible: partial-graph overlay over complex
  graphs. Key discovery: pre-built PartialGraphContext must be passed as
  partialGraphContext (partialGraph is a raw spec re-ingested at
  ArbiterChecks.js:15); overlay rides the direct relations a policy
  consumes (TTU-derived can_read ignores it, verified by probe).
- complex-graph-values-crucible: relational-comparator over value-carrying
  relations with pinned clocks — comparator parity, value-mutation flips
  the decision immediately, TTL expiry on the comparator denies.
- complex-graph-reachability-crucible: PLTC reachability vs ground-truth
  BFS on scale-free/community graphs — verdict parity, fast-fail
  soundness (no false positives), null-defer contract honored.
- complex-graph-batch-crucible: addRelationsBatch vs sequential build
  parity, mutation parity across both, cache-freshness after mutation.
- complex-graph-quantization-crucible: 16-bit quantization band over real
  possibility spreads, allow/deny agreement outside the band, snapshot-of-
  snapshot semantic identity.

Also seeds the two unseeded campaigns in tuple-to-userset-rule.test.js
(flagged flake ~1/15 — nondeterministic runs on a deterministic engine).

Rigor 245/245, full suite 847/785/0.
2026-08-02 15:06:50 -07:00
John Dvorak 1a2a6fc22e rigor: anti-vacuity guards, snapshot O(n), mutation crucible
Three improvements over the complex-graph crucibles:

1. Anti-vacuity guards (assertRealVerdict): rigor's complexity verdict
   PASSES on zero observations — a broken action (missing import, wrong
   args shape) silently goes green. Every complexity verdict now asserts
   observationCount >= 50, costSource == expected, and calibrated ==
   true, so a vacuous verdict is a test failure.

2. Snapshot complexity: serialized snapshot BYTE SIZE is O(n) in graph
   size, verified deterministically (build and restore round-trip).
   Wall-clock timing at sub-ms scale is pure jitter for the e-process
   spread check (verified empirically — buildTime O(n) failed on spread
   while buildBytes passed); latency stays covered by benchmark
   percentiles.

3. complex-graph-mutation-crucible: MUTATION-FRESHNESS — random edge
   removals/additions on community + scale-free graphs, normal/binary
   agreement re-checked after EVERY mutation, stale grants and missing
   fresh grants are failures. Two real findings during bring-up, both
   fixture bugs rather than engine bugs:
   - the scale-free generator returned a raw edge COUNT as 'relations'
     while other generators returned edge arrays (now null, consistent
     with dense-adversarial; the crucible walks the arbiter's store)
   - arbiter.relations stores NUMERIC ids, so removals must resolve
     string keys to ids before matching (string-key comparison silently
     no-oped, looking like a stale grant)

Snapshot read-only semantics documented in the crucible: enableCondensed-
Snapshot flips the engine to read-only permanently, so mutation crucibles
exercise the writable path, and frozen-snapshot properties stay with the
snapshot-parity suites. Rigor 239/239, full suite 841/779/0.
2026-08-02 14:33:31 -07:00
John Dvorak f0aefe4ba6 rigor: complexity-class + benchmark crucibles over complex graphs
Activates js-rigor's normally-dormant complexity and benchmark verdicts
against the complex-graph generators:

COMPLEXITY (e-process verified, deterministic cost signals):
- direct/chain/ttu lookups declared O(1) in graph size — verified with a
  deterministic engine-lookup counter as the cost metric (wall-clock at
  sub-ms scale is pure jitter for the spread check); a regression to
  linear scans would grow the counter with n and trip the e-process
- union evaluated O(k) in rule count in normal mode vs O(1) in binary
  mode (threshold early exit) — declared cost = rule evaluations

BENCHMARK (percentile assertions over auto-collected samples):
- direct/chain/ttu single-check actions stay under p50=0.2ms p95=1.0ms
  budgets on the complex graphs

Debugging along the way surfaced two rigor API facts worth pinning:
metric readers receive the raw generated-args ARRAY (fns get the spread
values), and a missing module import silently degrades actions into
'no observations' vacuous verdicts (ReferenceError swallowed by the
runner). Full rigor 237/237.
2026-08-02 14:20:50 -07:00
John Dvorak f410b6c902 rigor: complex-graph crucible — community/scale-free/hierarchy/dense generators
The engine's campaigns ran on toy star graphs. complex-graphs.js adds four
production-shaped generators:

- community: stochastic block model with nested groups (groups of groups),
  tuple-to-userset access, defeasible blocked overlay, cross-community
  delegation chains
- scale-free: preferential attachment, power-law degree distribution,
  hub-heavy adjacency
- hierarchy: org-tree (org -> dept -> team -> member) with 3-hop ownership
  chains
- dense-adversarial: maximal overlap on small graphs — reciprocal edges,
  self-loops, multi-rule policies (cycle + cache-collision pressure)

complex-graph-crucible.test.js runs four crucibles over them: structural
shape assertions across seeds, exhaustive normal/binary/snapshot parity
with bounds on every query, a 300-effort rigor fuzz campaign over
(generator, seed, user, relation, object) triples enforcing
allow/deny + possibility agreement across all three evaluation modes, and
a reachability guard proving complex policies (TTU, chain) are actually
exercised rather than denied trivially.

Generator fixes along the way: sub-groups own their own resources so the
one-level TTU is structurally reachable, and departments own resources so
the 3-hop chain terminates. Full rigor 234/234.
2026-08-02 13:51:03 -07:00
John Dvorak e98137a04f bench: complex-query cold-traffic benchmark (normal vs binary)
Prod gating is dominated by rule-based queries, not direct relations.
complex-query-bench.js measures cold-traffic latency (distinct
subject/object per sample, no cache reuse) across seven complex policy
shapes — tuple-to-userset, 2-hop chain, defeasible exclusion, ABAC
relational comparator, OWA union, nested comparator + OWA fusion, and a
mixed 10-rule union — for both evaluation paths, and enforces binary/
normal decision parity on every query.

At 25k and 100k nodes: binary wins every scenario (1.15x-2.0x median
speedup), p99 stays sub-0.05ms, and parity mismatches are zero across
all scenarios. Binary's early exit wins where a strong rule exists; the
earlier direct-relation 'binary slower' observation was a cache-hit
artifact (normal serves repeat queries from the rule result cache,
binary correctly does not, since thresholds are per-call options).

Note: report median, not avg — GC outliers inflate the mean (avg > p95
observed on two rows).
2026-08-02 13:38:37 -07:00
John Dvorak 5566d6c0ab perf: binary direct checks skip cycle machinery
_checkBinary ran the visited-set cycle detection (visit-key string
allocation + Set has/add) on every call, including top-level direct
checks that never recurse — making the 'fast' binary path 2.5x slower
than the normal fast path, which already skips the block when _visited
is empty.

The cycle block now sits after the direct fast path: direct checks
return before it, while the rule-evaluation branches (which recurse via
evaluateRule with the shared _visited) still detect cycles. Binary
direct checks drop from 6.7us to 4.1us avg; the remaining 1.4x is the
honest cost of the richer binary result contract (allow/deny, threshold
compare, config lookup). Parity suites and full rigor remain green.
2026-08-02 13:22:43 -07:00
John Dvorak 223cfb97c3 packaging: README (NASA style) + possibilistic perf baseline + CI benchmark job
CI / test (push) Successful in 4m11s
CI / benchmark (push) Successful in 28s
CI / publish (push) Has been skipped
README: purpose-first (possibility not boolean, caller owns evidence/time),
install, verified quick start, concepts (result shape, overlays, temporal
context), API table, development commands, design notes.

benchmark: scripts/benchmark.js on @tenere/benchmark-lib — eight contours
(direct/union/denied/meta/overlay/binary checks, snapshot build/restore),
committed .rigor-baseline.json, exit 1 on high-severity regressions.

CI: benchmark job compares on push (continue-on-error), re-saves baseline
and uploads it as an artifact on tags; publish now depends on benchmark
passing as well as test.
2026-08-02 12:52:21 -07:00
48 changed files with 3124 additions and 201 deletions
+66 -1
View File
@@ -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
+142
View File
@@ -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
}
}
}
}
}
+109
View File
@@ -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.
+259
View File
@@ -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(','));
}
+12
View File
@@ -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
View File
@@ -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"
}
+179
View File
@@ -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)
}
+70 -39
View File
@@ -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(
+3 -1
View File
@@ -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;
}
+4 -2
View File
@@ -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;
+4 -1
View File
@@ -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
+22 -8
View File
@@ -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(
+2 -1
View File
@@ -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'
}
+12 -6
View File
@@ -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'
}
+6
View File
@@ -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;
+4 -2
View File
@@ -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,
+6 -4
View File
@@ -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
View File
@@ -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;
+5 -5
View File
@@ -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');
+4 -4
View File
@@ -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());
+6 -6
View File
@@ -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`);
}
});
});
+154
View File
@@ -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`);
}
});
});
+381
View File
@@ -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`);
}
});
});
+7 -7
View File
@@ -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');
+8 -8
View File
@@ -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');
+8 -8
View File
@@ -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');
+6 -6
View File
@@ -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');
+5 -5
View File
@@ -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');
+7 -7
View File
@@ -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');
+7 -7
View File
@@ -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');
+13 -13
View File
@@ -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');
+6 -6
View File
@@ -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');
+2 -2
View File
@@ -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') {
+140
View File
@@ -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);
});
});
});
+6 -6
View File
@@ -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');