From bd9c74fb0e83fd611870f1010a94cc126d38d5ab Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Mon, 3 Aug 2026 15:43:48 -0700 Subject: [PATCH] feat: tuple_to_userset intermediate reachability; ratio-based benchmark gate - ChainRule._expandRuleFromSrc now expands tuple_to_userset configs: from a source node the reachable set is the objects sharing an intermediate with the source (src ->computed-> intermediate ->tupleset-> object, direction aware, weakest-link combined). Lets a TTU evidence serve as an intermediate condition step in a chain. - scripts/benchmark.js: ratio-based self-calibration. Comparing each action's RATIO to a cheap reference action (default check[direct-hit]) cancels machine-load swings that scale all actions proportionally, so the gate only fails on code regressions that shift a single action's ratio. The reference is still checked absolutely with a loose bound. Verified: stable across runs, and a simulated union-ttu slowdown is caught (+76.8% ratio). Tests: chain-condition-step intermediate TTU expansion. --- .rigor-baseline.json | 114 +++++++++++------------ package.json | 2 +- scripts/benchmark.js | 55 ++++++++--- src/authorization/rules/ChainRule.js | 36 +++++++ tests/rules/chain-condition-step.test.js | 29 ++++++ 5 files changed, 167 insertions(+), 69 deletions(-) diff --git a/.rigor-baseline.json b/.rigor-baseline.json index 391b9c1..582f40f 100644 --- a/.rigor-baseline.json +++ b/.rigor-baseline.json @@ -1,140 +1,140 @@ { "version": 1, - "generated": "2026-08-03T21:02:18.659Z", + "generated": "2026-08-03T22:37:59.060Z", "actions": { "check[direct-hit]": { - "mostPlausible": 0.13270348756111364, + "mostPlausible": 0.10672237227527051, "alphaCuts": { "p50": { - "lower": 0.13113648835948058, - "upper": 0.13428864360995807 + "lower": 0.105663874043709, + "upper": 0.10779178510155751 }, "p95": { - "lower": 0.12820153883971488, - "upper": 0.13736359391644548 + "lower": 0.10367558838055704, + "upper": 0.10985914641251807 }, "p99": { - "lower": 0.12681899549493264, - "upper": 0.13886133499002185 + "lower": 0.10273583797909441, + "upper": 0.11086333384764993 } } }, "check[union-ttu]": { - "mostPlausible": 0.13272407787199741, + "mostPlausible": 0.11249228961043199, "alphaCuts": { "p50": { - "lower": 0.13111540538562938, - "upper": 0.13435208823425798 + "lower": 0.111604019277894, + "upper": 0.11338800600185026 }, "p95": { - "lower": 0.12810370624158607, - "upper": 0.13751164134564425 + "lower": 0.10993048219120201, + "upper": 0.11511402952231622 }, "p99": { - "lower": 0.12668522950869812, - "upper": 0.1390508250938302 + "lower": 0.1091378967617418, + "upper": 0.11594970606842417 } } }, "check[denied-miss]": { - "mostPlausible": 0.10464274362739566, + "mostPlausible": 0.08747524001151907, "alphaCuts": { "p50": { - "lower": 0.1035911653567046, - "upper": 0.10570542262187044 + "lower": 0.08668417849217541, + "upper": 0.08827353325683636 }, "p95": { - "lower": 0.10161518016378715, - "upper": 0.10776062041045117 + "lower": 0.085196295997943, + "upper": 0.08981478022606769 }, "p99": { - "lower": 0.10068232198106161, - "upper": 0.10875916306014438 + "lower": 0.08449268650925873, + "upper": 0.09056310270952082 } } }, "check[include-meta]": { - "mostPlausible": 0.2032086398120905, + "mostPlausible": 0.17368093893555905, "alphaCuts": { "p50": { - "lower": 0.20130903880967277, - "upper": 0.20512602330365767 + "lower": 0.17235808249234505, + "upper": 0.17501399072522753 }, "p95": { - "lower": 0.19773841496722822, - "upper": 0.20882979063960022 + "lower": 0.1698647154489402, + "upper": 0.17758260042495247 }, "p99": { - "lower": 0.19605028516281864, - "upper": 0.21062828369506048 + "lower": 0.16868343694884977, + "upper": 0.178826872871734 } } }, "check[overlay-on-top]": { - "mostPlausible": 0.43801922339274096, + "mostPlausible": 0.34658090606556824, "alphaCuts": { "p50": { - "lower": 0.4331486167515239, - "upper": 0.4429442096584253 + "lower": 0.343563635087355, + "upper": 0.3496248120197711 }, "p95": { - "lower": 0.4240166116932131, - "upper": 0.45248423243066815 + "lower": 0.33788494447103923, + "upper": 0.3555006559358586 }, "p99": { - "lower": 0.4197093259995535, - "upper": 0.45712770847629536 + "lower": 0.33519739588838326, + "upper": 0.35835082770353416 } } }, "check[binary-direct]": { - "mostPlausible": 0.12194744387843882, + "mostPlausible": 0.10136432040214981, "alphaCuts": { "p50": { - "lower": 0.12053468994850938, - "upper": 0.12337633544038318 + "lower": 0.10038730449515523, + "upper": 0.10235072528492808 }, "p95": { - "lower": 0.1178875990998377, - "upper": 0.1261469438761139 + "lower": 0.0985518364498951, + "upper": 0.10425698760463886 }, "p99": { - "lower": 0.11663982372031695, - "upper": 0.12749639398836587 + "lower": 0.09768455509480933, + "upper": 0.10518292733500886 } } }, "snapshot[build-binary]": { - "mostPlausible": 4.699470380992978, + "mostPlausible": 2.848037533444555, "alphaCuts": { "p50": { - "lower": 4.649083103245884, - "upper": 4.750403850161681 + "lower": 2.827413825511722, + "upper": 2.868811889350609 }, "p95": { - "lower": 4.554543327557406, - "upper": 4.849008463493959 + "lower": 2.788520370277795, + "upper": 2.9088252291455836 }, "p99": { - "lower": 4.509934735938718, - "upper": 4.896973552928932 + "lower": 2.770077133158435, + "upper": 2.9281936091667653 } } }, "snapshot[restore-binary]": { - "mostPlausible": 5.015702105071855, + "mostPlausible": 2.9488926124434385, "alphaCuts": { "p50": { - "lower": 4.960977858992745, - "upper": 5.071029889482844 + "lower": 2.9247809483999716, + "upper": 2.9732031186198484 }, "p95": { - "lower": 4.858330254069017, - "upper": 5.178172214368928 + "lower": 2.8793716893148757, + "upper": 3.020092288118393 }, "p99": { - "lower": 4.809905283270391, - "upper": 5.2303028693638565 + "lower": 2.8578657882531426, + "upper": 3.0428172805315117 } } } diff --git a/package.json b/package.json index dfcf940..55e4c5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/core", - "version": "1.0.4", + "version": "1.0.5", "description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.", "license": "ISC", "author": "", diff --git a/scripts/benchmark.js b/scripts/benchmark.js index f818c3b..b3acfdc 100644 --- a/scripts/benchmark.js +++ b/scripts/benchmark.js @@ -207,17 +207,50 @@ if (SAVE) { const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')) const regResult = detectRegressions(current, baseline) if (!AS_JSON) console.log(formatRegressions(regResult, 'pretty')) - // The lib flags a regression whenever current > baseline.p95/p99, which is - // tighter than run-to-run noise at sub-ms scales (baseline alphaCuts capture - // per-run sample spread, not cross-run variance). Require a minimum change - // before treating a high-severity flag as a CI failure, so the gate fails on - // real regressions (>10% past the p99) instead of ~3-5% noise. - const MIN_HIGH_REGRESSION_PERCENT = Number(process.env.MIN_HIGH_REGRESSION_PERCENT ?? 10) - const critical = regResult.regressions.filter( - r => r.severity === 'high' && r.changePercent >= MIN_HIGH_REGRESSION_PERCENT - ) - if (critical.length > 0) { - console.error(`❌ ${critical.length} critical regression(s): ${critical.map(r => r.name).join(', ')}`) + + // ── Ratio-based self-calibration ───────────────────────────────────── + // Absolute timings swing with machine load (a shared runner at load 19 + // shifted every action +15..+50%). Comparing each action's RATIO to a cheap + // reference action cancels the load: load scales all actions proportionally, + // while a code regression shifts only the affected action's ratio. The + // reference action itself is still gate-checked absolutely with a loose + // bound (a regression of the reference would otherwise mask every ratio). + const RATIO_REFERENCE = process.env.BENCH_RATIO_REFERENCE || 'check[direct-hit]' + const RATIO_PERCENT = Number(process.env.RATIO_REGRESSION_PERCENT ?? 15) + const REFERENCE_PERCENT = Number(process.env.REFERENCE_REGRESSION_PERCENT ?? 30) + + const currentRef = current.actions?.[RATIO_REFERENCE]?.mostPlausible + const baselineRef = baseline.actions?.[RATIO_REFERENCE]?.mostPlausible + const ratioViolations = [] + let referenceViolation = false + + if (currentRef > 0 && baselineRef > 0) { + if (currentRef > baselineRef * (1 + REFERENCE_PERCENT / 100)) { + referenceViolation = true + ratioViolations.push( + `${RATIO_REFERENCE} (reference) baseline=${baselineRef.toFixed(4)} → current=${currentRef.toFixed(4)} (+${(((currentRef / baselineRef) - 1) * 100).toFixed(1)}%)` + ) + } + for (const [name, cur] of Object.entries(current.actions)) { + if (name === RATIO_REFERENCE) continue + const base = baseline.actions?.[name] + if (!base || !(base.mostPlausible > 0)) continue + const curRatio = cur.mostPlausible / currentRef + const baseRatio = base.mostPlausible / baselineRef + if (curRatio > baseRatio * (1 + RATIO_PERCENT / 100)) { + ratioViolations.push( + `${name} ratio ${curRatio.toFixed(3)} → baseline ${baseRatio.toFixed(3)} (+${(((curRatio / baseRatio) - 1) * 100).toFixed(1)}%)` + ) + } + } + } + + if (ratioViolations.length > 0) { + console.error(`❌ ${ratioViolations.length} ratio regression(s):`) + for (const v of ratioViolations) console.error(` - ${v}`) + if (referenceViolation) { + console.error(' (the reference action regressed absolutely — ratios may be unreliable)') + } process.exit(1) } } else if (!SAVE) { diff --git a/src/authorization/rules/ChainRule.js b/src/authorization/rules/ChainRule.js index 5695cd5..b5829e2 100644 --- a/src/authorization/rules/ChainRule.js +++ b/src/authorization/rules/ChainRule.js @@ -515,6 +515,9 @@ export class ChainRule extends BaseRule { if (rule.type === 'chain' && Array.isArray(rule.steps)) { return this._expandChainRuleFromSrc(srcId, rule, options); } + if (rule.type === 'tuple_to_userset') { + return this._expandTtuFromSrc(srcId, rule, options); + } if (rule.type === 'logical') { if (rule.when || rule.unless || rule.never || rule.requires || rule.always) { return this._expandDefeasibleFromSrc(srcId, rule, options); @@ -538,6 +541,39 @@ export class ChainRule extends BaseRule { return out; } + /** + * Expand a tuple_to_userset config from a source node. Semantics (forward, + * the common case): src →computed→ intermediate, and intermediate ↔object + * via tupleset (direction decides which end the intermediate sits on). The + * reachable set is the OBJECTS sharing an intermediate with src. Combined + * possibility is the weakest link across the two hops. + */ + _expandTtuFromSrc(srcId, rule, options = null) { + const { tuplesetRelation, computedRelation, tuplesetDirection = 'out', reverse = false } = rule; + if (!tuplesetRelation || !computedRelation) return new Map(); + // reverse swaps the roles: src is the object, intermediates come from the + // tupleset side, and computed edges go from intermediate to the user. + const srcAsUser = !reverse; + const computedRel = srcAsUser ? computedRelation : tuplesetRelation; + const tuplesetRel = srcAsUser ? tuplesetRelation : computedRelation; + const computedEdges = this.arbiter.relationManager.getRelationsFromSrc(srcId, computedRel, options); + const out = new Map(); + for (const ce of computedEdges || []) { + const intermediateId = ce.dst; + const tsEdges = tuplesetDirection === 'in' + ? this.arbiter.relationManager.getRelationsFromSrc(intermediateId, tuplesetRel, options) + : this.arbiter.relationManager.getRelationsToDst(intermediateId, tuplesetRel, options); + for (const te of tsEdges || []) { + const objId = tuplesetDirection === 'in' ? te.dst : te.src; + const poss = Math.min(ce.possibility ?? 1, te.possibility ?? 1); + const rel = (ce.reliability ?? 1) * (te.reliability ?? 1); + const cur = out.get(objId); + if (!cur || poss > cur.possibility) out.set(objId, { possibility: poss, reliability: rel }); + } + } + return out; + } + _expandLogicalNodeFromSrc(srcId, node, op, options = null) { const rules = (node && node.rules) || []; if (rules.length === 0) return new Map(); diff --git a/tests/rules/chain-condition-step.test.js b/tests/rules/chain-condition-step.test.js index 9d7a14c..ff57f37 100644 --- a/tests/rules/chain-condition-step.test.js +++ b/tests/rules/chain-condition-step.test.js @@ -106,6 +106,35 @@ describe('ChainRule condition step (rule-based final hop)', () => { assert.equal(denied.possibility, 0); }); + it('expands a tuple_to_userset evidence as an intermediate condition step', () => { + arbiter.setRelationConfig('member_of', { type: 'direct' }); + arbiter.setRelationConfig('owner', { type: 'direct' }); + arbiter.setRelationConfig('doc_route', { type: 'direct' }); + arbiter.addNode('group:g', 'group'); + arbiter.addNode('doc:1', 'doc'); + arbiter.addNode('doc:2', 'doc'); + arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 }); + arbiter.addRelation('group:g', 'owner', 'doc:1', { possibility: 0.8 }); + arbiter.addRelation('doc:1', 'doc_route', 'doc:2', { possibility: 0.9 }); + const ttuConfig = { + type: 'tuple_to_userset', + tuplesetRelation: 'owner', + computedRelation: 'member_of', + tuplesetDirection: 'in' + }; + const rule = { + type: 'chain', + steps: [{ rule: ttuConfig, conditionStep: true }, 'doc_route'] + }; + // TTU reachable from user = doc:1 (min(1.0, 0.8) = 0.8), then doc_route → 0.9 + const res = evalRule('user:u', 'doc:2', rule); + assert.ok(Math.abs(res.possibility - 0.8) < 1e-9, `expected 0.8, got ${res.possibility} (${res.reason})`); + // Direct expansion unit check + const expanded = chainRule._expandRuleFromSrc(arbiter.resolveNodeId('user:u'), ttuConfig, {}); + assert.equal(expanded.size, 1); + assert.equal(arbiter.resolveKey([...expanded.keys()][0]), 'doc:1'); + }); + it('combines across multiple parallel intermediates (max aggregation)', () => { arbiter.addNode('group:g2', 'group'); arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 0.5 });