@aiwg/cli 2026.9.21 → 2026.10.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/agentic/code/providers/capability-matrix.yaml +2 -1
- package/dist/src/artifacts/cli.js +23 -18
- package/dist/src/artifacts/discovery-shadow-evaluator.js +177 -0
- package/dist/src/artifacts/discovery-shadow.js +195 -0
- package/dist/src/artifacts/fortemi-core-query-adapter.js +6 -2
- package/dist/src/artifacts/index-builder.js +47 -9
- package/dist/src/artifacts/query-engine.js +108 -5
- package/dist/src/artifacts/types.js +1 -0
- package/dist/src/cli/handlers/decision.js +88 -0
- package/dist/src/cli/handlers/gates.js +154 -0
- package/dist/src/cli/handlers/index.js +5 -1
- package/dist/src/cli/handlers/sessions.js +10 -7
- package/dist/src/cli/handlers/utilities.js +20 -0
- package/dist/src/config/aiwg-config.js +15 -2
- package/dist/src/decision/acceptance.js +25 -0
- package/dist/src/decision/adapters/jev.js +24 -2
- package/dist/src/decision/calibration/registry.js +9 -0
- package/dist/src/decision/compose.js +74 -15
- package/dist/src/decision/context-live-promotion.js +344 -0
- package/dist/src/decision/context-live-qualification.js +591 -0
- package/dist/src/decision/context-pruning.js +847 -0
- package/dist/src/decision/driver.js +430 -0
- package/dist/src/decision/egress-live-qualification.js +874 -0
- package/dist/src/decision/ensemble/contract.js +64 -1
- package/dist/src/decision/ensemble/index.js +1 -0
- package/dist/src/decision/ensemble/report.js +8 -42
- package/dist/src/decision/ensemble/runtime.js +571 -0
- package/dist/src/decision/ensemble-study/artifacts.js +85 -0
- package/dist/src/decision/ensemble-study/calibration.js +443 -0
- package/dist/src/decision/ensemble-study/corpus.js +229 -0
- package/dist/src/decision/ensemble-study/multifact-artifacts.js +23 -0
- package/dist/src/decision/ensemble-study/multifact-study.js +596 -0
- package/dist/src/decision/ensemble-study/multifact.js +241 -0
- package/dist/src/decision/ensemble-study/protocol.js +77 -0
- package/dist/src/decision/ensemble-study/score.js +462 -0
- package/dist/src/decision/ensemble-study/staged.js +87 -0
- package/dist/src/decision/ensemble-study/statistics.js +84 -0
- package/dist/src/decision/evaluate.js +227 -14
- package/dist/src/decision/export.js +4 -0
- package/dist/src/decision/feature-export.js +1074 -0
- package/dist/src/decision/graph-index.js +16 -0
- package/dist/src/decision/graph-live-qualification.js +1041 -0
- package/dist/src/decision/graph-live-workload.js +245 -0
- package/dist/src/decision/graph-skill-bridge.js +21 -3
- package/dist/src/decision/heldout/calibration.js +140 -0
- package/dist/src/decision/heldout/collector.js +430 -0
- package/dist/src/decision/heldout/contract.js +309 -0
- package/dist/src/decision/heldout/d29-generator-ids.js +12 -0
- package/dist/src/decision/heldout/d29-passage-baseline-v2.js +93 -0
- package/dist/src/decision/heldout/d29-passage-baseline-v3.js +100 -0
- package/dist/src/decision/heldout/d29-passage-baseline.js +66 -0
- package/dist/src/decision/heldout/d29-pools-v8.js +575 -0
- package/dist/src/decision/heldout/d29-pools.js +288 -0
- package/dist/src/decision/heldout/d29-v6.js +169 -0
- package/dist/src/decision/heldout/d29-v7.js +202 -0
- package/dist/src/decision/heldout/d29-v8.js +586 -0
- package/dist/src/decision/heldout/generator-registry.js +67 -0
- package/dist/src/decision/heldout/generators.js +696 -0
- package/dist/src/decision/heldout/index.js +8 -0
- package/dist/src/decision/heldout/journal.js +346 -0
- package/dist/src/decision/heldout/study-generators.js +64 -0
- package/dist/src/decision/heldout/types.js +2 -0
- package/dist/src/decision/host-policy.js +61 -0
- package/dist/src/decision/index.js +11 -0
- package/dist/src/decision/interop-profile.v1.json +112 -0
- package/dist/src/decision/interop.js +653 -0
- package/dist/src/decision/issue-triage/index.js +3 -0
- package/dist/src/decision/issue-triage/runtime.js +1069 -0
- package/dist/src/decision/issue-triage/types.js +2 -0
- package/dist/src/decision/patterns/catalog.js +28 -8
- package/dist/src/decision/patterns/playground.js +28 -11
- package/dist/src/decision/patterns/specs.js +19 -10
- package/dist/src/decision/preprocessed-evidence.js +724 -0
- package/dist/src/decision/qualification/capture.js +8 -2
- package/dist/src/decision/qualification/gate-evidence.js +19 -4
- package/dist/src/decision/qualification/load.js +8 -1
- package/dist/src/decision/qualification/quality.js +57 -49
- package/dist/src/decision/qualification/release.js +76 -3
- package/dist/src/decision/qualification/runner.js +5 -2
- package/dist/src/decision/routing/contract.js +175 -0
- package/dist/src/decision/routing/index.js +5 -0
- package/dist/src/decision/routing/report.js +216 -0
- package/dist/src/decision/routing/runtime.js +685 -0
- package/dist/src/decision/routing/types.js +2 -0
- package/dist/src/decision/sdlc-screening.js +1157 -0
- package/dist/src/decision/sensitivity/comparative.js +154 -0
- package/dist/src/decision/sensitivity/contract.js +218 -0
- package/dist/src/decision/sensitivity/index.js +5 -0
- package/dist/src/decision/sensitivity/runtime.js +912 -0
- package/dist/src/decision/sensitivity/types.js +2 -0
- package/dist/src/decision/validate.js +26 -0
- package/dist/src/extensions/commands/definitions.js +38 -0
- package/dist/src/extensions/manifest.js +44 -0
- package/dist/src/gates/discovery.js +315 -0
- package/dist/src/gates/driver.js +388 -0
- package/dist/src/gates/evaluate.js +719 -0
- package/dist/src/gates/floors.js +255 -0
- package/dist/src/gates/index.js +17 -0
- package/dist/src/gates/providers/evidence.js +38 -0
- package/dist/src/gates/providers/index.js +21 -0
- package/dist/src/gates/providers/isolated-child-source.js +446 -0
- package/dist/src/gates/providers/loader.js +1069 -0
- package/dist/src/gates/providers/paired.js +52 -0
- package/dist/src/gates/providers/proportion.js +60 -0
- package/dist/src/gates/providers/registry.js +40 -0
- package/dist/src/gates/providers/scalar.js +41 -0
- package/dist/src/gates/providers/screening.js +53 -0
- package/dist/src/gates/providers/types.js +25 -0
- package/dist/src/gates/registry.js +1003 -0
- package/dist/src/gates/report.js +102 -0
- package/dist/src/gates/schema.js +56 -0
- package/dist/src/gates/stats/binomial.js +26 -0
- package/dist/src/gates/stats/bootstrap.js +62 -0
- package/dist/src/gates/stats/clopper-pearson.js +95 -0
- package/dist/src/gates/stats/digest.js +26 -0
- package/dist/src/gates/stats/error.js +5 -0
- package/dist/src/gates/stats/index.js +9 -0
- package/dist/src/gates/stats/normal.js +35 -0
- package/dist/src/gates/stats/paired.js +120 -0
- package/dist/src/gates/stats/splits.js +30 -0
- package/dist/src/gates/types.js +8 -0
- package/dist/src/governance/redaction.js +14 -4
- package/dist/src/mcp/muse-native-config.mjs +123 -0
- package/dist/src/mcp/tools/decision.mjs +188 -0
- package/dist/src/mcp/tools/subsystems.mjs +2 -0
- package/dist/src/providers/capability-matrix.yaml +2 -1
- package/dist/src/providers/provider-definitions.js +2 -1
- package/dist/src/sessions/adapters/muse.js +336 -39
- package/dist/src/sessions/batch-import.js +7 -0
- package/dist/src/sessions/workspace-discovery.js +13 -2
- package/package.json +1 -1
- package/schemas/decision/ConformalFrozenOpenData.v2.schema.json +371 -0
- package/schemas/decision/ConformalOpenDataReport.v2.schema.json +421 -0
- package/schemas/decision/ConformalPreregistration.v2.schema.json +529 -0
- package/schemas/decision/ContextPruningCandidate.v1.schema.json +47 -0
- package/schemas/decision/ContextPruningEvaluationReport.v1.schema.json +758 -0
- package/schemas/decision/ContextPruningPreregistration.v1.schema.json +175 -0
- package/schemas/decision/ContextPruningReceipt.v1.schema.json +72 -0
- package/schemas/decision/D17Multifact.v1.schema.json +1851 -0
- package/schemas/decision/D17StudyAnalysis.v1.schema.json +42 -0
- package/schemas/decision/D17StudyAnalysis.v2.schema.json +51 -0
- package/schemas/decision/D17StudyCalibration.v1.schema.json +819 -0
- package/schemas/decision/D17StudyDryRun.v1.schema.json +169 -0
- package/schemas/decision/D17StudyDryRun.v2.schema.json +239 -0
- package/schemas/decision/D17StudyGold.v1.schema.json +108 -0
- package/schemas/decision/D17StudyGuide.v1.schema.json +63 -0
- package/schemas/decision/D17StudyNativeTemplates.v1.schema.json +619 -0
- package/schemas/decision/D17StudyProtocol.v1.schema.json +241 -0
- package/schemas/decision/D17StudyReport.v1.schema.json +902 -0
- package/schemas/decision/D17StudyReport.v2.schema.json +1319 -0
- package/schemas/decision/D17StudyReview.v1.schema.json +175 -0
- package/schemas/decision/D17StudySplits.v1.schema.json +217 -0
- package/schemas/decision/D29Study.v1.schema.json +1903 -0
- package/schemas/decision/D29Study.v2.schema.json +1344 -0
- package/schemas/decision/D29Study.v3.schema.json +609 -0
- package/schemas/decision/D29Study.v4.schema.json +1133 -0
- package/schemas/decision/D29Study.v6.schema.json +1205 -0
- package/schemas/decision/D29Study.v7.schema.json +1471 -0
- package/schemas/decision/D29Study.v8.schema.json +671 -0
- package/schemas/decision/D29Study.v9.schema.json +778 -0
- package/schemas/decision/DecisionComparativeReplayAuditSample.v1.schema.json +137 -0
- package/schemas/decision/DecisionComparativeReplayCorpus.v1.schema.json +258 -0
- package/schemas/decision/DecisionComparativeReplayFreeze.v1.schema.json +74 -0
- package/schemas/decision/DecisionComparativeReplayGold.v1.schema.json +50 -0
- package/schemas/decision/DecisionComparativeReplayGoldAsset.v1.schema.json +37 -0
- package/schemas/decision/DecisionComparativeReplayIntegrity.v1.schema.json +81 -0
- package/schemas/decision/DecisionComparativeReplayMembers.v1.schema.json +52 -0
- package/schemas/decision/DecisionComparativeReplayPreregistration.v1.schema.json +113 -0
- package/schemas/decision/DecisionComparativeReplayReport.v1.schema.json +407 -0
- package/schemas/decision/DecisionComparativeReplayReviewPackets.v1.schema.json +260 -0
- package/schemas/decision/DecisionComparativeReplaySplit.v1.schema.json +84 -0
- package/schemas/decision/DecisionFeatureObservation.v1.schema.json +564 -0
- package/schemas/decision/DecisionFeatureSet.v1.schema.json +155 -0
- package/schemas/decision/DecisionInteropMapping.v1.schema.json +85 -0
- package/schemas/decision/DecisionIssueTriageEvaluationManifest.v1.schema.json +174 -0
- package/schemas/decision/DecisionIssueTriageEvaluationReport.v1.schema.json +280 -0
- package/schemas/decision/DecisionIssueTriagePilotPack.v1.schema.json +114 -0
- package/schemas/decision/DecisionRouteReceipt.v1.schema.json +155 -0
- package/schemas/decision/DecisionRoutingPolicy.v1.schema.json +132 -0
- package/schemas/decision/DecisionRoutingShadowReport.v1.schema.json +170 -0
- package/schemas/decision/DecisionRoutingTask.v1.schema.json +49 -0
- package/schemas/decision/DecisionRuleset.schema.json +8 -2
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +8 -2
- package/schemas/decision/DecisionSensitivityPlan.v1.schema.json +191 -0
- package/schemas/decision/DecisionSensitivityReport.v1.schema.json +163 -0
- package/schemas/decision/HeldoutApproval.v1.schema.json +312 -0
- package/schemas/decision/HeldoutAttempt.v1.schema.json +211 -0
- package/schemas/decision/HeldoutBaseline.v1.schema.json +90 -0
- package/schemas/decision/HeldoutCalibrationPhase.v1.schema.json +87 -0
- package/schemas/decision/HeldoutCorpus.v1.schema.json +167 -0
- package/schemas/decision/HeldoutEvent.v1.schema.json +41 -0
- package/schemas/decision/HeldoutFrozen.v1.schema.json +142 -0
- package/schemas/decision/HeldoutPreregistration.v1.schema.json +222 -0
- package/schemas/decision/HeldoutSpendEvent.v1.schema.json +122 -0
- package/schemas/decision/HeldoutSpendHead.v1.schema.json +25 -0
- package/schemas/decision/HeldoutSpendHead.v2.schema.json +46 -0
- package/schemas/decision/HeldoutSummary.v1.schema.json +101 -0
- package/schemas/decision/OpaDecisionLogExport.v1.schema.json +65 -0
- package/schemas/decision/PreprocessedEvidence.v1.schema.json +306 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +175 -0
- package/schemas/decision/SdlcEvidenceScreening.v1.schema.json +499 -0
- package/schemas/decision/SdlcScreeningPreregistration.v1.schema.json +132 -0
- package/schemas/decision/SdlcScreeningPreregistration.v2.schema.json +138 -0
- package/schemas/decision/SdlcScreeningRelease.v1.schema.json +654 -0
- package/schemas/decision/SdlcScreeningRelease.v2.schema.json +654 -0
- package/tools/agents/deploy-agents.mjs +1 -1
- package/tools/agents/providers/base.mjs +26 -7
- package/tools/agents/providers/factory.mjs +10 -2
- package/tools/agents/providers/muse-hooks.mjs +1 -29
- package/tools/agents/providers/muse.mjs +6 -3
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { qualificationIntegrityAllowlistProblems } from '../decision/qualification/release.js';
|
|
2
|
+
import { artifactDigest } from '../decision/validate.js';
|
|
3
|
+
import { evaluatePredicate } from '../decision/predicates.js';
|
|
4
|
+
import { clopperPearsonInterval } from './stats/clopper-pearson.js';
|
|
5
|
+
import { wilsonScoreInterval } from './stats/binomial.js';
|
|
6
|
+
import { pairedMeanDifferenceBootstrap } from './stats/bootstrap.js';
|
|
7
|
+
import { PairedDifferenceError } from './stats/error.js';
|
|
8
|
+
import { pairedBinaryDifferenceInterval, pairedNonInferiority } from './stats/paired.js';
|
|
9
|
+
import { GateRegistry, authoredGatePacksOf, gateProvidersOf, resolveGateBinding } from './registry.js';
|
|
10
|
+
import { GATE_PROVIDER_DEFAULT_TIMEOUT_MS, GATE_PROVIDER_MAX_OUTPUT_BYTES, deriveIsolatedSeed, isLoaderIssuedProvider, runIsolatedProviderSync, sealProviderSection, } from './providers/loader.js';
|
|
11
|
+
import { qualifyGateParameter } from './types.js';
|
|
12
|
+
export class GateEvaluationError extends Error {
|
|
13
|
+
constructor(message) { super(message); this.name = 'GateEvaluationError'; }
|
|
14
|
+
}
|
|
15
|
+
const RANK = { PROMOTE: 0, HOLD: 1, ROLLBACK: 2 };
|
|
16
|
+
/** Shared outcome lattice. The maximum never upgrades any component. */
|
|
17
|
+
export function maxOutcome(...outcomes) {
|
|
18
|
+
return outcomes.reduce((worst, outcome) => (RANK[outcome] > RANK[worst] ? outcome : worst), 'PROMOTE');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Per-gate outcome from its own status: `pass` promotes, `fail` takes the
|
|
22
|
+
* gate's `onFail`, `insufficient` takes `onInsufficient` (always HOLD by load
|
|
23
|
+
* validation; ROLLBACK needs an observed blocking failure). Shortfalls never PROMOTE.
|
|
24
|
+
*/
|
|
25
|
+
export function gateStatusOutcome(status, gate) {
|
|
26
|
+
if (status === 'pass')
|
|
27
|
+
return 'PROMOTE';
|
|
28
|
+
if (status === 'fail')
|
|
29
|
+
return gate.onFail;
|
|
30
|
+
return gate.onInsufficient ?? 'HOLD';
|
|
31
|
+
}
|
|
32
|
+
/** Binds an integrity report for evaluation. The digest is re-derived on every use; a forged copy is refused. */
|
|
33
|
+
export function sealUpstream(metadata) {
|
|
34
|
+
return { metadata, digest: artifactDigest(metadata) };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Seals trusted holdout inputs for evaluation. The seal covers
|
|
38
|
+
* `{frozenDigest, firstAccessedAt}` and is re-derived by `evaluateGates` on
|
|
39
|
+
* every use, following the HeldoutFrozen seal (`readHeldoutFrozen` checks
|
|
40
|
+
* `frozen.digest === heldoutDigest(frozen.bundle)`): a spread-copied or edited
|
|
41
|
+
* record is refused. The seal is an unkeyed integrity digest, not an
|
|
42
|
+
* authenticity proof, so the holdout inputs are trusted caller inputs built from
|
|
43
|
+
* verified HeldoutFrozen and access records (verification of the frozen record
|
|
44
|
+
* itself is tracked in #2833). `frozenDigest` is the binding digest in the frozen record.
|
|
45
|
+
*/
|
|
46
|
+
export function sealGateHoldout(input) {
|
|
47
|
+
const payload = { frozenDigest: input.frozenDigest, firstAccessedAt: input.firstAccessedAt };
|
|
48
|
+
return { ...payload, digest: artifactDigest(payload) };
|
|
49
|
+
}
|
|
50
|
+
const fail = (message) => { throw new GateEvaluationError(message); };
|
|
51
|
+
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
52
|
+
function supportOf(observation) {
|
|
53
|
+
const n = observation?.n;
|
|
54
|
+
return typeof n === 'number' && Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
55
|
+
}
|
|
56
|
+
function poolObservations(kind, observations) {
|
|
57
|
+
if (!observations.length)
|
|
58
|
+
return null;
|
|
59
|
+
if (kind === 'proportion') {
|
|
60
|
+
let n = 0;
|
|
61
|
+
let events = 0;
|
|
62
|
+
for (const observation of observations) {
|
|
63
|
+
if (supportOf(observation) === null || typeof observation.events !== 'number'
|
|
64
|
+
|| !Number.isSafeInteger(observation.events) || observation.events < 0)
|
|
65
|
+
return null;
|
|
66
|
+
if (observation.events > observation.n)
|
|
67
|
+
return null;
|
|
68
|
+
n += observation.n;
|
|
69
|
+
events += observation.events;
|
|
70
|
+
}
|
|
71
|
+
return { n, events };
|
|
72
|
+
}
|
|
73
|
+
if (kind === 'paired') {
|
|
74
|
+
const total = { both: 0, candidateOnly: 0, baselineOnly: 0, neither: 0 };
|
|
75
|
+
let n = 0;
|
|
76
|
+
for (const observation of observations) {
|
|
77
|
+
const cells = isRecord(observation?.paired)
|
|
78
|
+
? ['both', 'candidateOnly', 'baselineOnly', 'neither'].map(key => observation.paired[key])
|
|
79
|
+
: [];
|
|
80
|
+
if (supportOf(observation) === null || cells.length !== 4
|
|
81
|
+
|| cells.some(cell => !Number.isSafeInteger(cell) || cell < 0)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const [both, candidateOnly, baselineOnly, neither] = cells;
|
|
85
|
+
// The counted support must equal the cell sums; a disagreement is unknown, fail closed.
|
|
86
|
+
if (observation.n !== both + candidateOnly + baselineOnly + neither)
|
|
87
|
+
return null;
|
|
88
|
+
n += observation.n;
|
|
89
|
+
total.both += both;
|
|
90
|
+
total.candidateOnly += candidateOnly;
|
|
91
|
+
total.baselineOnly += baselineOnly;
|
|
92
|
+
total.neither += neither;
|
|
93
|
+
}
|
|
94
|
+
return { n, paired: total };
|
|
95
|
+
}
|
|
96
|
+
if (kind === 'scalar') {
|
|
97
|
+
let n = 0;
|
|
98
|
+
let weighted = 0;
|
|
99
|
+
for (const observation of observations) {
|
|
100
|
+
if (supportOf(observation) === null || typeof observation.value !== 'number' || !Number.isFinite(observation.value))
|
|
101
|
+
return null;
|
|
102
|
+
n += observation.n;
|
|
103
|
+
weighted += observation.n * observation.value;
|
|
104
|
+
}
|
|
105
|
+
return n === 0 ? null : { n, value: weighted / n };
|
|
106
|
+
}
|
|
107
|
+
if (kind === 'differences') {
|
|
108
|
+
const differences = [];
|
|
109
|
+
let n = 0;
|
|
110
|
+
for (const observation of observations) {
|
|
111
|
+
if (supportOf(observation) === null || !Array.isArray(observation.differences)
|
|
112
|
+
|| observation.differences.some(value => typeof value !== 'number' || !Number.isFinite(value)))
|
|
113
|
+
return null;
|
|
114
|
+
if (observation.n !== observation.differences.length)
|
|
115
|
+
return null;
|
|
116
|
+
n += observation.n;
|
|
117
|
+
differences.push(...observation.differences);
|
|
118
|
+
}
|
|
119
|
+
return { n, differences };
|
|
120
|
+
}
|
|
121
|
+
if (kind === 'evidence') {
|
|
122
|
+
const available = observations.flatMap(observation => observation?.available ?? []);
|
|
123
|
+
if (observations.some(observation => !Array.isArray(observation?.available)))
|
|
124
|
+
return null;
|
|
125
|
+
return { available };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function seriesFor(gate, pack, metrics) {
|
|
130
|
+
if (gate.metric === undefined)
|
|
131
|
+
return null;
|
|
132
|
+
const declared = pack.resolved.spec.metrics[gate.metric.name];
|
|
133
|
+
if (declared === undefined || declared.provider !== gate.metric.provider)
|
|
134
|
+
return null;
|
|
135
|
+
return metrics.providers[gate.metric.provider]?.metrics[gate.metric.name] ?? null;
|
|
136
|
+
}
|
|
137
|
+
function metricKindOf(gate, pack) {
|
|
138
|
+
if (gate.metric === undefined)
|
|
139
|
+
return '';
|
|
140
|
+
return pack.resolved.spec.metrics[gate.metric.name]?.kind ?? '';
|
|
141
|
+
}
|
|
142
|
+
function expandScope(gate, kind, series, slices) {
|
|
143
|
+
const mode = gate.scope.mode;
|
|
144
|
+
if (mode === 'all')
|
|
145
|
+
return [{ slice: null, observation: series?.pooled ?? null }];
|
|
146
|
+
if (mode === 'pooled') {
|
|
147
|
+
const observations = (gate.scope.slices ?? []).map(slice => series?.bySlice[slice] ?? null);
|
|
148
|
+
if (observations.some(observation => observation === null)) {
|
|
149
|
+
return [{ slice: null, observation: null }];
|
|
150
|
+
}
|
|
151
|
+
return [{ slice: null, observation: poolObservations(kind, observations) }];
|
|
152
|
+
}
|
|
153
|
+
const excluded = new Set(gate.scope.except ?? []);
|
|
154
|
+
const names = mode === 'each' ? slices.filter(slice => !excluded.has(slice)) : (gate.scope.slices ?? []);
|
|
155
|
+
return names.map(slice => ({ slice, observation: series?.bySlice[slice] ?? null }));
|
|
156
|
+
}
|
|
157
|
+
function pairedCells(observation) {
|
|
158
|
+
if (!isRecord(observation?.paired))
|
|
159
|
+
return null;
|
|
160
|
+
const cells = ['both', 'candidateOnly', 'baselineOnly', 'neither'].map(key => observation.paired[key]);
|
|
161
|
+
if (cells.some(cell => !Number.isSafeInteger(cell) || cell < 0))
|
|
162
|
+
return null;
|
|
163
|
+
const [both, candidateOnly, baselineOnly, neither] = cells;
|
|
164
|
+
return { both, candidateOnly, baselineOnly, neither };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Resolve a literal-or-parameter gate number against the binding's
|
|
168
|
+
* preregistered values. Null when a `{param}` reference has no resolved
|
|
169
|
+
* value: callers hold as `parameter-missing`, never promote.
|
|
170
|
+
*/
|
|
171
|
+
function resolveGateNumber(value, packId, parameters) {
|
|
172
|
+
if (value === undefined)
|
|
173
|
+
return null;
|
|
174
|
+
if (typeof value === 'number')
|
|
175
|
+
return value;
|
|
176
|
+
if (value !== null && typeof value === 'object' && typeof value.param === 'string') {
|
|
177
|
+
const resolved = parameters[qualifyGateParameter(packId, value.param)];
|
|
178
|
+
return typeof resolved === 'number' ? resolved : null;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function evaluateObservation(gate, observation, context) {
|
|
183
|
+
const { packId, parameters, metrics, upstream, nowMs, slice, slices } = context;
|
|
184
|
+
const base = { slice, n: supportOf(observation) };
|
|
185
|
+
const minimumN = gate.minimumN === undefined ? 0 : resolveGateNumber(gate.minimumN, packId, parameters);
|
|
186
|
+
// Evidence gates observe attestations, not counted support; upstream and predicate
|
|
187
|
+
// gates read the ceiling and the whole document. None of them take an n.
|
|
188
|
+
if (gate.kind !== 'upstream-ceiling' && gate.kind !== 'predicate' && gate.kind !== 'evidence') {
|
|
189
|
+
if (observation === null || observation === undefined)
|
|
190
|
+
return { ...base, status: 'insufficient', reason: 'metric-missing' };
|
|
191
|
+
if (base.n === null)
|
|
192
|
+
return { ...base, status: 'insufficient', reason: 'support-unknown' };
|
|
193
|
+
if (minimumN === null)
|
|
194
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing' };
|
|
195
|
+
if (base.n < minimumN)
|
|
196
|
+
return { ...base, status: 'insufficient', reason: 'below-minimum-n' };
|
|
197
|
+
}
|
|
198
|
+
const thresholdValue = () => {
|
|
199
|
+
if (gate.threshold?.value !== undefined)
|
|
200
|
+
return gate.threshold.value;
|
|
201
|
+
if (gate.threshold?.param !== undefined) {
|
|
202
|
+
const value = parameters[qualifyGateParameter(packId, gate.threshold.param)];
|
|
203
|
+
return typeof value === 'number' ? value : null;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
};
|
|
207
|
+
switch (gate.kind) {
|
|
208
|
+
case 'minimum-n': {
|
|
209
|
+
const target = gate.minimumN === undefined ? null : resolveGateNumber(gate.minimumN, packId, parameters);
|
|
210
|
+
if (target === null) {
|
|
211
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing',
|
|
212
|
+
statistic: { method: 'support-count' } };
|
|
213
|
+
}
|
|
214
|
+
const passed = base.n >= target;
|
|
215
|
+
return { ...base, status: passed ? 'pass' : 'insufficient', reason: passed ? 'support-met' : 'below-minimum-n',
|
|
216
|
+
statistic: { method: 'support-count' } };
|
|
217
|
+
}
|
|
218
|
+
case 'interval-bound': {
|
|
219
|
+
const events = observation?.events;
|
|
220
|
+
if (typeof events !== 'number' || !Number.isSafeInteger(events) || events < 0) {
|
|
221
|
+
return { ...base, status: 'insufficient', reason: 'events-unknown', events: null };
|
|
222
|
+
}
|
|
223
|
+
if (events > base.n)
|
|
224
|
+
return { ...base, status: 'insufficient', reason: 'events-exceed-n', events };
|
|
225
|
+
const target = thresholdValue();
|
|
226
|
+
if (target === null)
|
|
227
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing', events };
|
|
228
|
+
const method = gate.statistic?.method ?? 'wilson';
|
|
229
|
+
const levelBps = resolveGateNumber(gate.statistic?.levelBps, packId, parameters);
|
|
230
|
+
if (levelBps === null)
|
|
231
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing', events };
|
|
232
|
+
let lower;
|
|
233
|
+
let upper;
|
|
234
|
+
try {
|
|
235
|
+
if (method === 'clopper-pearson') {
|
|
236
|
+
({ lower, upper } = clopperPearsonInterval({ events, n: base.n, levelBps }));
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
[lower, upper] = wilsonScoreInterval({ events, n: base.n, levelBps });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (error instanceof PairedDifferenceError) {
|
|
244
|
+
return { ...base, status: 'insufficient', reason: 'statistic-invalid', events };
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
const lowerBps = Math.max(0, Math.floor(lower * 10000));
|
|
249
|
+
const upperBps = Math.min(10000, Math.ceil(upper * 10000));
|
|
250
|
+
const estimateBps = Math.round(events / base.n * 10000);
|
|
251
|
+
const passed = gate.statistic?.bound === 'upper' ? upperBps <= target : lowerBps >= target;
|
|
252
|
+
return { ...base, status: passed ? 'pass' : 'fail', reason: passed ? 'bound-within-threshold' : 'bound-breaches-threshold',
|
|
253
|
+
events, statistic: { method, levelBps, lowerBps, upperBps, estimateBps },
|
|
254
|
+
threshold: { op: gate.threshold?.op, ...(gate.threshold?.value !== undefined
|
|
255
|
+
? { value: gate.threshold.value } : { param: gate.threshold?.param }), resolved: target } };
|
|
256
|
+
}
|
|
257
|
+
case 'paired-difference': {
|
|
258
|
+
const cells = pairedCells(observation);
|
|
259
|
+
if (cells === null)
|
|
260
|
+
return { ...base, status: 'insufficient', reason: 'paired-unknown' };
|
|
261
|
+
if (base.n !== cells.both + cells.candidateOnly + cells.baselineOnly + cells.neither) {
|
|
262
|
+
return { ...base, status: 'insufficient', reason: 'observation-mismatch' };
|
|
263
|
+
}
|
|
264
|
+
const method = gate.statistic?.method ?? 'newcombe';
|
|
265
|
+
const levelBps = resolveGateNumber(gate.statistic?.levelBps, packId, parameters);
|
|
266
|
+
if (levelBps === null)
|
|
267
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing' };
|
|
268
|
+
let interval;
|
|
269
|
+
try {
|
|
270
|
+
interval = pairedBinaryDifferenceInterval({ counts: cells, levelBps, method: method === 'tango' ? 'tango' : 'newcombe-10' });
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error instanceof PairedDifferenceError)
|
|
274
|
+
return { ...base, status: 'insufficient', reason: 'paired-invalid' };
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
const mode = gate.statistic?.mode ?? 'non-inferiority';
|
|
278
|
+
const margin = gate.statistic?.marginBps ?? 0;
|
|
279
|
+
const statistic = { method, levelBps, lowerBps: interval.lowerBps, upperBps: interval.upperBps,
|
|
280
|
+
estimateBps: interval.estimateBps };
|
|
281
|
+
const threshold = { op: 'gte', resolved: margin };
|
|
282
|
+
if (mode === 'non-inferiority') {
|
|
283
|
+
const verdict = pairedNonInferiority({ interval, marginBps: margin });
|
|
284
|
+
if (verdict.decision === 'insufficient')
|
|
285
|
+
return { ...base, status: 'insufficient', reason: 'paired-invalid', statistic, threshold };
|
|
286
|
+
const passed = verdict.decision === 'non-inferior';
|
|
287
|
+
return { ...base, status: passed ? 'pass' : 'fail',
|
|
288
|
+
reason: passed ? 'lower-bound-at-or-above-margin' : 'lower-bound-below-margin', statistic, threshold };
|
|
289
|
+
}
|
|
290
|
+
const passed = interval.lowerBps > margin;
|
|
291
|
+
return { ...base, status: passed ? 'pass' : 'fail',
|
|
292
|
+
reason: passed ? 'lower-bound-above-margin' : 'lower-bound-at-or-below-margin', statistic, threshold };
|
|
293
|
+
}
|
|
294
|
+
case 'bootstrap-bound': {
|
|
295
|
+
const differences = observation?.differences;
|
|
296
|
+
if (!Array.isArray(differences))
|
|
297
|
+
return { ...base, status: 'insufficient', reason: 'differences-unknown' };
|
|
298
|
+
if (differences.length !== base.n)
|
|
299
|
+
return { ...base, status: 'insufficient', reason: 'observation-mismatch' };
|
|
300
|
+
const target = thresholdValue();
|
|
301
|
+
if (target === null)
|
|
302
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing' };
|
|
303
|
+
const levelBps = resolveGateNumber(gate.statistic?.levelBps, packId, parameters);
|
|
304
|
+
if (levelBps === null)
|
|
305
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing' };
|
|
306
|
+
let interval;
|
|
307
|
+
try {
|
|
308
|
+
interval = pairedMeanDifferenceBootstrap({ differences, levelBps,
|
|
309
|
+
seed: gate.statistic?.seed, resamples: gate.statistic?.resamples,
|
|
310
|
+
bounds: gate.statistic?.bounds });
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (error instanceof PairedDifferenceError)
|
|
314
|
+
return { ...base, status: 'insufficient', reason: 'statistic-invalid' };
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
const passed = gate.statistic?.bound === 'upper' ? interval.upperBps <= target : interval.lowerBps >= target;
|
|
318
|
+
return { ...base, status: passed ? 'pass' : 'fail', reason: passed ? 'bound-within-threshold' : 'bound-breaches-threshold',
|
|
319
|
+
statistic: { method: 'bootstrap', levelBps, lowerBps: interval.lowerBps,
|
|
320
|
+
upperBps: interval.upperBps, estimateBps: interval.estimateBps },
|
|
321
|
+
threshold: { op: gate.threshold?.op, ...(gate.threshold?.value !== undefined
|
|
322
|
+
? { value: gate.threshold.value } : { param: gate.threshold?.param }), resolved: target } };
|
|
323
|
+
}
|
|
324
|
+
case 'count-max':
|
|
325
|
+
case 'count-min': {
|
|
326
|
+
const events = observation?.events;
|
|
327
|
+
if (typeof events !== 'number' || !Number.isSafeInteger(events) || events < 0) {
|
|
328
|
+
return { ...base, status: 'insufficient', reason: 'events-unknown', events: null };
|
|
329
|
+
}
|
|
330
|
+
if (events > base.n)
|
|
331
|
+
return { ...base, status: 'insufficient', reason: 'events-exceed-n', events };
|
|
332
|
+
const target = thresholdValue();
|
|
333
|
+
if (target === null)
|
|
334
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing', events };
|
|
335
|
+
const passed = gate.kind === 'count-max' ? events <= target : events >= target;
|
|
336
|
+
return { ...base, status: passed ? 'pass' : 'fail', reason: passed ? 'count-within-threshold' : 'count-breaches-threshold',
|
|
337
|
+
events, statistic: { method: 'exact-count' },
|
|
338
|
+
threshold: { op: gate.threshold?.op, ...(gate.threshold?.value !== undefined
|
|
339
|
+
? { value: gate.threshold.value } : { param: gate.threshold?.param }), resolved: target } };
|
|
340
|
+
}
|
|
341
|
+
case 'value-threshold': {
|
|
342
|
+
const value = observation?.value;
|
|
343
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
344
|
+
return { ...base, status: 'insufficient', reason: 'value-unknown' };
|
|
345
|
+
}
|
|
346
|
+
const target = thresholdValue();
|
|
347
|
+
if (target === null)
|
|
348
|
+
return { ...base, status: 'insufficient', reason: 'parameter-missing' };
|
|
349
|
+
const passed = gate.threshold?.op === 'lte' ? value <= target : value >= target;
|
|
350
|
+
return { ...base, status: passed ? 'pass' : 'fail', reason: passed ? 'value-within-threshold' : 'value-breaches-threshold',
|
|
351
|
+
statistic: { method: 'exact-value' },
|
|
352
|
+
threshold: { op: gate.threshold?.op, ...(gate.threshold?.value !== undefined
|
|
353
|
+
? { value: gate.threshold.value } : { param: gate.threshold?.param }), resolved: target } };
|
|
354
|
+
}
|
|
355
|
+
case 'evidence': {
|
|
356
|
+
const available = observation?.available;
|
|
357
|
+
if (!Array.isArray(available))
|
|
358
|
+
return { ...base, status: 'insufficient', reason: 'evidence-unknown' };
|
|
359
|
+
for (const id of gate.evidence?.required ?? []) {
|
|
360
|
+
const entry = available.find(candidate => candidate?.id === id);
|
|
361
|
+
if (entry === undefined)
|
|
362
|
+
return { ...base, status: 'insufficient', reason: 'evidence-missing', statistic: { method: 'evidence-present' } };
|
|
363
|
+
if (entry.passed !== true) {
|
|
364
|
+
return { ...base, status: 'fail', reason: 'evidence-failed', statistic: { method: 'evidence-present' } };
|
|
365
|
+
}
|
|
366
|
+
if (entry.expiresAt !== undefined && entry.expiresAt !== null) {
|
|
367
|
+
const expiry = Date.parse(entry.expiresAt);
|
|
368
|
+
if (!Number.isFinite(expiry) || expiry <= nowMs) {
|
|
369
|
+
return { ...base, status: 'fail', reason: 'evidence-expired', statistic: { method: 'evidence-present' } };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { ...base, status: 'pass', reason: 'evidence-present', statistic: { method: 'evidence-present' } };
|
|
374
|
+
}
|
|
375
|
+
case 'predicate': {
|
|
376
|
+
let value = 'unknown';
|
|
377
|
+
try {
|
|
378
|
+
value = evaluatePredicate(gate.predicate, { metrics, parameters, slices }, {});
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
value = 'unknown';
|
|
382
|
+
}
|
|
383
|
+
if (value === true)
|
|
384
|
+
return { ...base, status: 'pass', reason: 'predicate-true', statistic: { method: 'predicate' } };
|
|
385
|
+
if (value === false)
|
|
386
|
+
return { ...base, status: 'fail', reason: 'predicate-false', statistic: { method: 'predicate' } };
|
|
387
|
+
return { ...base, status: 'insufficient', reason: 'predicate-unknown', statistic: { method: 'predicate' } };
|
|
388
|
+
}
|
|
389
|
+
case 'upstream-ceiling': {
|
|
390
|
+
if (upstream === null)
|
|
391
|
+
return { ...base, status: 'insufficient', reason: 'upstream-missing',
|
|
392
|
+
statistic: { method: 'upstream-integrity' } };
|
|
393
|
+
const problems = qualificationIntegrityAllowlistProblems(upstream.metadata);
|
|
394
|
+
if (!problems.length) {
|
|
395
|
+
return { ...base, status: 'pass', reason: 'upstream-clean', statistic: { method: 'upstream-integrity' } };
|
|
396
|
+
}
|
|
397
|
+
const compromised = problems.includes('compromised')
|
|
398
|
+
|| upstream.metadata.release_gate?.decision === 'ROLLBACK'
|
|
399
|
+
|| upstream.metadata.integrity_state === 'compromised';
|
|
400
|
+
return { ...base, status: 'fail', reason: compromised ? 'upstream-compromised' : 'upstream-allowlist',
|
|
401
|
+
statistic: { method: 'upstream-integrity' } };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function digestRecordsOrFail(records, providerId) {
|
|
406
|
+
try {
|
|
407
|
+
return artifactDigest(records);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return fail(`metric provider ${providerId} records are not digestible`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function serializeRecordsOrFail(records, providerId) {
|
|
414
|
+
try {
|
|
415
|
+
const serialized = JSON.stringify(records);
|
|
416
|
+
if (typeof serialized !== 'string')
|
|
417
|
+
throw new Error('unserializable');
|
|
418
|
+
return serialized;
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return fail(`metric provider ${providerId} records are not JSON-serializable`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function rerunProviderOrFail(provider, args, providerId) {
|
|
425
|
+
// C2: execute only the registry's loader-issued provider. The runner makes
|
|
426
|
+
// its own copy of the loader-captured bytes, re-digests exactly the bytes it
|
|
427
|
+
// sends to the sandbox and refuses unless they match the pinned digest.
|
|
428
|
+
// Caller-supplied runtime objects (snapshot paths, digests) are never used.
|
|
429
|
+
if (!isLoaderIssuedProvider(provider)) {
|
|
430
|
+
return fail(`metric provider ${providerId} was not issued by the bundle loader: refusing to re-run it`);
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
return runIsolatedProviderSync(provider, {
|
|
434
|
+
mode: 'invoke', recordsJson: args.recordsJson, clockMs: args.clockMs, seed: args.seed,
|
|
435
|
+
expectedCodeDigest: args.codeDigest,
|
|
436
|
+
}, { timeoutMs: args.timeoutMs, maxOutputBytes: GATE_PROVIDER_MAX_OUTPUT_BYTES }).result;
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
return fail(`metric provider ${providerId} re-run failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function upstreamOutcome(upstream) {
|
|
443
|
+
if (upstream === null)
|
|
444
|
+
return { ceiling: 'HOLD', problems: ['upstream-ceiling-missing'] };
|
|
445
|
+
const problems = qualificationIntegrityAllowlistProblems(upstream.metadata);
|
|
446
|
+
const release = upstream.metadata.release_gate?.decision;
|
|
447
|
+
const compromised = problems.includes('compromised')
|
|
448
|
+
|| release === 'ROLLBACK'
|
|
449
|
+
|| upstream.metadata.integrity_state === 'compromised';
|
|
450
|
+
if (compromised)
|
|
451
|
+
return { ceiling: 'ROLLBACK', problems };
|
|
452
|
+
if (problems.length || release === 'HOLD')
|
|
453
|
+
return { ceiling: 'HOLD', problems };
|
|
454
|
+
return { ceiling: 'PROMOTE', problems };
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Pure, deterministic gate evaluation. The binding is resolved internally
|
|
458
|
+
* against the registry: pack pins (authored + composed digests), provider pins
|
|
459
|
+
* and parameters are re-derived, so no caller-supplied resolution is accepted.
|
|
460
|
+
* Precondition violations (untrusted digests, provider pin mismatches,
|
|
461
|
+
* missing trusted holdout inputs, frozen-after-holdout bindings) throw before
|
|
462
|
+
* any gate runs, so failure paths never discard completed results. Per-gate
|
|
463
|
+
* shortfalls report `insufficient`, which never promotes.
|
|
464
|
+
*
|
|
465
|
+
* Interval levels are two-sided confidence levels applied to one-sided bounds:
|
|
466
|
+
* the reported bound is the one-sided edge of a two-sided interval, which is
|
|
467
|
+
* conservative (wider) relative to a one-sided interval at the same level.
|
|
468
|
+
*/
|
|
469
|
+
export function evaluateGates(input) {
|
|
470
|
+
const { binding, registry, trustedBindingDigest, holdout, metrics, upstream, now } = input;
|
|
471
|
+
if ('resolved' in input
|
|
472
|
+
&& input.resolved !== undefined) {
|
|
473
|
+
fail('caller-supplied gate resolution is not accepted: pass the binding and registry');
|
|
474
|
+
}
|
|
475
|
+
if (!(registry instanceof GateRegistry))
|
|
476
|
+
fail('a GateRegistry is required for internal resolution');
|
|
477
|
+
if (input.floors === undefined) {
|
|
478
|
+
fail('project floors are required: pass resolveProjectFloors(config) or \'none-explicit-opt-out\' with a documented reason');
|
|
479
|
+
}
|
|
480
|
+
const floors = input.floors === 'none-explicit-opt-out' ? undefined : input.floors;
|
|
481
|
+
// Reserve before dispatch: resolve and fully validate the binding (pins,
|
|
482
|
+
// providers, parameters, slices) before any gate observes any metric. The
|
|
483
|
+
// resolution runs through the pure `resolveGateBinding` over a standalone
|
|
484
|
+
// snapshot of the registry's authored packs: no overridable `GateRegistry`
|
|
485
|
+
// instance method is called for anything security-relevant, so a subclass
|
|
486
|
+
// that overrides `resolveBinding` (or a duck-typed `{resolveBinding}`)
|
|
487
|
+
// cannot empty gates, loosen parameters or forge digests.
|
|
488
|
+
const resolved = resolveGateBinding(binding, authoredGatePacksOf(registry), gateProvidersOf(registry), floors);
|
|
489
|
+
const { packs, parameters } = resolved;
|
|
490
|
+
// 1:1 pins<->packs: the pin list and the resolved packs must match exactly.
|
|
491
|
+
if (packs.length !== binding.spec.packs.length)
|
|
492
|
+
fail('binding pins do not match resolved packs 1:1');
|
|
493
|
+
for (const pack of packs) {
|
|
494
|
+
// Recompute the authored digest: the stored pin is never trusted on its own.
|
|
495
|
+
if (artifactDigest(pack.authored) !== pack.digest) {
|
|
496
|
+
fail(`pack digest mismatch for ${pack.authored.metadata.id}`);
|
|
497
|
+
}
|
|
498
|
+
// Recompute the composed digest from the evaluator's own pure composition
|
|
499
|
+
// (`resolveGateBinding` above): registry-provided resolved bytes and
|
|
500
|
+
// digests are never trusted.
|
|
501
|
+
if (artifactDigest(pack.resolved) !== pack.resolvedDigest) {
|
|
502
|
+
fail(`composed pack digest mismatch for ${pack.authored.metadata.id}`);
|
|
503
|
+
}
|
|
504
|
+
const pin = binding.spec.packs.find(candidate => candidate.id === pack.authored.metadata.id);
|
|
505
|
+
if (pin === undefined || pin.version !== pack.authored.metadata.version
|
|
506
|
+
|| pin.digest !== pack.digest || pin.resolvedDigest !== pack.resolvedDigest) {
|
|
507
|
+
fail(`pack pin mismatch for ${pack.authored.metadata.id}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Every metric section consumed must be pinned; an unpinned section is
|
|
511
|
+
// refused rather than evaluated. Null version/digests never match. Bundle
|
|
512
|
+
// sections (#2831 rework) additionally carry the trusted code digest and
|
|
513
|
+
// the input-records digest sealed by the host: the section code digest must
|
|
514
|
+
// match the binding pin (verified above against the loaded provider), never
|
|
515
|
+
// a provider-declared field on its own. Sections covered by an isolated
|
|
516
|
+
// re-run are reproduced below and their caller-asserted values ignored, so
|
|
517
|
+
// a missing caller section is tolerated only with runtime coverage.
|
|
518
|
+
const runtimeById = new Map((input.providerRuntime?.providers ?? []).map(provider => [provider.id, provider]));
|
|
519
|
+
const runtimeCovers = (id) => runtimeById.has(id) && input.providerRuntime?.records[id] !== undefined;
|
|
520
|
+
for (const pin of binding.spec.metricProviders) {
|
|
521
|
+
const section = metrics.providers[pin.id];
|
|
522
|
+
if (section === undefined && runtimeCovers(pin.id))
|
|
523
|
+
continue;
|
|
524
|
+
if (section === undefined || section.version !== pin.version || section.sourceDigest !== pin.sourceDigest) {
|
|
525
|
+
fail(`metric provider pin mismatch for ${pin.id}`);
|
|
526
|
+
}
|
|
527
|
+
const pinnedCode = pin.codeDigest;
|
|
528
|
+
const sectionCode = section.codeDigest;
|
|
529
|
+
const sectionRecords = section.recordsDigest;
|
|
530
|
+
if (typeof pinnedCode === 'string') {
|
|
531
|
+
if (sectionCode !== pinnedCode)
|
|
532
|
+
fail(`metric provider pin mismatch for ${pin.id}`);
|
|
533
|
+
if (typeof sectionRecords !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(sectionRecords)) {
|
|
534
|
+
fail(`metric provider pin mismatch for ${pin.id}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else if (sectionCode !== undefined) {
|
|
538
|
+
fail(`metric provider pin mismatch for ${pin.id}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const id of Object.keys(metrics.providers)) {
|
|
542
|
+
if (!binding.spec.metricProviders.some(pin => pin.id === id)) {
|
|
543
|
+
fail(`metric provider ${id} is not pinned by the binding`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (upstream !== null && artifactDigest(upstream.metadata) !== upstream.digest) {
|
|
547
|
+
fail('upstream integrity digest does not match its report');
|
|
548
|
+
}
|
|
549
|
+
// Omitting the upstream record must never downgrade a bound ceiling to
|
|
550
|
+
// HOLD: when the resolved binding observes any upstream-ceiling gate
|
|
551
|
+
// (including the default floor's), evaluation without a sealed upstream is
|
|
552
|
+
// refused here in the evaluator, not just at the CLI.
|
|
553
|
+
if (upstream === null
|
|
554
|
+
&& packs.some(pack => pack.resolved.spec.gates.some(gate => gate.kind === 'upstream-ceiling'))) {
|
|
555
|
+
fail('evaluation requires a sealed upstream record: the binding observes an upstream-ceiling gate');
|
|
556
|
+
}
|
|
557
|
+
const nowMs = Date.parse(now);
|
|
558
|
+
if (!Number.isFinite(nowMs))
|
|
559
|
+
fail('evaluation timestamp is not a valid date-time');
|
|
560
|
+
if (artifactDigest(binding) !== trustedBindingDigest)
|
|
561
|
+
fail('binding digest does not match its trusted pin');
|
|
562
|
+
// Holdout freeze is enforced from the REQUIRED sealed holdout record, never
|
|
563
|
+
// from binding fields (which the study author controls). The seal is
|
|
564
|
+
// re-derived here: `frozenDigest` and `firstAccessedAt` come only from a
|
|
565
|
+
// `sealGateHoldout` record whose digest matches its content.
|
|
566
|
+
if (holdout === undefined || holdout === null)
|
|
567
|
+
fail('trusted holdout inputs are required for evaluation');
|
|
568
|
+
const sealed = holdout;
|
|
569
|
+
if (typeof sealed.digest !== 'string'
|
|
570
|
+
|| artifactDigest({ frozenDigest: sealed.frozenDigest, firstAccessedAt: sealed.firstAccessedAt }) !== sealed.digest) {
|
|
571
|
+
fail('holdout record seal does not match its content');
|
|
572
|
+
}
|
|
573
|
+
if (sealed.frozenDigest !== trustedBindingDigest)
|
|
574
|
+
fail('binding digest does not match the frozen record digest');
|
|
575
|
+
const frozenDigest = sealed.frozenDigest;
|
|
576
|
+
const firstAccessedAt = sealed.firstAccessedAt;
|
|
577
|
+
if (firstAccessedAt !== null && typeof firstAccessedAt !== 'string') {
|
|
578
|
+
fail('trusted holdout first-access time must be a date-time or null');
|
|
579
|
+
}
|
|
580
|
+
// Null access is allowed only when the binding declares no held-out split:
|
|
581
|
+
// a split binding with a null record would disable the freeze check.
|
|
582
|
+
const declaresSplit = binding.spec.splitDigest !== undefined
|
|
583
|
+
|| binding.spec.corpusDigest !== undefined || binding.spec.goldDigest !== undefined;
|
|
584
|
+
if (firstAccessedAt === null && declaresSplit) {
|
|
585
|
+
fail('holdout record reports no access but the binding declares held-out data');
|
|
586
|
+
}
|
|
587
|
+
const frozenAt = Date.parse(binding.spec.frozenAt);
|
|
588
|
+
if (!Number.isFinite(frozenAt))
|
|
589
|
+
fail('binding freeze timestamp is not a valid date-time');
|
|
590
|
+
if (frozenAt > nowMs)
|
|
591
|
+
fail('binding freezes after the evaluation timestamp');
|
|
592
|
+
if (firstAccessedAt !== null) {
|
|
593
|
+
const accessedAt = Date.parse(firstAccessedAt);
|
|
594
|
+
if (!Number.isFinite(accessedAt))
|
|
595
|
+
fail('holdout access timestamp is not a valid date-time');
|
|
596
|
+
if (frozenAt >= accessedAt)
|
|
597
|
+
fail('binding froze at or after holdout access: evaluation refused');
|
|
598
|
+
if (accessedAt > nowMs)
|
|
599
|
+
fail('holdout access timestamp is after the evaluation timestamp');
|
|
600
|
+
}
|
|
601
|
+
// Records binding (P4): every bundle-provider pin is reproduced by an
|
|
602
|
+
// isolated re-run of the pinned provider over records digesting to the
|
|
603
|
+
// pin's records digest, and the re-run output replaces the caller-asserted
|
|
604
|
+
// section. Without runtime coverage the section cannot be reproduced and
|
|
605
|
+
// evaluation refuses (fail closed, including the file path).
|
|
606
|
+
const effectiveProviders = { ...metrics.providers };
|
|
607
|
+
for (const pin of binding.spec.metricProviders) {
|
|
608
|
+
const pinnedCode = pin.codeDigest;
|
|
609
|
+
if (typeof pinnedCode !== 'string')
|
|
610
|
+
continue;
|
|
611
|
+
const pinnedRecords = pin.recordsDigest;
|
|
612
|
+
const snapshot = runtimeById.get(pin.id) ?? fail(`metric provider ${pin.id} requires an isolated re-run`
|
|
613
|
+
+ ' over its pinned records: caller-asserted metrics cannot reproduce provider output');
|
|
614
|
+
const records = input.providerRuntime?.records[pin.id] ?? fail(`metric provider ${pin.id} requires an isolated re-run`
|
|
615
|
+
+ ' over its pinned records: caller-asserted metrics cannot reproduce provider output');
|
|
616
|
+
const registered = gateProvidersOf(registry).get(pin.id);
|
|
617
|
+
if (registered.version !== pin.version || registered.sourceDigest !== pin.sourceDigest
|
|
618
|
+
|| registered.codeDigest !== pinnedCode
|
|
619
|
+
|| snapshot.version !== pin.version || snapshot.sourceDigest !== pin.sourceDigest
|
|
620
|
+
|| snapshot.codeDigest !== pinnedCode) {
|
|
621
|
+
fail(`metric provider pin mismatch for ${pin.id}`);
|
|
622
|
+
}
|
|
623
|
+
if (!Array.isArray(records))
|
|
624
|
+
fail(`metric provider ${pin.id} records must be an array`);
|
|
625
|
+
const actualRecordsDigest = digestRecordsOrFail(records, pin.id);
|
|
626
|
+
if (actualRecordsDigest !== pinnedRecords) {
|
|
627
|
+
fail(`metric provider ${pin.id} records do not match the pinned records digest`);
|
|
628
|
+
}
|
|
629
|
+
const recordsJson = serializeRecordsOrFail(records, pin.id);
|
|
630
|
+
const reproduced = rerunProviderOrFail(registered, {
|
|
631
|
+
recordsJson, clockMs: nowMs, seed: deriveIsolatedSeed(pinnedCode, pinnedRecords),
|
|
632
|
+
timeoutMs: input.providerRuntime?.timeoutMs ?? GATE_PROVIDER_DEFAULT_TIMEOUT_MS,
|
|
633
|
+
codeDigest: pinnedCode,
|
|
634
|
+
}, pin.id);
|
|
635
|
+
try {
|
|
636
|
+
effectiveProviders[pin.id] = sealProviderSection(registered, records, reproduced);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
fail(`metric provider ${pin.id} re-run failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const effectiveMetrics = { ...metrics, providers: effectiveProviders };
|
|
643
|
+
const gateEvidence = [];
|
|
644
|
+
for (const pack of packs) {
|
|
645
|
+
for (const gate of pack.resolved.spec.gates) {
|
|
646
|
+
const kind = metricKindOf(gate, pack);
|
|
647
|
+
const series = seriesFor(gate, pack, effectiveMetrics);
|
|
648
|
+
const targets = gate.kind === 'upstream-ceiling' || gate.kind === 'predicate'
|
|
649
|
+
? [{ slice: null, observation: null }]
|
|
650
|
+
: expandScope(gate, kind, series, binding.spec.slices);
|
|
651
|
+
if (!targets.length) {
|
|
652
|
+
// Unreachable through resolveBinding (vacuous scopes are load errors),
|
|
653
|
+
// but a zero-target gate must never PROMOTE vacuously.
|
|
654
|
+
gateEvidence.push({
|
|
655
|
+
gateId: gate.id, kind: gate.kind,
|
|
656
|
+
scope: { mode: gate.scope.mode, ...(gate.scope.slices === undefined ? {} : { slices: [...gate.scope.slices] }) },
|
|
657
|
+
slice: null, n: null, status: 'insufficient', outcome: gate.onInsufficient ?? 'HOLD',
|
|
658
|
+
reasons: [`${gate.id}:no-evaluation-targets`],
|
|
659
|
+
});
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
for (const target of targets) {
|
|
663
|
+
const evaluation = evaluateObservation(gate, target.observation, { packId: pack.authored.metadata.id, parameters, metrics: effectiveMetrics, upstream, nowMs,
|
|
664
|
+
slice: target.slice, slices: binding.spec.slices });
|
|
665
|
+
// Upstream-ceiling gates mirror the upstream verdict exactly: compromise
|
|
666
|
+
// rolls back, allowlist problems hold, clean promotes. The gate's own
|
|
667
|
+
// onFail never escalates (or softens) the trusted verdict.
|
|
668
|
+
const outcome = gate.kind === 'upstream-ceiling' && evaluation.status === 'fail'
|
|
669
|
+
? upstreamOutcome(upstream).ceiling
|
|
670
|
+
: gateStatusOutcome(evaluation.status, gate);
|
|
671
|
+
gateEvidence.push({
|
|
672
|
+
gateId: gate.id, kind: gate.kind,
|
|
673
|
+
scope: { mode: gate.scope.mode, ...(gate.scope.slices === undefined ? {} : { slices: [...gate.scope.slices] }) },
|
|
674
|
+
slice: evaluation.slice, n: evaluation.n,
|
|
675
|
+
...(evaluation.events === undefined ? {} : { events: evaluation.events }),
|
|
676
|
+
...(evaluation.statistic === undefined ? {} : { statistic: evaluation.statistic }),
|
|
677
|
+
...(evaluation.threshold === undefined ? {} : { threshold: evaluation.threshold }),
|
|
678
|
+
status: evaluation.status,
|
|
679
|
+
outcome,
|
|
680
|
+
reasons: [`${gate.id}:${evaluation.reason}`],
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const packOutcome = maxOutcome(...gateEvidence.map(entry => entry.outcome));
|
|
686
|
+
const { ceiling: upstreamCeiling, problems: upstreamProblems } = upstreamOutcome(upstream);
|
|
687
|
+
const bindingCeiling = binding.spec.ceiling ?? 'PROMOTE';
|
|
688
|
+
const decision = maxOutcome(packOutcome, upstreamCeiling, bindingCeiling);
|
|
689
|
+
const body = {
|
|
690
|
+
apiVersion: binding.apiVersion,
|
|
691
|
+
kind: 'GateReport',
|
|
692
|
+
metadata: { id: binding.metadata.id, version: binding.metadata.version,
|
|
693
|
+
description: `GateReport for ${binding.metadata.id}` },
|
|
694
|
+
binding: { id: binding.metadata.id, version: binding.metadata.version, digest: trustedBindingDigest },
|
|
695
|
+
packs: packs.map(pack => ({ id: pack.authored.metadata.id, version: pack.authored.metadata.version,
|
|
696
|
+
digest: pack.digest, resolvedDigest: pack.resolvedDigest })),
|
|
697
|
+
metricProviders: binding.spec.metricProviders.map(pin => ({ ...pin })),
|
|
698
|
+
metricsDigest: artifactDigest(effectiveMetrics),
|
|
699
|
+
floors: input.floors === 'none-explicit-opt-out'
|
|
700
|
+
? { mode: 'opt-out' }
|
|
701
|
+
: { mode: 'applied', digest: artifactDigest(input.floors) },
|
|
702
|
+
holdout: { digest: sealed.digest, frozenDigest, firstAccessedAt },
|
|
703
|
+
gateEvidence,
|
|
704
|
+
references: binding.spec.references.map(reference => ({
|
|
705
|
+
name: reference.name, kind: reference.kind, observed: effectiveMetrics.references?.[reference.name] !== undefined,
|
|
706
|
+
})),
|
|
707
|
+
upstream: upstream === null ? null : {
|
|
708
|
+
decision: upstreamCeiling,
|
|
709
|
+
reasons: upstreamProblems.map(problem => `upstream:${problem}`),
|
|
710
|
+
digest: upstream.digest,
|
|
711
|
+
},
|
|
712
|
+
ceilings: { packOutcome, upstreamCeiling, bindingCeiling },
|
|
713
|
+
decision,
|
|
714
|
+
evaluatedAt: now,
|
|
715
|
+
...(input.attestation === undefined ? {} : { attestation: input.attestation }),
|
|
716
|
+
};
|
|
717
|
+
return { ...body, digest: artifactDigest(body) };
|
|
718
|
+
}
|
|
719
|
+
//# sourceMappingURL=evaluate.js.map
|