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.
Files changed (61) hide show
  1. package/.pi/extensions/smart-router/index.ts +9 -0
  2. package/.pi/extensions/smart-router/pi-model-scope.ts +127 -20
  3. package/.pi/extensions/smart-router/planning-delegate.ts +318 -0
  4. package/.pi/extensions/smart-router/route-and-delegate.ts +21 -1
  5. package/.pi/extensions/smart-router/types.ts +3 -0
  6. package/README.md +51 -0
  7. package/config/benchmark-profiles.json +145 -0
  8. package/config/models.yaml.example +5 -0
  9. package/config/routing-calibration.json.example +14 -2
  10. package/dist/config/defaults.d.ts +2 -2
  11. package/dist/config/defaults.d.ts.map +1 -1
  12. package/dist/config/defaults.js +5 -3
  13. package/dist/config/defaults.js.map +1 -1
  14. package/dist/config/pi-model-mapper.d.ts +12 -2
  15. package/dist/config/pi-model-mapper.d.ts.map +1 -1
  16. package/dist/config/pi-model-mapper.js +91 -6
  17. package/dist/config/pi-model-mapper.js.map +1 -1
  18. package/dist/domain/matching/hydra-input.d.ts +6 -5
  19. package/dist/domain/matching/hydra-input.d.ts.map +1 -1
  20. package/dist/domain/matching/hydra-input.js +73 -6
  21. package/dist/domain/matching/hydra-input.js.map +1 -1
  22. package/dist/domain/pipeline/router-pipeline.d.ts +22 -1
  23. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
  24. package/dist/domain/pipeline/router-pipeline.js +135 -22
  25. package/dist/domain/pipeline/router-pipeline.js.map +1 -1
  26. package/dist/domain/routing/isotonic-calibrator.d.ts +56 -0
  27. package/dist/domain/routing/isotonic-calibrator.d.ts.map +1 -0
  28. package/dist/domain/routing/isotonic-calibrator.js +187 -0
  29. package/dist/domain/routing/isotonic-calibrator.js.map +1 -0
  30. package/dist/domain/routing/p-success-classifier.d.ts +53 -7
  31. package/dist/domain/routing/p-success-classifier.d.ts.map +1 -1
  32. package/dist/domain/routing/p-success-classifier.js +205 -21
  33. package/dist/domain/routing/p-success-classifier.js.map +1 -1
  34. package/dist/domain/types/entities.d.ts +54 -0
  35. package/dist/domain/types/entities.d.ts.map +1 -1
  36. package/dist/domain/types/index.d.ts +1 -1
  37. package/dist/domain/types/index.d.ts.map +1 -1
  38. package/dist/domain/types/schemas.d.ts +29 -0
  39. package/dist/domain/types/schemas.d.ts.map +1 -1
  40. package/dist/domain/types/schemas.js +57 -0
  41. package/dist/domain/types/schemas.js.map +1 -1
  42. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -1
  43. package/dist/infrastructure/persistence/sqlite-store.js +2 -1
  44. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -1
  45. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +20 -1
  46. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -1
  47. package/dist/infrastructure/telemetry/routing-telemetry.js +82 -2
  48. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -1
  49. package/package.json +6 -3
  50. package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +29 -1
  51. package/src/config/defaults.ts +6 -2
  52. package/src/config/pi-model-mapper.ts +110 -6
  53. package/src/domain/matching/hydra-input.ts +86 -7
  54. package/src/domain/pipeline/router-pipeline.ts +195 -29
  55. package/src/domain/routing/isotonic-calibrator.ts +255 -0
  56. package/src/domain/routing/p-success-classifier.ts +299 -26
  57. package/src/domain/types/entities.ts +58 -0
  58. package/src/domain/types/index.ts +4 -0
  59. package/src/domain/types/schemas.ts +73 -0
  60. package/src/infrastructure/persistence/sqlite-store.ts +2 -0
  61. 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 model = selectLowestCostModel(tierCandidates);
949
- if (!model) {
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
- if (pin) {
956
- const pinnedModel = this.activeFleet.find(
957
- (m) => m.id === pin.pinned_model_id && m.healthy !== false,
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 (pinnedModel && pinnedModel.id !== model.id) {
960
- const skipBreakeven =
961
- turnType === 'planning' && this.isSaarPlanningBufferActive(request);
962
- if (!skipBreakeven) {
963
- const tokenEstimate =
964
- request.estimated_input_tokens ?? request.prompt_text.length;
965
- const breakeven = evaluateModelSwitchBreakeven(
966
- pinnedModel,
967
- model,
968
- tokenEstimate,
969
- tokenEstimate,
970
- this.options.saarConfig,
971
- );
972
- if (!breakeven.shouldSwitch) {
973
- this.currentBreakevenReason = 'breakeven_blocked';
974
- return { decided: false, stage: 'turn_envelope' };
975
- }
976
- this.currentBreakevenReason = 'breakeven_pass';
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, model, {
1049
+ decision: this.withEstimatedCost(request, targetModel, {
985
1050
  request_id: request.request_id,
986
- selected_model_id: model.id,
1051
+ selected_model_id: targetModel.id,
987
1052
  tier: targetTier,
988
1053
  stage: 'turn_envelope',
989
- reason_code: `turn_${turnType}`,
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
- this.currentPSuccessCheap = pResult.probability;
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
- pResult.probability,
1181
+ pSuccessForGate,
1045
1182
  alpha,
1046
1183
  );
1047
- this.logExpectedCostExplain(pResult.probability, alpha, selection);
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
+ }