diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 904fd9e..fd2aeee 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/.rigor-baseline.json b/.rigor-baseline.json new file mode 100644 index 0000000..46f4194 --- /dev/null +++ b/.rigor-baseline.json @@ -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 + } + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d73c5b9 --- /dev/null +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 8245835..4f267c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6c325c2..afe3365 100644 --- a/package.json +++ b/package.json @@ -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,8 @@ "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" }, "keywords": [ "zanzibar", @@ -64,6 +65,7 @@ }, "devDependencies": { "@rigor/core": "*", + "@tenere/benchmark-lib": "^2.0.1", "fast-check": "^4.5.3", "peggy": "^5.0.6" } diff --git a/scripts/benchmark.js b/scripts/benchmark.js new file mode 100644 index 0000000..f4be9b1 --- /dev/null +++ b/scripts/benchmark.js @@ -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 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) +}