packaging: README (NASA style) + possibilistic perf baseline + CI benchmark job
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.
This commit is contained in:
@@ -35,10 +35,75 @@ jobs:
|
|||||||
- name: Rigor campaigns
|
- name: Rigor campaigns
|
||||||
run: npm run test:rigor
|
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:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
needs: test
|
needs: [test, benchmark]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"generated": "2026-08-02T19:51:19.208Z",
|
||||||
|
"actions": {
|
||||||
|
"check[direct-hit]": {
|
||||||
|
"mostPlausible": 0.25810395981996936,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 0.16434574601481775,
|
||||||
|
"upper": 0.40535024830520205
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 0.10394999999999016,
|
||||||
|
"upper": 1.2158777106838974
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 0.10394999999999016,
|
||||||
|
"upper": 2.040950000000002
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"check[union-ttu]": {
|
||||||
|
"mostPlausible": 0.3581109933665416,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 0.07645000000001517,
|
||||||
|
"upper": 0.35811139703768785
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 0.07645000000001517,
|
||||||
|
"upper": 0.35811139703768785
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 0.07645000000001517,
|
||||||
|
"upper": 0.35811139703768785
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"check[denied-miss]": {
|
||||||
|
"mostPlausible": 0.21718442918988012,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 0.18384889559816292,
|
||||||
|
"upper": 0.2565642603295502
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 0.13274999999997988,
|
||||||
|
"upper": 0.3601500000000823
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 0.13274999999997988,
|
||||||
|
"upper": 0.3601500000000823
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"check[include-meta]": {
|
||||||
|
"mostPlausible": 0.9937660048895368,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 0.9937660048895368,
|
||||||
|
"upper": 0.9937663566453343
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 0.9937660048895368,
|
||||||
|
"upper": 0.9937663566453343
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 0.9937660048895368,
|
||||||
|
"upper": 0.9937663566453343
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"check[overlay-on-top]": {
|
||||||
|
"mostPlausible": 0.3485105914857449,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 0.26044999999998425,
|
||||||
|
"upper": 0.3881187132654935
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 0.26044999999998425,
|
||||||
|
"upper": 0.5286391152207875
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 0.26044999999998425,
|
||||||
|
"upper": 0.5374790233000701
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"check[binary-direct]": {
|
||||||
|
"mostPlausible": 0.2660225524868295,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 0.21220996174732032,
|
||||||
|
"upper": 0.3334807973509898
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 0.16554999999993186,
|
||||||
|
"upper": 0.5780022226626057
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 0.16554999999993186,
|
||||||
|
"upper": 0.8219500000000078
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snapshot[build-binary]": {
|
||||||
|
"mostPlausible": 4.119453383803492,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 3.638457178151043,
|
||||||
|
"upper": 4.664037328289517
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 3.1707499999999906,
|
||||||
|
"upper": 6.309350037482752
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 3.1707499999999906,
|
||||||
|
"upper": 7.547749999999944
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snapshot[restore-binary]": {
|
||||||
|
"mostPlausible": 6.906283652168588,
|
||||||
|
"alphaCuts": {
|
||||||
|
"p50": {
|
||||||
|
"lower": 3.2581500000001067,
|
||||||
|
"upper": 6.906283919208314
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"lower": 3.2581500000001067,
|
||||||
|
"upper": 6.906283919208314
|
||||||
|
},
|
||||||
|
"p99": {
|
||||||
|
"lower": 3.2581500000001067,
|
||||||
|
"upper": 6.906283919208314
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# @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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
Generated
+12
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rigor/core": "*",
|
"@rigor/core": "*",
|
||||||
|
"@tenere/benchmark-lib": "^2.0.1",
|
||||||
"fast-check": "^4.5.3",
|
"fast-check": "^4.5.3",
|
||||||
"peggy": "^5.0.6"
|
"peggy": "^5.0.6"
|
||||||
}
|
}
|
||||||
@@ -305,6 +306,17 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/@tenere/graph-core": {
|
||||||
"version": "1.0.1",
|
"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",
|
"resolved": "https://hub.kl1.tenere.ai/api/packages/Tenere/npm/%40tenere%2Fgraph-core/-/1.0.1/graph-core-1.0.1.tgz",
|
||||||
|
|||||||
+4
-2
@@ -34,7 +34,7 @@
|
|||||||
"test:perf": "RUN_PERF_TESTS=1 node --test --test-force-exit tests/engine/core-performance-targets.test.js",
|
"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",
|
"generate:parser": "node scripts/generate-parser.js",
|
||||||
"build:ast": "npm run generate:parser",
|
"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:core": "node benchmarks/core-performance-benchmark.js",
|
||||||
"benchmark:batch": "node benchmarks/batch-size-analysis.js",
|
"benchmark:batch": "node benchmarks/batch-size-analysis.js",
|
||||||
"benchmark:chain": "node benchmarks/chain-rule-benchmark.js",
|
"benchmark:chain": "node benchmarks/chain-rule-benchmark.js",
|
||||||
@@ -47,7 +47,8 @@
|
|||||||
"benchmark:snapshot": "node benchmarks/arbiter-snapshot-boot-bench.js",
|
"benchmark:snapshot": "node benchmarks/arbiter-snapshot-boot-bench.js",
|
||||||
"benchmark:sharded": "node benchmarks/sharded-snapshot-build.js",
|
"benchmark:sharded": "node benchmarks/sharded-snapshot-build.js",
|
||||||
"benchmark:memory": "node benchmarks/memory-breakdown.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"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"zanzibar",
|
"zanzibar",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rigor/core": "*",
|
"@rigor/core": "*",
|
||||||
|
"@tenere/benchmark-lib": "^2.0.1",
|
||||||
"fast-check": "^4.5.3",
|
"fast-check": "^4.5.3",
|
||||||
"peggy": "^5.0.6"
|
"peggy": "^5.0.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* CI benchmark script for @arbiter/core — powered by @tenere/benchmark-lib
|
||||||
|
* (possibilistic contours with valid alpha cuts).
|
||||||
|
*
|
||||||
|
* node --expose-gc scripts/benchmark.js compare against baseline
|
||||||
|
* node --expose-gc scripts/benchmark.js --save save new baseline
|
||||||
|
* node --expose-gc scripts/benchmark.js --json JSON output
|
||||||
|
* node --expose-gc scripts/benchmark.js --baseline <path> custom baseline path
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 — all benchmarks pass, no high-severity regressions
|
||||||
|
* 1 — high-severity regression found
|
||||||
|
* 2 — crash (benchmark threw unexpectedly)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import { benchmark, createBaseline, detectRegressions, formatRegressions } from '@tenere/benchmark-lib'
|
||||||
|
import { Arbiter } from '../src/core/Arbiter.js'
|
||||||
|
import { PartialGraphContext } from '../src/core/PartialGraphContext.js'
|
||||||
|
|
||||||
|
const SAVE = process.argv.includes('--save')
|
||||||
|
const AS_JSON = process.argv.includes('--json')
|
||||||
|
const BASELINE_PATH = (() => {
|
||||||
|
const idx = process.argv.indexOf('--baseline')
|
||||||
|
return idx >= 0 ? process.argv[idx + 1] : '.rigor-baseline.json'
|
||||||
|
})()
|
||||||
|
|
||||||
|
benchmark.config({
|
||||||
|
measurements: ['timing'],
|
||||||
|
uncertaintyThreshold: 0.99,
|
||||||
|
minSamples: 0,
|
||||||
|
maxSamples: 200,
|
||||||
|
overheadCompensation: true,
|
||||||
|
gcBetweenSamples: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let CRASHED = 0
|
||||||
|
|
||||||
|
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildEngine() {
|
||||||
|
const a = new Arbiter()
|
||||||
|
const groupCount = 40
|
||||||
|
for (let g = 0; g < groupCount; g++) a.addNode(`group:${g}`, 'group')
|
||||||
|
for (let u = 0; u < 200; u++) {
|
||||||
|
a.addNode(`user:${u}`, 'user')
|
||||||
|
a.addNode(`doc:${u}`, 'doc')
|
||||||
|
}
|
||||||
|
a.setRelationConfig('owner', { type: 'direct' })
|
||||||
|
a.setRelationConfig('member_of', { type: 'direct' })
|
||||||
|
a.setRelationConfig('can_read', {
|
||||||
|
type: 'union',
|
||||||
|
rules: [{ kind: 'direct', relation: 'owner' }, { kind: 'tuple_to_userset', parentRelation: 'member_of', childRelation: 'owner' }]
|
||||||
|
})
|
||||||
|
for (let u = 0; u < 200; u++) {
|
||||||
|
a.addRelation(`user:${u}`, 'owner', `doc:${u}`, { possibility: 0.9 + (u % 10) / 100 })
|
||||||
|
a.addRelation(`user:${u}`, 'member_of', `group:${u % groupCount}`, { possibility: 0.7 })
|
||||||
|
}
|
||||||
|
for (let g = 0; g < groupCount; g++) a.addRelation(`group:${g}`, 'owner', `doc:${g}`, { possibility: 1.0 })
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = buildEngine()
|
||||||
|
|
||||||
|
const directBench = benchmark('check[direct-hit]', () => {
|
||||||
|
engine.check('user:1', 'owner', 'doc:1')
|
||||||
|
})
|
||||||
|
|
||||||
|
const unionBench = benchmark('check[union-ttu]', () => {
|
||||||
|
engine.check('user:1', 'can_read', 'doc:1')
|
||||||
|
})
|
||||||
|
|
||||||
|
const deniedBench = benchmark('check[denied-miss]', () => {
|
||||||
|
engine.check('user:5', 'owner', 'doc:1')
|
||||||
|
})
|
||||||
|
|
||||||
|
const metaBench = benchmark('check[include-meta]', () => {
|
||||||
|
engine.check('user:1', 'can_read', 'doc:1', { includeMeta: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const overlayBench = benchmark('check[overlay-on-top]', () => {
|
||||||
|
const ctx = new PartialGraphContext(engine, {
|
||||||
|
relations: [{ src: 'user:1', relation: 'owner', dst: 'doc:1', possibility: 0.5 }]
|
||||||
|
})
|
||||||
|
engine.check('user:1', 'owner', 'doc:1', { partialGraphContext: ctx })
|
||||||
|
})
|
||||||
|
|
||||||
|
const binaryBench = (() => {
|
||||||
|
const snap = buildEngine()
|
||||||
|
snap.enableCondensedSnapshot()
|
||||||
|
return benchmark('check[binary-direct]', () => {
|
||||||
|
snap.check('user:1', 'owner', 'doc:1', { binary: true })
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
const snapshotBuildBench = (() => {
|
||||||
|
const snap = buildEngine()
|
||||||
|
snap.enableCondensedSnapshot()
|
||||||
|
return benchmark('snapshot[build-binary]', () => {
|
||||||
|
snap.toSnapshotBinary()
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
const snapshotRestoreBench = (() => {
|
||||||
|
const snap = buildEngine()
|
||||||
|
snap.enableCondensedSnapshot()
|
||||||
|
const buf = snap.toSnapshotBinary()
|
||||||
|
return benchmark('snapshot[restore-binary]', () => {
|
||||||
|
Arbiter.fromSnapshotBinary(buf)
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
// ── Run ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function measure(handle) {
|
||||||
|
try {
|
||||||
|
return handle.contour('timing')
|
||||||
|
} catch (e) {
|
||||||
|
CRASHED++
|
||||||
|
console.error(`⚠ ${e.message}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {}
|
||||||
|
const order = [directBench, unionBench, deniedBench, metaBench, overlayBench, binaryBench, snapshotBuildBench, snapshotRestoreBench]
|
||||||
|
for (const handle of order) {
|
||||||
|
const contour = measure(handle)
|
||||||
|
if (!contour) continue
|
||||||
|
const mp = contour.mostPlausible()
|
||||||
|
results[handle.name] = {
|
||||||
|
mostPlausible: mp.value,
|
||||||
|
alphaCuts: contour.alphaCuts(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = { actions: results, campaign: null, failures: 0 }
|
||||||
|
|
||||||
|
if (AS_JSON) {
|
||||||
|
const out = { results: {}, regressions: [], improvements: [], crashed: CRASHED }
|
||||||
|
for (const [name, r] of Object.entries(results)) {
|
||||||
|
out.results[name] = { timing: { mp: r.mostPlausible, alphaCuts: r.alphaCuts } }
|
||||||
|
}
|
||||||
|
if (fs.existsSync(BASELINE_PATH) && !SAVE) {
|
||||||
|
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
|
||||||
|
const regResult = detectRegressions(current, baseline)
|
||||||
|
out.regressions = regResult.regressions
|
||||||
|
out.improvements = regResult.improvements
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(out, null, 2))
|
||||||
|
} else {
|
||||||
|
for (const [name, r] of Object.entries(results)) {
|
||||||
|
const cuts = r.alphaCuts
|
||||||
|
console.log(`${name}: mp=${r.mostPlausible.toFixed(4)}ms p95=[${cuts.p95.lower.toFixed(4)},${cuts.p95.upper.toFixed(4)}]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SAVE) {
|
||||||
|
const baseline = createBaseline([current])
|
||||||
|
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2), 'utf8')
|
||||||
|
console.log(`baseline saved: ${BASELINE_PATH}`)
|
||||||
|
} else if (fs.existsSync(BASELINE_PATH)) {
|
||||||
|
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
|
||||||
|
const regResult = detectRegressions(current, baseline)
|
||||||
|
if (!AS_JSON) console.log(formatRegressions(regResult, 'pretty'))
|
||||||
|
const critical = regResult.regressions.filter(r => r.severity === 'high')
|
||||||
|
if (critical.length > 0) {
|
||||||
|
console.error(`❌ ${critical.length} critical regression(s): ${critical.map(r => r.name).join(', ')}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else if (!SAVE) {
|
||||||
|
console.log('no baseline — first run, use --save')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CRASHED > 0) {
|
||||||
|
console.error(`⚠ ${CRASHED} benchmark(s) crashed`)
|
||||||
|
process.exit(SAVE ? 0 : 2)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user