pi-smart-router 0.2.0 → 0.4.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/.pi/extensions/smart-router/index.ts +9 -0
- package/.pi/extensions/smart-router/pi-model-scope.ts +127 -20
- package/.pi/extensions/smart-router/planning-delegate.ts +318 -0
- package/.pi/extensions/smart-router/route-and-delegate.ts +21 -1
- package/.pi/extensions/smart-router/types.ts +3 -0
- package/README.md +51 -0
- package/config/benchmark-profiles.json +145 -0
- package/config/models.yaml.example +5 -0
- package/config/routing-calibration.json.example +14 -2
- package/dist/config/defaults.d.ts +2 -2
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +5 -3
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/pi-model-mapper.d.ts +12 -2
- package/dist/config/pi-model-mapper.d.ts.map +1 -1
- package/dist/config/pi-model-mapper.js +91 -6
- package/dist/config/pi-model-mapper.js.map +1 -1
- package/dist/domain/matching/hydra-input.d.ts +6 -5
- package/dist/domain/matching/hydra-input.d.ts.map +1 -1
- package/dist/domain/matching/hydra-input.js +73 -6
- package/dist/domain/matching/hydra-input.js.map +1 -1
- package/dist/domain/pipeline/router-pipeline.d.ts +22 -1
- package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
- package/dist/domain/pipeline/router-pipeline.js +135 -22
- package/dist/domain/pipeline/router-pipeline.js.map +1 -1
- package/dist/domain/routing/isotonic-calibrator.d.ts +56 -0
- package/dist/domain/routing/isotonic-calibrator.d.ts.map +1 -0
- package/dist/domain/routing/isotonic-calibrator.js +187 -0
- package/dist/domain/routing/isotonic-calibrator.js.map +1 -0
- package/dist/domain/routing/p-success-classifier.d.ts +53 -7
- package/dist/domain/routing/p-success-classifier.d.ts.map +1 -1
- package/dist/domain/routing/p-success-classifier.js +205 -21
- package/dist/domain/routing/p-success-classifier.js.map +1 -1
- package/dist/domain/types/entities.d.ts +54 -0
- package/dist/domain/types/entities.d.ts.map +1 -1
- package/dist/domain/types/index.d.ts +1 -1
- package/dist/domain/types/index.d.ts.map +1 -1
- package/dist/domain/types/schemas.d.ts +29 -0
- package/dist/domain/types/schemas.d.ts.map +1 -1
- package/dist/domain/types/schemas.js +57 -0
- package/dist/domain/types/schemas.js.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
- package/dist/infrastructure/persistence/sqlite-store.js +2 -1
- package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts +20 -1
- package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
- package/dist/infrastructure/telemetry/routing-telemetry.js +82 -2
- package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
- package/package.json +6 -3
- package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +29 -1
- package/src/config/defaults.ts +6 -2
- package/src/config/pi-model-mapper.ts +110 -6
- package/src/domain/matching/hydra-input.ts +86 -7
- package/src/domain/pipeline/router-pipeline.ts +195 -29
- package/src/domain/routing/isotonic-calibrator.ts +255 -0
- package/src/domain/routing/p-success-classifier.ts +299 -26
- package/src/domain/types/entities.ts +58 -0
- package/src/domain/types/index.ts +4 -0
- package/src/domain/types/schemas.ts +73 -0
- package/src/infrastructure/persistence/sqlite-store.ts +2 -0
- package/src/infrastructure/telemetry/routing-telemetry.ts +127 -7
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
import type {
|
|
13
13
|
ModelProfile,
|
|
14
|
+
PlanningDelegateConfig,
|
|
15
|
+
PlanningDelegateObservability,
|
|
14
16
|
PriceCatalog,
|
|
15
17
|
RoutingDecision,
|
|
16
18
|
RoutingFeatureSidecar,
|
|
@@ -49,6 +51,10 @@ import {
|
|
|
49
51
|
estimateRoutingCost,
|
|
50
52
|
enrichRoutingDecisionWithContextFit,
|
|
51
53
|
enrichRoutingDecisionWithTierSelection,
|
|
54
|
+
createPlanningDelegateObservability,
|
|
55
|
+
PLANNING_DELEGATE,
|
|
56
|
+
PLANNING_DELEGATE_DISABLED,
|
|
57
|
+
PLANNING_DIRECT_FRONTIER,
|
|
52
58
|
} from '../../infrastructure/telemetry/routing-telemetry.js';
|
|
53
59
|
import type { HydraMatcher as HydraMatcherType, MatchResult } from '../matching/hydra-matcher.js';
|
|
54
60
|
import type { ClusterMatcher, ClusterMatchResult } from '../matching/cluster-matcher.js';
|
|
@@ -58,6 +64,11 @@ import {
|
|
|
58
64
|
buildTierFeatures,
|
|
59
65
|
scoreLowIntensity,
|
|
60
66
|
} from '../routing/tier-features.js';
|
|
67
|
+
import {
|
|
68
|
+
applyIsotonicCalibratorTimed,
|
|
69
|
+
resolveIsotonicCalibrator,
|
|
70
|
+
type IsotonicCalibratorArtifact,
|
|
71
|
+
} from '../routing/isotonic-calibrator.js';
|
|
61
72
|
import {
|
|
62
73
|
predictPSuccessCheapTimed,
|
|
63
74
|
resolvePSuccessWeights,
|
|
@@ -168,8 +179,13 @@ export interface PipelineOptions {
|
|
|
168
179
|
/** Preloaded P(success) weights for tests; lazy-loads artifact when omitted (SP-105). */
|
|
169
180
|
readonly pSuccessWeights?: PSuccessWeights;
|
|
170
181
|
readonly pSuccessWeightsPath?: string;
|
|
182
|
+
/** Preloaded isotonic calibrator for tests; lazy-loads bundle when omitted (SP-133). */
|
|
183
|
+
readonly isotonicCalibrator?: IsotonicCalibratorArtifact | null;
|
|
184
|
+
readonly routingCalibrationPath?: string;
|
|
171
185
|
/** SAAR pin policy (SP-123). Must match sessionPinner.saarConfig when enabled. */
|
|
172
186
|
readonly saarConfig?: SaarConfig;
|
|
187
|
+
/** Planning delegate operator knobs (SP-143, #71). Defaults to operator config. */
|
|
188
|
+
readonly planningDelegateConfig?: PlanningDelegateConfig;
|
|
173
189
|
}
|
|
174
190
|
|
|
175
191
|
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
|
@@ -194,17 +210,23 @@ export class RouterPipeline {
|
|
|
194
210
|
private currentTierHintReasonCode: string | null = null;
|
|
195
211
|
private currentLowIntensityScore: number | null = null;
|
|
196
212
|
private currentPSuccessCheap: number | null = null;
|
|
213
|
+
private currentPSuccessRaw: number | null = null;
|
|
214
|
+
private currentPSuccessCalibrated: number | null = null;
|
|
197
215
|
private currentPSuccessAlpha: number | null = null;
|
|
198
216
|
private currentExpectedCostByTier: ExpectedCostBreakdown[] | null = null;
|
|
199
217
|
private currentLocalEligibleReason: string | null = null;
|
|
200
218
|
private pSuccessWeightsLoaded = false;
|
|
201
219
|
private cachedPSuccessWeights: PSuccessWeights | null = null;
|
|
220
|
+
private isotonicCalibratorLoaded = false;
|
|
221
|
+
private cachedIsotonicCalibrator: IsotonicCalibratorArtifact | null = null;
|
|
202
222
|
private currentContextFitRejected: readonly CandidateScore[] = [];
|
|
203
223
|
private currentContextFitViableCount = 0;
|
|
204
224
|
private contextOverflowPreferredProvider: string | null = null;
|
|
205
225
|
private contextOverflowTriggered = false;
|
|
206
226
|
/** Internal breakeven gate reason for SP-126 explain wiring. */
|
|
207
227
|
private currentBreakevenReason: string | null = null;
|
|
228
|
+
/** Planning delegate observability for SP-143 explain/telemetry wiring. */
|
|
229
|
+
private currentPlanningDelegate: PlanningDelegateObservability | null = null;
|
|
208
230
|
|
|
209
231
|
constructor(fleet: readonly ModelProfile[], options?: PipelineOptions) {
|
|
210
232
|
this.fleet = fleet;
|
|
@@ -243,6 +265,8 @@ export class RouterPipeline {
|
|
|
243
265
|
this.currentTierHintReasonCode = null;
|
|
244
266
|
this.currentLowIntensityScore = null;
|
|
245
267
|
this.currentPSuccessCheap = null;
|
|
268
|
+
this.currentPSuccessRaw = null;
|
|
269
|
+
this.currentPSuccessCalibrated = null;
|
|
246
270
|
this.currentPSuccessAlpha = null;
|
|
247
271
|
this.currentExpectedCostByTier = null;
|
|
248
272
|
this.currentLocalEligibleReason = null;
|
|
@@ -251,6 +275,7 @@ export class RouterPipeline {
|
|
|
251
275
|
this.contextOverflowPreferredProvider = null;
|
|
252
276
|
this.contextOverflowTriggered = false;
|
|
253
277
|
this.currentBreakevenReason = null;
|
|
278
|
+
this.currentPlanningDelegate = null;
|
|
254
279
|
|
|
255
280
|
let currentStage: NamedPipelineStage | undefined;
|
|
256
281
|
|
|
@@ -341,8 +366,13 @@ export class RouterPipeline {
|
|
|
341
366
|
tier_hint_reason_code: this.currentTierHintReasonCode,
|
|
342
367
|
low_intensity_score: this.currentLowIntensityScore,
|
|
343
368
|
p_success_cheap: this.currentPSuccessCheap,
|
|
369
|
+
p_success_raw: this.currentPSuccessRaw,
|
|
370
|
+
p_success_calibrated: this.currentPSuccessCalibrated,
|
|
344
371
|
p_success_alpha: this.currentPSuccessAlpha,
|
|
345
372
|
local_eligible_reason: this.currentLocalEligibleReason,
|
|
373
|
+
...(this.currentPlanningDelegate
|
|
374
|
+
? { planning_delegate: this.currentPlanningDelegate }
|
|
375
|
+
: {}),
|
|
346
376
|
};
|
|
347
377
|
|
|
348
378
|
const withBaseFeatures = { ...decision, features };
|
|
@@ -945,54 +975,155 @@ export class RouterPipeline {
|
|
|
945
975
|
const tierCandidates = this.activeFleet.filter(
|
|
946
976
|
(m) => m.tier === targetTier && m.healthy !== false,
|
|
947
977
|
);
|
|
948
|
-
const
|
|
949
|
-
if (!
|
|
978
|
+
const targetModel = selectLowestCostModel(tierCandidates);
|
|
979
|
+
if (!targetModel) {
|
|
950
980
|
return { decided: false, stage: 'turn_envelope' };
|
|
951
981
|
}
|
|
952
982
|
|
|
953
983
|
const pinner = this.options.sessionPinner;
|
|
954
984
|
const pin = pinner?.getPin(request.session_id) ?? null;
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
985
|
+
const pinnedModel =
|
|
986
|
+
pin !== null
|
|
987
|
+
? this.activeFleet.find(
|
|
988
|
+
(m) => m.id === pin.pinned_model_id && m.healthy !== false,
|
|
989
|
+
)
|
|
990
|
+
: undefined;
|
|
991
|
+
|
|
992
|
+
// SP-143: cache-preserving planning delegate when warm economical pin would switch to frontier.
|
|
993
|
+
if (
|
|
994
|
+
turnType === 'planning' &&
|
|
995
|
+
pinnedModel &&
|
|
996
|
+
pinnedModel.tier === 'economical-cloud' &&
|
|
997
|
+
pinnedModel.id !== targetModel.id
|
|
998
|
+
) {
|
|
999
|
+
const delegateDecision = this.tryPlanningDelegateDecision(
|
|
1000
|
+
request,
|
|
1001
|
+
pinnedModel,
|
|
1002
|
+
targetModel,
|
|
958
1003
|
);
|
|
959
|
-
if (
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1004
|
+
if (delegateDecision) {
|
|
1005
|
+
return delegateDecision;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (pinnedModel && pinnedModel.id !== targetModel.id) {
|
|
1010
|
+
const skipBreakeven =
|
|
1011
|
+
turnType === 'planning' && this.isSaarPlanningBufferActive(request);
|
|
1012
|
+
if (!skipBreakeven) {
|
|
1013
|
+
const tokenEstimate =
|
|
1014
|
+
request.estimated_input_tokens ?? request.prompt_text.length;
|
|
1015
|
+
const breakeven = evaluateModelSwitchBreakeven(
|
|
1016
|
+
pinnedModel,
|
|
1017
|
+
targetModel,
|
|
1018
|
+
tokenEstimate,
|
|
1019
|
+
tokenEstimate,
|
|
1020
|
+
this.options.saarConfig,
|
|
1021
|
+
);
|
|
1022
|
+
if (!breakeven.shouldSwitch) {
|
|
1023
|
+
this.currentBreakevenReason = 'breakeven_blocked';
|
|
1024
|
+
return { decided: false, stage: 'turn_envelope' };
|
|
977
1025
|
}
|
|
1026
|
+
this.currentBreakevenReason = 'breakeven_pass';
|
|
978
1027
|
}
|
|
979
1028
|
}
|
|
980
1029
|
|
|
1030
|
+
const directReasonCode = `turn_${turnType}`;
|
|
1031
|
+
let planningDirectFallback: string | null = null;
|
|
1032
|
+
|
|
1033
|
+
if (
|
|
1034
|
+
turnType === 'planning' &&
|
|
1035
|
+
pinnedModel &&
|
|
1036
|
+
pinnedModel.tier === 'economical-cloud' &&
|
|
1037
|
+
pinnedModel.id !== targetModel.id
|
|
1038
|
+
) {
|
|
1039
|
+
planningDirectFallback = this.resolvePlanningDirectFallbackReason();
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (planningDirectFallback) {
|
|
1043
|
+
this.setPlanningDelegateDirectFallback(targetModel.id, planningDirectFallback);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
981
1046
|
return {
|
|
982
1047
|
decided: true,
|
|
983
1048
|
stage: 'turn_envelope',
|
|
984
|
-
decision: this.withEstimatedCost(request,
|
|
1049
|
+
decision: this.withEstimatedCost(request, targetModel, {
|
|
985
1050
|
request_id: request.request_id,
|
|
986
|
-
selected_model_id:
|
|
1051
|
+
selected_model_id: targetModel.id,
|
|
987
1052
|
tier: targetTier,
|
|
988
1053
|
stage: 'turn_envelope',
|
|
989
|
-
reason_code:
|
|
1054
|
+
reason_code: planningDirectFallback
|
|
1055
|
+
? PLANNING_DIRECT_FRONTIER
|
|
1056
|
+
: directReasonCode,
|
|
1057
|
+
routing_latency_ms: 0,
|
|
1058
|
+
pin_reason: null,
|
|
1059
|
+
}),
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private resolvePlanningDirectFallbackReason(): string | null {
|
|
1064
|
+
const config = this.resolvePlanningDelegateConfig();
|
|
1065
|
+
if (!config.enabled) {
|
|
1066
|
+
return PLANNING_DELEGATE_DISABLED;
|
|
1067
|
+
}
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private resolvePlanningDelegateConfig(): PlanningDelegateConfig {
|
|
1072
|
+
return (
|
|
1073
|
+
this.options.planningDelegateConfig ??
|
|
1074
|
+
DEFAULT_OPERATOR_CONFIG.planning_delegate
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* SP-143: emit planning_delegate when enabled; otherwise record direct fallback
|
|
1080
|
+
* observability and return null so breakeven-gated direct frontier can proceed.
|
|
1081
|
+
*/
|
|
1082
|
+
private tryPlanningDelegateDecision(
|
|
1083
|
+
request: RoutingRequest,
|
|
1084
|
+
pinnedModel: ModelProfile,
|
|
1085
|
+
frontierModel: ModelProfile,
|
|
1086
|
+
): StageResult | null {
|
|
1087
|
+
const config = this.resolvePlanningDelegateConfig();
|
|
1088
|
+
if (!config.enabled) {
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
this.currentPlanningDelegate = createPlanningDelegateObservability({
|
|
1093
|
+
path: 'delegate',
|
|
1094
|
+
primary_model_id: pinnedModel.id,
|
|
1095
|
+
delegate_model_id: frontierModel.id,
|
|
1096
|
+
compressed_context: config.compressed_context,
|
|
1097
|
+
planning_delegate_reason_code: PLANNING_DELEGATE,
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
decided: true,
|
|
1102
|
+
stage: 'turn_envelope',
|
|
1103
|
+
decision: this.withEstimatedCost(request, pinnedModel, {
|
|
1104
|
+
request_id: request.request_id,
|
|
1105
|
+
selected_model_id: pinnedModel.id,
|
|
1106
|
+
tier: pinnedModel.tier,
|
|
1107
|
+
stage: 'turn_envelope',
|
|
1108
|
+
reason_code: PLANNING_DELEGATE,
|
|
990
1109
|
routing_latency_ms: 0,
|
|
991
1110
|
pin_reason: null,
|
|
992
1111
|
}),
|
|
993
1112
|
};
|
|
994
1113
|
}
|
|
995
1114
|
|
|
1115
|
+
private setPlanningDelegateDirectFallback(
|
|
1116
|
+
delegateModelId: string | null,
|
|
1117
|
+
fallbackReason: string,
|
|
1118
|
+
): void {
|
|
1119
|
+
this.currentPlanningDelegate = createPlanningDelegateObservability({
|
|
1120
|
+
path: 'direct',
|
|
1121
|
+
delegate_model_id: delegateModelId,
|
|
1122
|
+
planning_delegate_reason_code: PLANNING_DIRECT_FRONTIER,
|
|
1123
|
+
fallback_reason: fallbackReason,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
996
1127
|
// ─── Low-intensity tier gate (SP-103, #58) ───────────────────────────────
|
|
997
1128
|
|
|
998
1129
|
/**
|
|
@@ -1027,7 +1158,13 @@ export class RouterPipeline {
|
|
|
1027
1158
|
const weights = this.resolvePSuccessWeights();
|
|
1028
1159
|
const pFeatures = tierFeaturesToPSuccessFeatures(tierFeatures);
|
|
1029
1160
|
const pResult = predictPSuccessCheapTimed(pFeatures, weights);
|
|
1030
|
-
|
|
1161
|
+
const calibrator = this.resolveIsotonicCalibrator();
|
|
1162
|
+
const calibratedResult = applyIsotonicCalibratorTimed(pResult.probability, calibrator);
|
|
1163
|
+
const pSuccessForGate = calibratedResult.calibrated;
|
|
1164
|
+
|
|
1165
|
+
this.currentPSuccessRaw = pResult.probability;
|
|
1166
|
+
this.currentPSuccessCalibrated = pSuccessForGate;
|
|
1167
|
+
this.currentPSuccessCheap = pSuccessForGate;
|
|
1031
1168
|
|
|
1032
1169
|
const structuralHint = this.resolveTierHint(
|
|
1033
1170
|
score,
|
|
@@ -1041,10 +1178,14 @@ export class RouterPipeline {
|
|
|
1041
1178
|
? (() => {
|
|
1042
1179
|
const selection = this.selectExpectedCostTierHint(
|
|
1043
1180
|
request,
|
|
1044
|
-
|
|
1181
|
+
pSuccessForGate,
|
|
1045
1182
|
alpha,
|
|
1046
1183
|
);
|
|
1047
|
-
this.logExpectedCostExplain(
|
|
1184
|
+
this.logExpectedCostExplain(pSuccessForGate, alpha, selection, {
|
|
1185
|
+
p_success_raw: pResult.probability,
|
|
1186
|
+
p_success_calibrated: pSuccessForGate,
|
|
1187
|
+
calibration_applied: calibratedResult.calibration_applied,
|
|
1188
|
+
});
|
|
1048
1189
|
return {
|
|
1049
1190
|
tierHint: selection.tierHint,
|
|
1050
1191
|
reasonCode: selection.reasonCode,
|
|
@@ -1106,10 +1247,18 @@ export class RouterPipeline {
|
|
|
1106
1247
|
tierCosts: readonly ExpectedCostBreakdown[];
|
|
1107
1248
|
rationale: string;
|
|
1108
1249
|
},
|
|
1250
|
+
calibration?: {
|
|
1251
|
+
readonly p_success_raw: number;
|
|
1252
|
+
readonly p_success_calibrated: number;
|
|
1253
|
+
readonly calibration_applied: boolean;
|
|
1254
|
+
},
|
|
1109
1255
|
): void {
|
|
1110
1256
|
console.info('Expected-cost tier gate', {
|
|
1111
1257
|
reason: selection.reasonCode,
|
|
1112
1258
|
p_success_cheap: pSuccessCheap,
|
|
1259
|
+
p_success_raw: calibration?.p_success_raw ?? pSuccessCheap,
|
|
1260
|
+
p_success_calibrated: calibration?.p_success_calibrated ?? pSuccessCheap,
|
|
1261
|
+
calibration_applied: calibration?.calibration_applied ?? false,
|
|
1113
1262
|
alpha,
|
|
1114
1263
|
chosen_tier: selection.tierHint,
|
|
1115
1264
|
rationale: selection.rationale,
|
|
@@ -1140,6 +1289,23 @@ export class RouterPipeline {
|
|
|
1140
1289
|
return this.cachedPSuccessWeights!;
|
|
1141
1290
|
}
|
|
1142
1291
|
|
|
1292
|
+
private resolveIsotonicCalibrator(): IsotonicCalibratorArtifact | null {
|
|
1293
|
+
if (this.options.isotonicCalibrator !== undefined) {
|
|
1294
|
+
return this.options.isotonicCalibrator;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (!this.isotonicCalibratorLoaded) {
|
|
1298
|
+
this.cachedIsotonicCalibrator = resolveIsotonicCalibrator({
|
|
1299
|
+
...(this.options.routingCalibrationPath !== undefined
|
|
1300
|
+
? { filePath: this.options.routingCalibrationPath }
|
|
1301
|
+
: {}),
|
|
1302
|
+
});
|
|
1303
|
+
this.isotonicCalibratorLoaded = true;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
return this.cachedIsotonicCalibrator;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1143
1309
|
private resolveTierHint(
|
|
1144
1310
|
score: number,
|
|
1145
1311
|
highThreshold: number,
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Online isotonic P(success) calibrator for low_intensity gate (SP-133).
|
|
3
|
+
*
|
|
4
|
+
* Loads monotonic knot tables from routing-calibration bundle at serve time.
|
|
5
|
+
* O(log n) binary search lookup with <5ms budget; falls back to raw logistic
|
|
6
|
+
* when the artifact is missing or under-trained.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
import { MIN_TRAINING_SAMPLES } from './p-success-classifier.js';
|
|
15
|
+
|
|
16
|
+
export const ISOTONIC_CALIBRATOR_ARTIFACT_VERSION = 1 as const;
|
|
17
|
+
export const ISOTONIC_LOOKUP_BUDGET_MS = 5;
|
|
18
|
+
export const DEFAULT_ROUTING_CALIBRATION_PATH = resolve('config', 'routing-calibration.json');
|
|
19
|
+
|
|
20
|
+
export interface IsotonicCalibratorArtifact {
|
|
21
|
+
readonly version: typeof ISOTONIC_CALIBRATOR_ARTIFACT_VERSION;
|
|
22
|
+
readonly min_training_samples: number;
|
|
23
|
+
readonly x_knots: readonly number[];
|
|
24
|
+
readonly y_knots: readonly number[];
|
|
25
|
+
readonly trained_sample_count: number;
|
|
26
|
+
readonly holdout_ece_raw: number | null;
|
|
27
|
+
readonly holdout_ece_calibrated: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IsotonicCalibratorResult {
|
|
31
|
+
readonly calibrated: number;
|
|
32
|
+
readonly elapsed_ms: number;
|
|
33
|
+
readonly within_budget: boolean;
|
|
34
|
+
readonly calibration_applied: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LoadIsotonicCalibratorOptions {
|
|
38
|
+
readonly filePath?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class IsotonicCalibratorLoaderError extends Error {
|
|
42
|
+
override readonly name = 'IsotonicCalibratorLoaderError';
|
|
43
|
+
|
|
44
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
45
|
+
super(message, options);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const IsotonicCalibratorArtifactSchema = z.object({
|
|
50
|
+
version: z.literal(ISOTONIC_CALIBRATOR_ARTIFACT_VERSION),
|
|
51
|
+
min_training_samples: z.number().int().min(0),
|
|
52
|
+
x_knots: z.array(z.number().finite().min(0).max(1)).min(2),
|
|
53
|
+
y_knots: z.array(z.number().finite().min(0).max(1)).min(2),
|
|
54
|
+
trained_sample_count: z.number().int().min(0),
|
|
55
|
+
holdout_ece_raw: z.number().finite().min(0).max(1).nullable(),
|
|
56
|
+
holdout_ece_calibrated: z.number().finite().min(0).max(1).nullable(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function clamp01(value: number): number {
|
|
60
|
+
if (value <= 0) return 0;
|
|
61
|
+
if (value >= 1) return 1;
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatZodIssues(error: { issues: readonly { path: readonly PropertyKey[]; message: string }[] }): string {
|
|
66
|
+
return error.issues
|
|
67
|
+
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
|
|
68
|
+
.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when artifact has enough labeled samples for serve-time calibration. */
|
|
72
|
+
export function isIsotonicCalibratorTrained(artifact: IsotonicCalibratorArtifact): boolean {
|
|
73
|
+
return artifact.trained_sample_count >= artifact.min_training_samples;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createDefaultIsotonicCalibratorArtifact(): IsotonicCalibratorArtifact {
|
|
77
|
+
return {
|
|
78
|
+
version: ISOTONIC_CALIBRATOR_ARTIFACT_VERSION,
|
|
79
|
+
min_training_samples: MIN_TRAINING_SAMPLES,
|
|
80
|
+
x_knots: [0, 1],
|
|
81
|
+
y_knots: [0, 1],
|
|
82
|
+
trained_sample_count: 0,
|
|
83
|
+
holdout_ece_raw: null,
|
|
84
|
+
holdout_ece_calibrated: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Piecewise-constant isotonic lookup with O(log n) binary search on knots. */
|
|
89
|
+
export function applyIsotonicLookup(
|
|
90
|
+
rawScore: number,
|
|
91
|
+
xKnots: readonly number[],
|
|
92
|
+
yKnots: readonly number[],
|
|
93
|
+
): number {
|
|
94
|
+
if (xKnots.length === 0 || yKnots.length === 0 || xKnots.length !== yKnots.length) {
|
|
95
|
+
return clamp01(rawScore);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const score = clamp01(rawScore);
|
|
99
|
+
if (score <= xKnots[0]!) {
|
|
100
|
+
return clamp01(yKnots[0]!);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const lastIndex = xKnots.length - 1;
|
|
104
|
+
if (score >= xKnots[lastIndex]!) {
|
|
105
|
+
return clamp01(yKnots[lastIndex]!);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let low = 0;
|
|
109
|
+
let high = lastIndex;
|
|
110
|
+
while (low < high) {
|
|
111
|
+
const mid = Math.floor((low + high + 1) / 2);
|
|
112
|
+
if (xKnots[mid]! <= score) {
|
|
113
|
+
low = mid;
|
|
114
|
+
} else {
|
|
115
|
+
high = mid - 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return clamp01(yKnots[low]!);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Apply isotonic calibration to a raw logistic P(success) score.
|
|
124
|
+
* Returns the raw score when the artifact is missing or under-trained.
|
|
125
|
+
*/
|
|
126
|
+
export function applyIsotonicCalibrator(
|
|
127
|
+
rawScore: number,
|
|
128
|
+
artifact: IsotonicCalibratorArtifact | null,
|
|
129
|
+
): number {
|
|
130
|
+
if (artifact === null || !isIsotonicCalibratorTrained(artifact)) {
|
|
131
|
+
return clamp01(rawScore);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return applyIsotonicLookup(rawScore, artifact.x_knots, artifact.y_knots);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Apply isotonic calibration with elapsed timing guard for the online routing budget. */
|
|
138
|
+
export function applyIsotonicCalibratorTimed(
|
|
139
|
+
rawScore: number,
|
|
140
|
+
artifact: IsotonicCalibratorArtifact | null,
|
|
141
|
+
budgetMs: number = ISOTONIC_LOOKUP_BUDGET_MS,
|
|
142
|
+
): IsotonicCalibratorResult {
|
|
143
|
+
const start = performance.now();
|
|
144
|
+
const calibration_applied = artifact !== null && isIsotonicCalibratorTrained(artifact);
|
|
145
|
+
const calibrated = applyIsotonicCalibrator(rawScore, artifact);
|
|
146
|
+
const elapsed_ms = performance.now() - start;
|
|
147
|
+
|
|
148
|
+
if (elapsed_ms > budgetMs) {
|
|
149
|
+
console.warn('Isotonic P(success) lookup exceeded latency budget', {
|
|
150
|
+
elapsed_ms,
|
|
151
|
+
budget_ms: budgetMs,
|
|
152
|
+
knot_count: artifact?.x_knots.length ?? 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
calibrated,
|
|
158
|
+
elapsed_ms,
|
|
159
|
+
within_budget: elapsed_ms <= budgetMs,
|
|
160
|
+
calibration_applied,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Parse and validate an isotonic calibrator artifact from JSON text. */
|
|
165
|
+
export function parseIsotonicCalibratorJson(raw: string): IsotonicCalibratorArtifact {
|
|
166
|
+
let parsed: unknown;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(raw);
|
|
169
|
+
} catch (err: unknown) {
|
|
170
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
171
|
+
throw new IsotonicCalibratorLoaderError(`Failed to parse JSON: ${message}`, { cause: err });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = IsotonicCalibratorArtifactSchema.safeParse(parsed);
|
|
175
|
+
if (!result.success) {
|
|
176
|
+
throw new IsotonicCalibratorLoaderError(
|
|
177
|
+
`Invalid isotonic calibrator artifact:\n${formatZodIssues(result.error)}`,
|
|
178
|
+
{ cause: result.error },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result.data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Extract isotonic_calibrator from a routing-calibration bundle object. */
|
|
186
|
+
export function parseIsotonicCalibratorFromBundle(parsed: unknown): IsotonicCalibratorArtifact {
|
|
187
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
188
|
+
throw new IsotonicCalibratorLoaderError('Routing calibration bundle must be a JSON object');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const isotonic = (parsed as Record<string, unknown>).isotonic_calibrator;
|
|
192
|
+
if (isotonic === undefined) {
|
|
193
|
+
throw new IsotonicCalibratorLoaderError('Routing calibration bundle missing isotonic_calibrator');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = IsotonicCalibratorArtifactSchema.safeParse(isotonic);
|
|
197
|
+
if (!result.success) {
|
|
198
|
+
throw new IsotonicCalibratorLoaderError(
|
|
199
|
+
`Invalid isotonic_calibrator in routing calibration bundle:\n${formatZodIssues(result.error)}`,
|
|
200
|
+
{ cause: result.error },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return result.data;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Load isotonic calibrator from routing-calibration bundle on disk.
|
|
209
|
+
* Returns null when the bundle file is missing.
|
|
210
|
+
*/
|
|
211
|
+
export function loadIsotonicCalibrator(
|
|
212
|
+
options?: LoadIsotonicCalibratorOptions,
|
|
213
|
+
): IsotonicCalibratorArtifact | null {
|
|
214
|
+
const filePath = options?.filePath ?? DEFAULT_ROUTING_CALIBRATION_PATH;
|
|
215
|
+
|
|
216
|
+
if (!existsSync(filePath)) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let raw: string;
|
|
221
|
+
try {
|
|
222
|
+
raw = readFileSync(filePath, 'utf8');
|
|
223
|
+
} catch (err: unknown) {
|
|
224
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
225
|
+
throw new IsotonicCalibratorLoaderError(`Failed to read routing calibration file: ${message}`, {
|
|
226
|
+
cause: err,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let parsed: unknown;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(raw);
|
|
233
|
+
} catch (err: unknown) {
|
|
234
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
235
|
+
throw new IsotonicCalibratorLoaderError(`Failed to parse routing calibration JSON: ${message}`, {
|
|
236
|
+
cause: err,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return parseIsotonicCalibratorFromBundle(parsed);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Resolve calibrator for online inference — missing or invalid artifacts fall back safely. */
|
|
244
|
+
export function resolveIsotonicCalibrator(
|
|
245
|
+
options?: LoadIsotonicCalibratorOptions,
|
|
246
|
+
): IsotonicCalibratorArtifact | null {
|
|
247
|
+
try {
|
|
248
|
+
return loadIsotonicCalibrator(options);
|
|
249
|
+
} catch (err: unknown) {
|
|
250
|
+
console.warn('Isotonic calibrator artifact invalid; using raw logistic fallback', {
|
|
251
|
+
error: err instanceof Error ? err.message : String(err),
|
|
252
|
+
});
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|