@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +226 -0
- package/src/posture/verification/patch-promotion.js +113 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Quality calibration by supported strata (X-603).
|
|
2
|
+
//
|
|
3
|
+
// From outcome records (outcomes.js) to a quality estimate per (model, model version, task stratum), each with an uncertainty
|
|
4
|
+
// interval and an explicit statement of whether it may be relied on. The central design choice: a stratum earns the label `reliable`
|
|
5
|
+
// only by clearing every one of these, and otherwise it carries a named fallback and NEVER the label.
|
|
6
|
+
//
|
|
7
|
+
// population at least 200 PAIRED adjudicated tasks overall (a task labelled for the baseline model AND a candidate) and at least
|
|
8
|
+
// 30 for the stratum (PRD section 5; frozen in ROUTING_MINIMUMS and a test pins them to the PRD numbers)
|
|
9
|
+
// uncertainty the grouped bootstrap (evaluation/interval.js, the same method the accuracy reports register) over HELD-OUT outcomes
|
|
10
|
+
// is `measured`; below 10 independent groups it is `unmeasured` and the stratum cannot be reliable
|
|
11
|
+
// stability the held-out rate agrees with the development rate: a development rate outside the held-out 95% interval makes the
|
|
12
|
+
// stratum `shifted`, and a stratum with no development baseline cannot be called stable
|
|
13
|
+
// provenance only outcomes decided by adjudication or trusted execution are used (outcomes.js); unknown and delayed are counted
|
|
14
|
+
// and disclosed, never imputed
|
|
15
|
+
// reality a synthetic population can never produce `reliable`, only `reliable-synthetic`, which a production policy refuses
|
|
16
|
+
//
|
|
17
|
+
// Splits. Held-out data is what the estimate is computed on; development data only tests whether the held-out rate has shifted.
|
|
18
|
+
// time outcomes observed at or after `timeSplit.heldOutFrom` are held out
|
|
19
|
+
// model version a (model, version) listed in `heldOutModelVersions` is held out entirely, so a new version is judged on its own
|
|
20
|
+
// data and never inherits a predecessor's. Estimates are per version and versions are never pooled.
|
|
21
|
+
// group a group (target, upstream, advisory) that appears on BOTH sides is removed from the held-out side and counted, so a
|
|
22
|
+
// near-duplicate cannot appear to generalise
|
|
23
|
+
//
|
|
24
|
+
// The artifact records dataset hashes, adjudication versions, sample counts and every exclusion with its reason, and rebuilds
|
|
25
|
+
// byte-for-byte from the same outcomes (`verifyCalibration`). No clock, no randomness beyond the seeded bootstrap.
|
|
26
|
+
|
|
27
|
+
import { digestOf } from '../assurance/identity.js';
|
|
28
|
+
import { groupedBootstrap, recallOf, wilsonInterval } from '../evaluation/interval.js';
|
|
29
|
+
import { stratumParents, FAILURE_STATUSES, validateRoutingOutcome } from './outcomes.js';
|
|
30
|
+
import { medianKnown } from './economics.js';
|
|
31
|
+
|
|
32
|
+
export const CALIBRATION_SCHEMA = 'agentic-security/routing-calibration';
|
|
33
|
+
const CALIBRATION_VERSION = '1.0.0';
|
|
34
|
+
|
|
35
|
+
/** PRD section 5, "Routing promotion". Frozen here so a config cannot quietly loosen them. */
|
|
36
|
+
export const ROUTING_MINIMUMS = Object.freeze({
|
|
37
|
+
pairedTasksOverall: 200,
|
|
38
|
+
pairedTasksPerStratum: 30,
|
|
39
|
+
/** Development outcomes needed before a stratum's stability can be tested at all (an engineering choice, recorded in the artifact). */
|
|
40
|
+
developmentPerStratum: 10,
|
|
41
|
+
});
|
|
42
|
+
/** The rest of the section 5 routing row, carried for the promotion gate (X-605). The comparison itself is not implemented here. */
|
|
43
|
+
export const ROUTING_PROMOTION_TARGETS = Object.freeze({
|
|
44
|
+
qualityDifferenceLowerBound: -0.02, medianCostReduction: 0.20, qualityGain: 0.05, p95LatencyRatio: 1.2,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const CALIBRATION_LABELS = Object.freeze(['reliable', 'reliable-synthetic', 'fallback-parent', 'shifted', 'insufficient-evidence']);
|
|
48
|
+
export const EXCLUSION_REASONS = Object.freeze([
|
|
49
|
+
'invalid-record', 'duplicate', 'unlabelled-unknown', 'unlabelled-delayed', 'not-adjudicated-or-executed', 'missing-time', 'missing-group', 'group-straddles-split',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const DAY = 86_400_000;
|
|
53
|
+
const T = (s) => Date.parse(s);
|
|
54
|
+
const round = (x) => (x === null || x === undefined ? null : Math.round(x * 1e9) / 1e9);
|
|
55
|
+
|
|
56
|
+
export function validateCalibrationConfig(c) {
|
|
57
|
+
const errors = [];
|
|
58
|
+
const bad = (path, message) => errors.push({ code: 'BAD_CALIBRATION_CONFIG', path, message });
|
|
59
|
+
if (!c || typeof c !== 'object') return { ok: false, errors: [{ code: 'BAD_CALIBRATION_CONFIG', path: '', message: 'not an object' }] };
|
|
60
|
+
if (typeof c.id !== 'string' || !c.id) bad('id', 'a configuration has an id');
|
|
61
|
+
if (typeof c.baselineModel !== 'string' || !c.baselineModel) bad('baselineModel', 'the paired baseline model is named');
|
|
62
|
+
if (!c.timeSplit || Number.isNaN(T(c.timeSplit.heldOutFrom))) bad('timeSplit.heldOutFrom', 'an ISO timestamp is required');
|
|
63
|
+
if (c.heldOutModelVersions !== undefined && !Array.isArray(c.heldOutModelVersions)) bad('heldOutModelVersions', 'must be an array of { model, modelVersion }');
|
|
64
|
+
const m = c.minimums || {};
|
|
65
|
+
for (const [k, floor] of Object.entries(ROUTING_MINIMUMS)) {
|
|
66
|
+
if (m[k] !== undefined && !(Number.isInteger(m[k]) && m[k] >= floor)) bad(`minimums.${k}`, `cannot be set below the preregistered minimum of ${floor}`);
|
|
67
|
+
}
|
|
68
|
+
if (c.replicates !== undefined && !(Number.isInteger(c.replicates) && c.replicates >= 100)) bad('replicates', 'at least 100 bootstrap resamples');
|
|
69
|
+
return { ok: errors.length === 0, errors };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const keyOf = (o) => `${o.model}\u0000${o.modelVersion ?? ''}`;
|
|
73
|
+
const isDecided = (o) => (o.outcome === 'correct' || o.outcome === 'incorrect') && (o.labelSource === 'adjudication' || o.labelSource === 'trusted-execution');
|
|
74
|
+
|
|
75
|
+
function nearestRank(sorted, q) {
|
|
76
|
+
if (!sorted.length) return null;
|
|
77
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the calibration artifact.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} o
|
|
84
|
+
* @param {object[]} o.outcomes routing-outcome records (outcomes.js). Order does not matter.
|
|
85
|
+
* @param {object} o.config { id, version, baselineModel, timeSplit:{heldOutFrom}, heldOutModelVersions?, minimums?, replicates? }
|
|
86
|
+
* @returns {{ok:boolean, errors:object[], artifact?:object}}
|
|
87
|
+
*/
|
|
88
|
+
export function buildCalibration({ outcomes, config }) {
|
|
89
|
+
const cv = validateCalibrationConfig(config);
|
|
90
|
+
if (!cv.ok) return { ok: false, errors: cv.errors };
|
|
91
|
+
const minimums = { ...ROUTING_MINIMUMS, ...(config.minimums || {}) };
|
|
92
|
+
const replicates = config.replicates ?? 1000;
|
|
93
|
+
const heldVersions = new Set((config.heldOutModelVersions || []).map((v) => `${v.model}\u0000${v.modelVersion ?? ''}`));
|
|
94
|
+
const cutoff = T(config.timeSplit.heldOutFrom);
|
|
95
|
+
const configCore = { id: config.id, version: config.version ?? null, baselineModel: config.baselineModel, timeSplit: { heldOutFrom: config.timeSplit.heldOutFrom }, heldOutModelVersions: [...heldVersions].sort(), minimums, replicates };
|
|
96
|
+
const configHash = digestOf(configCore);
|
|
97
|
+
|
|
98
|
+
const exclusions = Object.fromEntries(EXCLUSION_REASONS.map((r) => [r, 0]));
|
|
99
|
+
const excludedIds = Object.fromEntries(EXCLUSION_REASONS.map((r) => [r, []]));
|
|
100
|
+
const exclude = (reason, id) => { exclusions[reason] += 1; if (id) excludedIds[reason].push(id); };
|
|
101
|
+
|
|
102
|
+
// 1. validity and duplicates
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const valid = [];
|
|
105
|
+
const input = Array.isArray(outcomes) ? outcomes : [];
|
|
106
|
+
for (const o of input) {
|
|
107
|
+
if (!validateRoutingOutcome(o).ok) { exclude('invalid-record', o?.id ?? null); continue; }
|
|
108
|
+
if (seen.has(o.id)) { exclude('duplicate', o.id); continue; }
|
|
109
|
+
seen.add(o.id); valid.push(o);
|
|
110
|
+
}
|
|
111
|
+
valid.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
112
|
+
|
|
113
|
+
const datasetHash = digestOf(valid.map((o) => ({
|
|
114
|
+
id: o.id, taskId: o.taskId, model: o.model, modelVersion: o.modelVersion, stratum: o.stratum, outcome: o.outcome, labelSource: o.labelSource,
|
|
115
|
+
adjudicationVersion: o.adjudicationVersion ?? null, group: o.group ?? null, observedAt: o.observedAt ?? null, status: o.status, synthetic: o.synthetic,
|
|
116
|
+
})));
|
|
117
|
+
const synthetic = valid.length > 0 && valid.some((o) => o.synthetic);
|
|
118
|
+
|
|
119
|
+
// 2. usable (decided) outcomes; operational facts about the rest are counted, never imputed
|
|
120
|
+
const usable = [];
|
|
121
|
+
for (const o of valid) {
|
|
122
|
+
if (!isDecided(o)) {
|
|
123
|
+
if (o.outcome === 'unknown') exclude('unlabelled-unknown', o.id);
|
|
124
|
+
else if (o.outcome === 'delayed') exclude('unlabelled-delayed', o.id);
|
|
125
|
+
else exclude('not-adjudicated-or-executed', o.id);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!o.observedAt || Number.isNaN(T(o.observedAt))) { exclude('missing-time', o.id); continue; }
|
|
129
|
+
if (!o.group) { exclude('missing-group', o.id); continue; }
|
|
130
|
+
usable.push(o);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3. splits
|
|
134
|
+
const isHeld = (o) => heldVersions.has(keyOf(o)) || T(o.observedAt) >= cutoff;
|
|
135
|
+
const dev = usable.filter((o) => !isHeld(o));
|
|
136
|
+
let held = usable.filter(isHeld);
|
|
137
|
+
const devGroups = new Set(dev.map((o) => o.group));
|
|
138
|
+
const straddlers = held.filter((o) => devGroups.has(o.group));
|
|
139
|
+
for (const o of straddlers) exclude('group-straddles-split', o.id);
|
|
140
|
+
const straddleIds = new Set(straddlers.map((o) => o.id));
|
|
141
|
+
held = held.filter((o) => !straddleIds.has(o.id));
|
|
142
|
+
|
|
143
|
+
// 4. pairing against the baseline, on held-out data
|
|
144
|
+
const baseline = config.baselineModel;
|
|
145
|
+
const labelledBy = new Map(); // taskId -> Set(model)
|
|
146
|
+
for (const o of held) { if (!labelledBy.has(o.taskId)) labelledBy.set(o.taskId, new Set()); labelledBy.get(o.taskId).add(o.model); }
|
|
147
|
+
const pairedTasks = [...labelledBy.entries()].filter(([, ms]) => ms.has(baseline) && [...ms].some((m) => m !== baseline)).map(([t]) => t).sort();
|
|
148
|
+
const overallPaired = pairedTasks.length;
|
|
149
|
+
const overallMet = overallPaired >= minimums.pairedTasksOverall;
|
|
150
|
+
const pairedSet = new Set(pairedTasks);
|
|
151
|
+
|
|
152
|
+
// 5. estimates per (model, version, stratum) and per parent level
|
|
153
|
+
const heldBy = new Map(); const devByModel = new Map();
|
|
154
|
+
const add = (map, k, o) => { if (!map.has(k)) map.set(k, []); map.get(k).push(o); };
|
|
155
|
+
for (const o of held) { add(heldBy, keyOf(o), o); }
|
|
156
|
+
for (const o of dev) { add(devByModel, o.model, o); }
|
|
157
|
+
const stratumOf = (o, depth) => o.stratum.split('|').slice(0, depth).join('|');
|
|
158
|
+
const modelVersions = [...new Set(valid.map(keyOf))].sort();
|
|
159
|
+
const estimates = [];
|
|
160
|
+
for (const mk of modelVersions) {
|
|
161
|
+
const [model, modelVersion] = mk.split('\u0000');
|
|
162
|
+
const heldRows = heldBy.get(mk) || [];
|
|
163
|
+
const strata = new Set(valid.filter((o) => keyOf(o) === mk).map((o) => o.stratum));
|
|
164
|
+
const levels = new Map(); // stratum key -> depth
|
|
165
|
+
for (const s of strata) { levels.set(s, 4); for (const p of stratumParents(s)) levels.set(p, p.split('|').length); }
|
|
166
|
+
for (const [stratum, depth] of [...levels.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
167
|
+
const rows = heldRows.filter((o) => stratumOf(o, depth) === stratum);
|
|
168
|
+
const devRows = (devByModel.get(model) || []).filter((o) => stratumOf(o, depth) === stratum);
|
|
169
|
+
const correct = rows.filter((o) => o.outcome === 'correct').length;
|
|
170
|
+
const n = rows.length;
|
|
171
|
+
const units = rows.map((o) => ({ id: o.id, language: 'all', tp: o.outcome === 'correct' ? 1 : 0, fn: o.outcome === 'correct' ? 0 : 1, fp: 0, tn: 0 }));
|
|
172
|
+
const groupOf = Object.fromEntries(rows.map((o) => [o.id, o.group]));
|
|
173
|
+
const interval = n === 0 ? { method: 'grouped-bootstrap-95', status: 'unmeasured', low: null, high: null, groups: 0, reason: 'no held-out outcomes' }
|
|
174
|
+
: groupedBootstrap({ units, groupOf, statistic: (a) => recallOf(a.total), name: `routing-quality|${mk}|${stratum}`, protocolHash: `${configHash}|${datasetHash}`, replicates });
|
|
175
|
+
const accuracy = n ? correct / n : null;
|
|
176
|
+
const w = wilsonInterval(correct, n);
|
|
177
|
+
const devN = devRows.length;
|
|
178
|
+
const devAcc = devN ? devRows.filter((o) => o.outcome === 'correct').length / devN : null;
|
|
179
|
+
let shift = 'untested';
|
|
180
|
+
if (devN >= minimums.developmentPerStratum && w.status === 'measured') shift = devAcc < w.low || devAcc > w.high ? 'shifted' : 'stable';
|
|
181
|
+
const paired = model === baseline ? n : rows.filter((o) => pairedSet.has(o.taskId)).length;
|
|
182
|
+
const scope = valid.filter((o) => keyOf(o) === mk && stratumOf(o, depth) === stratum);
|
|
183
|
+
const op = { total: scope.length, failed: 0, timeout: 0, cancelled: 0, providerError: 0, blocked: 0, partial: 0, unlabelled: 0 };
|
|
184
|
+
for (const o of scope) {
|
|
185
|
+
if (o.status === 'timeout') op.timeout += 1; else if (o.status === 'cancelled') op.cancelled += 1; else if (o.status === 'provider-error') op.providerError += 1;
|
|
186
|
+
else if (o.status === 'blocked') op.blocked += 1; else if (o.status === 'failed') op.failed += 1; else if (o.status === 'partial') op.partial += 1;
|
|
187
|
+
if (!isDecided(o)) op.unlabelled += 1;
|
|
188
|
+
}
|
|
189
|
+
const failuresNotInRows = scope.filter((o) => FAILURE_STATUSES.includes(o.status) && !isDecided(o)).length;
|
|
190
|
+
const reasons = [];
|
|
191
|
+
if (!overallMet) reasons.push(`only ${overallPaired} paired adjudicated task(s) overall; ${minimums.pairedTasksOverall} are required`);
|
|
192
|
+
if (paired < minimums.pairedTasksPerStratum) reasons.push(`only ${paired} paired adjudicated task(s) in this stratum; ${minimums.pairedTasksPerStratum} are required`);
|
|
193
|
+
if (n < minimums.pairedTasksPerStratum) reasons.push(`only ${n} held-out adjudicated outcome(s) for this model version; ${minimums.pairedTasksPerStratum} are required`);
|
|
194
|
+
if (interval.status !== 'measured') reasons.push(`uncertainty interval is ${interval.status}${interval.reason ? `: ${interval.reason}` : ''}`);
|
|
195
|
+
if (shift === 'untested') reasons.push(`no usable development baseline (needs ${minimums.developmentPerStratum}) to test for shift`);
|
|
196
|
+
if (shift === 'shifted') reasons.push('the development rate lies outside the held-out 95% interval');
|
|
197
|
+
const lat = scope.map((o) => o.latencyMs).filter((x) => typeof x === 'number').sort((x, y) => x - y);
|
|
198
|
+
const costs = scope;
|
|
199
|
+
const estimate = {
|
|
200
|
+
model, modelVersion: modelVersion || null, stratum, level: depth === 4 ? 'stratum' : 'parent',
|
|
201
|
+
n, correct, accuracy: round(accuracy), interval: { method: interval.method, status: interval.status, low: round(interval.low), high: round(interval.high), groups: interval.groups, ...(interval.reason ? { reason: interval.reason } : {}) },
|
|
202
|
+
development: { n: devN, accuracy: round(devAcc) }, shift, pairedWithBaseline: paired,
|
|
203
|
+
// operational failures are never silently ignored: the labelled rate above is over decided outcomes only
|
|
204
|
+
operational: op,
|
|
205
|
+
conservativeAccuracy: round(n + failuresNotInRows > 0 ? correct / (n + failuresNotInRows) : null),
|
|
206
|
+
latencyMs: { n: lat.length, p50: round(nearestRank(lat, 0.5)), p95: round(nearestRank(lat, 0.95)) },
|
|
207
|
+
costUsd: { n: costs.filter((o) => o.costUsd !== null).length, unknown: costs.filter((o) => o.costUsd === null).length, p50: round(medianKnown(costs.map((o) => o.costUsd))) },
|
|
208
|
+
latestObservationAt: rows.concat(dev.filter((o) => keyOf(o) === mk && stratumOf(o, depth) === stratum)).map((o) => o.observedAt).sort().pop() ?? null,
|
|
209
|
+
reasons,
|
|
210
|
+
label: null, fallback: null,
|
|
211
|
+
};
|
|
212
|
+
const clears = overallMet && paired >= minimums.pairedTasksPerStratum && n >= minimums.pairedTasksPerStratum && interval.status === 'measured' && shift === 'stable';
|
|
213
|
+
estimate.label = clears ? (synthetic ? 'reliable-synthetic' : 'reliable') : (shift === 'shifted' ? 'shifted' : 'insufficient-evidence');
|
|
214
|
+
estimates.push(estimate);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// 6. explicit fallback for every stratum-level estimate that is not reliable
|
|
218
|
+
const isReliable = (e) => e.label === 'reliable' || e.label === 'reliable-synthetic';
|
|
219
|
+
const find = (model, version, stratum) => estimates.find((e) => e.model === model && e.modelVersion === version && e.stratum === stratum);
|
|
220
|
+
for (const e of estimates) {
|
|
221
|
+
if (isReliable(e)) { e.fallback = null; continue; }
|
|
222
|
+
if (e.label !== 'shifted') {
|
|
223
|
+
for (const p of stratumParents(e.stratum)) {
|
|
224
|
+
const pe = find(e.model, e.modelVersion, p);
|
|
225
|
+
if (pe && isReliable(pe)) { e.fallback = { policy: 'parent-stratum', from: p, parentLabel: pe.label, parentAccuracy: pe.accuracy, parentInterval: pe.interval }; if (e.level === 'stratum') e.label = 'fallback-parent'; break; }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!e.fallback) e.fallback = { policy: 'baseline-model', model: baseline, note: 'no reliable evidence for this model version in this stratum; the baseline route stands' };
|
|
229
|
+
}
|
|
230
|
+
estimates.sort((a, b) => (`${a.model}|${a.modelVersion}|${a.stratum}` < `${b.model}|${b.modelVersion}|${b.stratum}` ? -1 : 1));
|
|
231
|
+
|
|
232
|
+
const adjudicationVersions = [...new Set(usable.map((o) => o.adjudicationVersion).filter(Boolean))].sort();
|
|
233
|
+
const byStatus = {};
|
|
234
|
+
for (const o of valid) byStatus[o.status] = (byStatus[o.status] || 0) + 1;
|
|
235
|
+
const artifact = {
|
|
236
|
+
schema: CALIBRATION_SCHEMA, schemaVersion: CALIBRATION_VERSION, synthetic,
|
|
237
|
+
config: configCore, configHash,
|
|
238
|
+
dataset: { hash: datasetHash, hashOf: 'sorted outcome identity, label, version, group and time fields', outcomeCount: input.length },
|
|
239
|
+
adjudicationVersions,
|
|
240
|
+
counts: { input: input.length, valid: valid.length, usable: usable.length, development: dev.length, heldOut: held.length, byStatus },
|
|
241
|
+
exclusions: { counts: exclusions, ids: Object.fromEntries(Object.entries(excludedIds).map(([k, v]) => [k, v.sort()])) },
|
|
242
|
+
pairing: { baselineModel: baseline, overallPairedHeldOut: overallPaired, requiredOverall: minimums.pairedTasksOverall, requiredPerStratum: minimums.pairedTasksPerStratum, overallMet },
|
|
243
|
+
latestObservationAt: valid.map((o) => o.observedAt).filter(Boolean).sort().pop() ?? null,
|
|
244
|
+
estimates,
|
|
245
|
+
disclosure: 'quality is the rate of independently decided correct outcomes on held-out grouped data; operational failures are counted beside it and never dropped',
|
|
246
|
+
};
|
|
247
|
+
artifact.calibrationHash = digestOf({ ...artifact, calibrationHash: undefined });
|
|
248
|
+
return { ok: true, errors: [], artifact: Object.freeze(artifact) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Rebuild from the same outcomes and config and compare. A different hash means the artifact was edited or the inputs changed. */
|
|
252
|
+
export function verifyCalibration(artifact, { outcomes, config }) {
|
|
253
|
+
if (!artifact || typeof artifact !== 'object') return { ok: false, reason: 'not an artifact' };
|
|
254
|
+
const claimed = artifact.calibrationHash;
|
|
255
|
+
const selfCheck = digestOf({ ...artifact, calibrationHash: undefined });
|
|
256
|
+
if (selfCheck !== claimed) return { ok: false, reason: 'the artifact does not match its own hash (edited after it was built)' };
|
|
257
|
+
const rebuilt = buildCalibration({ outcomes, config });
|
|
258
|
+
if (!rebuilt.ok) return { ok: false, reason: 'configuration rejected', errors: rebuilt.errors };
|
|
259
|
+
if (rebuilt.artifact.calibrationHash !== claimed) return { ok: false, reason: 'rebuilding from these outcomes and this configuration gives a different artifact' };
|
|
260
|
+
return { ok: true, reason: 'rebuilt identically' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The population half of the PRD section 5 routing-promotion row. It can say the evidence is `insufficient-population`, `unmeasured`
|
|
265
|
+
* (a synthetic population measures nothing real) or `population-sufficient`. It NEVER says pass: the paired quality, cost and latency
|
|
266
|
+
* comparison is what promotion needs (X-605) and does not exist here.
|
|
267
|
+
*/
|
|
268
|
+
export function routingPopulationGate(artifact) {
|
|
269
|
+
const base = { id: 'routing-promotion-population', thresholds: { ...ROUTING_MINIMUMS, ...ROUTING_PROMOTION_TARGETS }, promotion: 'not-evaluated' };
|
|
270
|
+
if (!artifact) return { ...base, status: 'unmeasured', reason: 'no calibration artifact' };
|
|
271
|
+
const paired = artifact.pairing?.overallPairedHeldOut ?? 0;
|
|
272
|
+
const strata = artifact.estimates.filter((e) => e.level === 'stratum' && e.model !== artifact.pairing.baselineModel && e.pairedWithBaseline >= artifact.pairing.requiredPerStratum);
|
|
273
|
+
const measured = { pairedTasks: paired, strataMeetingMinimum: strata.length };
|
|
274
|
+
if (artifact.synthetic) return { ...base, status: 'unmeasured', measured, reason: 'the population is synthetic: it exercises the machinery and measures nothing about real routing' };
|
|
275
|
+
if (paired < artifact.pairing.requiredOverall) return { ...base, status: 'insufficient-population', measured, reason: `${paired} paired adjudicated task(s); ${artifact.pairing.requiredOverall} are required` };
|
|
276
|
+
if (!strata.length) return { ...base, status: 'insufficient-population', measured, reason: `no candidate stratum has ${artifact.pairing.requiredPerStratum} paired adjudicated tasks` };
|
|
277
|
+
return { ...base, status: 'population-sufficient', measured, reason: 'the population minimums are met; promotion still needs the paired quality, cost and latency comparison, which is not evaluated here' };
|
|
278
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Canary window, circuit breaker and rollback (X-606.AC02), and the drift-aware route entry point (X-606.AC01).
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle (PRD section 8, routing policy): offline -> shadow -> canary -> active, with degradation sending the policy back to the
|
|
4
|
+
// previous known-good one. This module owns the last three steps.
|
|
5
|
+
//
|
|
6
|
+
// shadow the candidate only records proposals (shadow.js); production is the known-good policy
|
|
7
|
+
// canary a deterministic share of tasks (by task id hash, no randomness) is served by the candidate, under FINITE limits:
|
|
8
|
+
// a task count, a spend budget, an error-rate threshold, a consecutive-error (outage) threshold and an incorrect-rate
|
|
9
|
+
// threshold. An outcome with an unknown cost is charged at `perCallUsdCeiling`, never at zero.
|
|
10
|
+
// canary-complete the canary ran its finite window without tripping; the candidate receives no further traffic until `activate`
|
|
11
|
+
// active only reachable through `activate`, which needs a promotion verdict of `pass` (promotion.js): a canary that merely
|
|
12
|
+
// did not fail is not evidence of an advantage
|
|
13
|
+
// rolled-back a limit tripped (or drift invalidated the evidence); the known-good policy is restored and stays active
|
|
14
|
+
//
|
|
15
|
+
// Receipts are never lost. Every start, trip, completion, activation and rollback is appended to the hash-chained receipt log
|
|
16
|
+
// (receipts.js), and a rollback only appends. `observe` after a rollback is refused, so a degraded policy cannot keep accruing.
|
|
17
|
+
|
|
18
|
+
import { digestOf } from '../assurance/identity.js';
|
|
19
|
+
import { FAILURE_STATUSES } from './outcomes.js';
|
|
20
|
+
import { createReceiptLog } from './receipts.js';
|
|
21
|
+
import { routeConstrained } from './decide.js';
|
|
22
|
+
|
|
23
|
+
export const CANARY_STATES = Object.freeze(['shadow', 'canary', 'canary-complete', 'active', 'rolled-back']);
|
|
24
|
+
export const CANARY_CEILINGS = Object.freeze({ maxTasks: 10_000, budgetUsd: 1000 });
|
|
25
|
+
export const TRIP_CODES = Object.freeze(['budget-exhausted', 'provider-outage', 'error-rate', 'degraded-quality', 'drift-invalidation']);
|
|
26
|
+
|
|
27
|
+
function validateLimits(l) {
|
|
28
|
+
const errors = [];
|
|
29
|
+
const bad = (k, m) => errors.push({ path: k, message: m });
|
|
30
|
+
if (!l || typeof l !== 'object') return [{ path: '', message: 'limits are required: a canary has no unbounded mode' }];
|
|
31
|
+
if (!(Number.isInteger(l.maxTasks) && l.maxTasks >= 1 && l.maxTasks <= CANARY_CEILINGS.maxTasks)) bad('maxTasks', `a finite task count of 1 to ${CANARY_CEILINGS.maxTasks}`);
|
|
32
|
+
if (!(typeof l.budgetUsd === 'number' && l.budgetUsd > 0 && l.budgetUsd <= CANARY_CEILINGS.budgetUsd)) bad('budgetUsd', `a finite budget above 0 and at most ${CANARY_CEILINGS.budgetUsd}`);
|
|
33
|
+
if (!(typeof l.perCallUsdCeiling === 'number' && l.perCallUsdCeiling > 0)) bad('perCallUsdCeiling', 'a positive ceiling charged for any outcome of unknown cost');
|
|
34
|
+
if (!(typeof l.maxErrorRate === 'number' && l.maxErrorRate >= 0 && l.maxErrorRate <= 1)) bad('maxErrorRate', 'a rate in [0, 1]');
|
|
35
|
+
if (!(Number.isInteger(l.minSamples) && l.minSamples >= 1)) bad('minSamples', 'the sample count before rates are judged');
|
|
36
|
+
if (!(Number.isInteger(l.maxConsecutiveErrors) && l.maxConsecutiveErrors >= 1)) bad('maxConsecutiveErrors', 'the run of errors that counts as an outage');
|
|
37
|
+
if (!(typeof l.maxIncorrectRate === 'number' && l.maxIncorrectRate >= 0 && l.maxIncorrectRate <= 1)) bad('maxIncorrectRate', 'a rate in [0, 1]');
|
|
38
|
+
if (!(Number.isInteger(l.minDecided) && l.minDecided >= 1)) bad('minDecided', 'the decided-outcome count before quality is judged');
|
|
39
|
+
return errors;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const shareBucket = (taskId) => parseInt(digestOf({ canary: taskId }).slice('sha256:'.length, 'sha256:'.length + 8), 16) / 0x1_0000_0000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {{policyVersion:string}} o.knownGood the policy to restore (and to serve outside the canary)
|
|
47
|
+
* @param {{policyVersion:string}} o.candidate
|
|
48
|
+
* @param {object} o.limits
|
|
49
|
+
* @param {number} [o.canaryShare] fraction of tasks served by the candidate while the canary runs, in (0, 1]
|
|
50
|
+
* @param {object} [o.log] receipt log; one is created when absent
|
|
51
|
+
*/
|
|
52
|
+
export function createCanary({ knownGood, candidate, limits, canaryShare = 0.1, log = null }) {
|
|
53
|
+
const errs = validateLimits(limits);
|
|
54
|
+
if (!knownGood?.policyVersion || !candidate?.policyVersion) errs.push({ path: 'policy', message: 'both a known-good and a candidate policy version are required' });
|
|
55
|
+
if (!(typeof canaryShare === 'number' && canaryShare > 0 && canaryShare <= 1)) errs.push({ path: 'canaryShare', message: 'a share in (0, 1]' });
|
|
56
|
+
if (errs.length) return { ok: false, errors: errs };
|
|
57
|
+
const receipts = log || createReceiptLog();
|
|
58
|
+
let state = 'shadow';
|
|
59
|
+
let rollback = null;
|
|
60
|
+
const c = { candidateTasks: 0, spentUsd: 0, errors: 0, consecutiveErrors: 0, decided: 0, incorrect: 0, knownGoodTasks: 0 };
|
|
61
|
+
const note = (kind, body) => receipts.append(kind, { policyCandidate: candidate.policyVersion, policyKnownGood: knownGood.policyVersion, state, ...body });
|
|
62
|
+
const trip = (code, detail) => {
|
|
63
|
+
state = 'rolled-back';
|
|
64
|
+
rollback = { code, detail, restored: knownGood.policyVersion, counters: { ...c } };
|
|
65
|
+
note('rollback', { code, detail, restored: knownGood.policyVersion, counters: { ...c } });
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const api = {
|
|
69
|
+
state: () => state,
|
|
70
|
+
counters: () => ({ ...c }),
|
|
71
|
+
rollbackInfo: () => (rollback ? { ...rollback } : null),
|
|
72
|
+
limits: () => ({ ...limits, canaryShare }),
|
|
73
|
+
/** The policy that serves production right now. */
|
|
74
|
+
activePolicy: () => (state === 'active' ? candidate : knownGood),
|
|
75
|
+
receipts: receipts,
|
|
76
|
+
/** shadow -> canary. Needs at least one recorded shadow decision: a policy never seen proposing is not canaried. */
|
|
77
|
+
start({ shadowRecords = 0 } = {}) {
|
|
78
|
+
if (state !== 'shadow') return { ok: false, code: 'BAD_STATE', reason: `cannot start a canary from '${state}'` };
|
|
79
|
+
if (!(shadowRecords >= 1)) return { ok: false, code: 'NO_SHADOW_EVIDENCE', reason: 'a canary follows shadow mode: no shadow decision has been recorded' };
|
|
80
|
+
state = 'canary';
|
|
81
|
+
note('canary', { event: 'start', shadowRecords, limits: { ...limits, canaryShare } });
|
|
82
|
+
return { ok: true };
|
|
83
|
+
},
|
|
84
|
+
/** Which policy serves this task. Deterministic; the candidate is never selected outside the canary window or past its budget. */
|
|
85
|
+
select(taskId) {
|
|
86
|
+
if (state === 'active') return { arm: 'candidate', policyVersion: candidate.policyVersion };
|
|
87
|
+
if (state === 'canary' && c.candidateTasks < limits.maxTasks && c.spentUsd + limits.perCallUsdCeiling <= limits.budgetUsd && shareBucket(String(taskId)) < canaryShare) {
|
|
88
|
+
return { arm: 'candidate', policyVersion: candidate.policyVersion };
|
|
89
|
+
}
|
|
90
|
+
return { arm: 'known-good', policyVersion: knownGood.policyVersion };
|
|
91
|
+
},
|
|
92
|
+
/** Record the outcome of a task served by the candidate during the canary. */
|
|
93
|
+
observe({ taskId = null, outcome }) {
|
|
94
|
+
if (state !== 'canary') return { ok: false, code: 'NOT_IN_CANARY', reason: `the policy is '${state}'; no further observations are accepted` };
|
|
95
|
+
if (!outcome || typeof outcome !== 'object') return { ok: false, code: 'BAD_OUTCOME', reason: 'an outcome record is required' };
|
|
96
|
+
c.candidateTasks += 1;
|
|
97
|
+
c.spentUsd += typeof outcome.costUsd === 'number' ? outcome.costUsd : limits.perCallUsdCeiling;
|
|
98
|
+
const failed = FAILURE_STATUSES.includes(outcome.status);
|
|
99
|
+
c.errors += failed ? 1 : 0;
|
|
100
|
+
c.consecutiveErrors = failed ? c.consecutiveErrors + 1 : 0;
|
|
101
|
+
if (outcome.outcome === 'correct' || outcome.outcome === 'incorrect') { c.decided += 1; if (outcome.outcome === 'incorrect') c.incorrect += 1; }
|
|
102
|
+
note('canary', { event: 'observation', taskId, status: outcome.status, outcome: outcome.outcome, costUsd: outcome.costUsd ?? null, counters: { ...c } });
|
|
103
|
+
if (c.spentUsd > limits.budgetUsd) trip('budget-exhausted', `spent ${c.spentUsd} of a ${limits.budgetUsd} canary budget`);
|
|
104
|
+
else if (c.consecutiveErrors >= limits.maxConsecutiveErrors) trip('provider-outage', `${c.consecutiveErrors} consecutive failures (limit ${limits.maxConsecutiveErrors})`);
|
|
105
|
+
else if (c.candidateTasks >= limits.minSamples && c.errors / c.candidateTasks > limits.maxErrorRate) trip('error-rate', `${c.errors}/${c.candidateTasks} failed (limit ${limits.maxErrorRate})`);
|
|
106
|
+
else if (c.decided >= limits.minDecided && c.incorrect / c.decided > limits.maxIncorrectRate) trip('degraded-quality', `${c.incorrect}/${c.decided} decided outcomes incorrect (limit ${limits.maxIncorrectRate})`);
|
|
107
|
+
else if (c.candidateTasks >= limits.maxTasks) { state = 'canary-complete'; note('canary', { event: 'window-complete', counters: { ...c } }); }
|
|
108
|
+
return { ok: true, state, tripped: state === 'rolled-back' ? rollback.code : null };
|
|
109
|
+
},
|
|
110
|
+
/** An outside signal (drift.js) that the evidence behind the candidate is invalid. Restores the known-good policy. */
|
|
111
|
+
reportDrift(assessment) {
|
|
112
|
+
if (!assessment || assessment.state === 'active' || assessment.unchanged) return { ok: true, rolledBack: false };
|
|
113
|
+
if (state === 'rolled-back') return { ok: true, rolledBack: true, already: true };
|
|
114
|
+
trip('drift-invalidation', `${(assessment.kinds || []).join(', ') || 'unspecified'}: ${(assessment.invalidations || []).length} estimate(s) invalidated`);
|
|
115
|
+
return { ok: true, rolledBack: true };
|
|
116
|
+
},
|
|
117
|
+
/** canary-complete -> active, only on a passing promotion verdict. */
|
|
118
|
+
activate(verdict) {
|
|
119
|
+
if (state !== 'canary-complete') return { ok: false, code: 'BAD_STATE', reason: `cannot activate from '${state}'` };
|
|
120
|
+
if (!verdict || verdict.status !== 'pass') return { ok: false, code: 'NOT_PROMOTED', reason: `the promotion gate reads '${verdict ? verdict.status : 'absent'}': a canary that did not fail is not evidence of an advantage` };
|
|
121
|
+
state = 'active';
|
|
122
|
+
note('canary', { event: 'activate', verdictHash: verdict.verdictHash ?? null });
|
|
123
|
+
return { ok: true };
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
return { ok: true, canary: api };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Route under the drift state. `fallback`: the production route stands and nothing is decided. `shadow`: the proposal is recorded
|
|
131
|
+
* (through the recorder) and production stands. `active`: the constrained router decides.
|
|
132
|
+
*/
|
|
133
|
+
export function routeUnderDrift({ assessment, recorder, productionRoute, ...decideArgs }) {
|
|
134
|
+
const state = assessment?.state ?? 'fallback';
|
|
135
|
+
if (state === 'active') {
|
|
136
|
+
const decision = routeConstrained({ ...decideArgs, baselineRoute: productionRoute });
|
|
137
|
+
return { route: decision.selected ? { model: decision.selected.model } : null, decision, state };
|
|
138
|
+
}
|
|
139
|
+
if (state === 'shadow' && recorder) { const r = recorder.shadow({ productionRoute, ...decideArgs }); return { route: productionRoute, shadow: r.shadow, state, reason: assessment.documentedFallback }; }
|
|
140
|
+
return { route: productionRoute, state: 'fallback', reason: assessment?.documentedFallback ?? 'no drift assessment: the existing route stands' };
|
|
141
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Operator control over adaptive routing (X-608.AC03): adaptive (default), pinned to one model, or disabled.
|
|
2
|
+
//
|
|
3
|
+
// Precedence, highest first: explicit options (a CLI flag), then the environment (`AGENTIC_SECURITY_ROUTING`), then the default.
|
|
4
|
+
// Values: `adaptive`, `disabled` (the existing capability route, untouched), `pin:<model>` (that model, no optimisation).
|
|
5
|
+
// An unreadable value FAILS CLOSED to `disabled` and says so; it never falls through to adaptive.
|
|
6
|
+
//
|
|
7
|
+
// A pin chooses a model. It does not clear the egress policy: sending a prompt still goes through the guarded adapter, which checks
|
|
8
|
+
// the egress policy at call time. `routeModelWithPolicy` (model-routing.js) honours this control.
|
|
9
|
+
|
|
10
|
+
export const ROUTING_MODES = Object.freeze(['adaptive', 'pinned', 'disabled']);
|
|
11
|
+
export const ROUTING_ENV = 'AGENTIC_SECURITY_ROUTING';
|
|
12
|
+
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
13
|
+
|
|
14
|
+
function parse(raw) {
|
|
15
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
16
|
+
const v = String(raw).trim();
|
|
17
|
+
if (v === 'adaptive') return { mode: 'adaptive', pin: null };
|
|
18
|
+
if (v === 'disabled' || v === 'off') return { mode: 'disabled', pin: null };
|
|
19
|
+
if (v.startsWith('pin:')) {
|
|
20
|
+
const model = v.slice(4);
|
|
21
|
+
return MODEL_ID.test(model) ? { mode: 'pinned', pin: model } : { error: `'${v}' does not name a valid model id` };
|
|
22
|
+
}
|
|
23
|
+
return { error: `'${v}' is not one of adaptive, disabled, pin:<model>` };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveRoutingControl({ env = process.env, options = {} } = {}) {
|
|
27
|
+
const candidates = [['option', options?.routing], ['environment', env?.[ROUTING_ENV]]];
|
|
28
|
+
for (const [source, raw] of candidates) {
|
|
29
|
+
const p = parse(raw);
|
|
30
|
+
if (!p) continue;
|
|
31
|
+
if (p.error) return Object.freeze({ mode: 'disabled', pin: null, source, error: p.error, reason: `invalid routing control (${p.error}); adaptive routing is off` });
|
|
32
|
+
return Object.freeze({ ...p, source, error: null, reason: p.mode === 'adaptive' ? 'adaptive routing selected' : p.mode === 'pinned' ? `routing pinned to ${p.pin}` : 'adaptive routing disabled by the operator' });
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze({ mode: 'adaptive', pin: null, source: 'default', error: null, reason: 'default' });
|
|
35
|
+
}
|