pi-smart-router 0.1.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 (310) hide show
  1. package/.pi/extensions/smart-router/command-formatters.ts +167 -0
  2. package/.pi/extensions/smart-router/commands.ts +242 -0
  3. package/.pi/extensions/smart-router/dataset-export.ts +142 -0
  4. package/.pi/extensions/smart-router/delegate-stream.ts +105 -0
  5. package/.pi/extensions/smart-router/delegation-runtime.ts +355 -0
  6. package/.pi/extensions/smart-router/extension-setup.ts +115 -0
  7. package/.pi/extensions/smart-router/fleet-bootstrap.ts +244 -0
  8. package/.pi/extensions/smart-router/index.ts +115 -0
  9. package/.pi/extensions/smart-router/package.json +3 -0
  10. package/.pi/extensions/smart-router/pi-model-scope.ts +55 -0
  11. package/.pi/extensions/smart-router/pricing-lifecycle.ts +43 -0
  12. package/.pi/extensions/smart-router/route-and-delegate.ts +562 -0
  13. package/.pi/extensions/smart-router/routing-context.ts +171 -0
  14. package/.pi/extensions/smart-router/routing-outcomes.ts +44 -0
  15. package/.pi/extensions/smart-router/session-lifecycle.ts +140 -0
  16. package/.pi/extensions/smart-router/stream-delegation.ts +75 -0
  17. package/.pi/extensions/smart-router/types.ts +90 -0
  18. package/.pi/extensions/smart-router/utils.ts +58 -0
  19. package/README.md +593 -0
  20. package/bin/pi-smart-router.mjs +79 -0
  21. package/config/.gitkeep +0 -0
  22. package/config/hydra-projection-weights.json.example +1169 -0
  23. package/config/models.yaml.example +58 -0
  24. package/config/p-success-weights.json.example +19 -0
  25. package/config/routing-calibration.json.example +1605 -0
  26. package/config/routing-centroids.json.example +1570 -0
  27. package/config/routing-clusters.yaml.example +41 -0
  28. package/data/contrib/example.json +25 -0
  29. package/dist/api/explain/router-explain.d.ts +47 -0
  30. package/dist/api/explain/router-explain.d.ts.map +1 -0
  31. package/dist/api/explain/router-explain.js +101 -0
  32. package/dist/api/explain/router-explain.js.map +1 -0
  33. package/dist/api/middleware/pi-router-middleware.d.ts +86 -0
  34. package/dist/api/middleware/pi-router-middleware.d.ts.map +1 -0
  35. package/dist/api/middleware/pi-router-middleware.js +83 -0
  36. package/dist/api/middleware/pi-router-middleware.js.map +1 -0
  37. package/dist/cli/smart-router-cli.d.ts +85 -0
  38. package/dist/cli/smart-router-cli.d.ts.map +1 -0
  39. package/dist/cli/smart-router-cli.js +293 -0
  40. package/dist/cli/smart-router-cli.js.map +1 -0
  41. package/dist/config/defaults.d.ts +7 -0
  42. package/dist/config/defaults.d.ts.map +1 -0
  43. package/dist/config/defaults.js +33 -0
  44. package/dist/config/defaults.js.map +1 -0
  45. package/dist/config/models-loader.d.ts +22 -0
  46. package/dist/config/models-loader.d.ts.map +1 -0
  47. package/dist/config/models-loader.js +51 -0
  48. package/dist/config/models-loader.js.map +1 -0
  49. package/dist/config/pi-model-mapper.d.ts +40 -0
  50. package/dist/config/pi-model-mapper.d.ts.map +1 -0
  51. package/dist/config/pi-model-mapper.js +216 -0
  52. package/dist/config/pi-model-mapper.js.map +1 -0
  53. package/dist/config/routing-clusters-loader.d.ts +35 -0
  54. package/dist/config/routing-clusters-loader.d.ts.map +1 -0
  55. package/dist/config/routing-clusters-loader.js +100 -0
  56. package/dist/config/routing-clusters-loader.js.map +1 -0
  57. package/dist/domain/delegation/delegation-context.d.ts +23 -0
  58. package/dist/domain/delegation/delegation-context.d.ts.map +1 -0
  59. package/dist/domain/delegation/delegation-context.js +73 -0
  60. package/dist/domain/delegation/delegation-context.js.map +1 -0
  61. package/dist/domain/delegation/execution-ledger.d.ts +19 -0
  62. package/dist/domain/delegation/execution-ledger.d.ts.map +1 -0
  63. package/dist/domain/delegation/execution-ledger.js +23 -0
  64. package/dist/domain/delegation/execution-ledger.js.map +1 -0
  65. package/dist/domain/delegation/output-headroom.d.ts +39 -0
  66. package/dist/domain/delegation/output-headroom.d.ts.map +1 -0
  67. package/dist/domain/delegation/output-headroom.js +78 -0
  68. package/dist/domain/delegation/output-headroom.js.map +1 -0
  69. package/dist/domain/matching/cluster-matcher.d.ts +69 -0
  70. package/dist/domain/matching/cluster-matcher.d.ts.map +1 -0
  71. package/dist/domain/matching/cluster-matcher.js +294 -0
  72. package/dist/domain/matching/cluster-matcher.js.map +1 -0
  73. package/dist/domain/matching/embedding-provider.d.ts +21 -0
  74. package/dist/domain/matching/embedding-provider.d.ts.map +1 -0
  75. package/dist/domain/matching/embedding-provider.js +42 -0
  76. package/dist/domain/matching/embedding-provider.js.map +1 -0
  77. package/dist/domain/matching/hydra-input.d.ts +18 -0
  78. package/dist/domain/matching/hydra-input.d.ts.map +1 -0
  79. package/dist/domain/matching/hydra-input.js +40 -0
  80. package/dist/domain/matching/hydra-input.js.map +1 -0
  81. package/dist/domain/matching/hydra-matcher.d.ts +103 -0
  82. package/dist/domain/matching/hydra-matcher.d.ts.map +1 -0
  83. package/dist/domain/matching/hydra-matcher.js +275 -0
  84. package/dist/domain/matching/hydra-matcher.js.map +1 -0
  85. package/dist/domain/pinning/cache-economics.d.ts +53 -0
  86. package/dist/domain/pinning/cache-economics.d.ts.map +1 -0
  87. package/dist/domain/pinning/cache-economics.js +72 -0
  88. package/dist/domain/pinning/cache-economics.js.map +1 -0
  89. package/dist/domain/pinning/loop-escalation.d.ts +41 -0
  90. package/dist/domain/pinning/loop-escalation.d.ts.map +1 -0
  91. package/dist/domain/pinning/loop-escalation.js +137 -0
  92. package/dist/domain/pinning/loop-escalation.js.map +1 -0
  93. package/dist/domain/pinning/session-pinner.d.ts +81 -0
  94. package/dist/domain/pinning/session-pinner.d.ts.map +1 -0
  95. package/dist/domain/pinning/session-pinner.js +237 -0
  96. package/dist/domain/pinning/session-pinner.js.map +1 -0
  97. package/dist/domain/pinning/sub-route-policy.d.ts +41 -0
  98. package/dist/domain/pinning/sub-route-policy.d.ts.map +1 -0
  99. package/dist/domain/pinning/sub-route-policy.js +72 -0
  100. package/dist/domain/pinning/sub-route-policy.js.map +1 -0
  101. package/dist/domain/pipeline/router-pipeline.d.ts +180 -0
  102. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -0
  103. package/dist/domain/pipeline/router-pipeline.js +807 -0
  104. package/dist/domain/pipeline/router-pipeline.js.map +1 -0
  105. package/dist/domain/pipeline/safe-default.d.ts +25 -0
  106. package/dist/domain/pipeline/safe-default.d.ts.map +1 -0
  107. package/dist/domain/pipeline/safe-default.js +41 -0
  108. package/dist/domain/pipeline/safe-default.js.map +1 -0
  109. package/dist/domain/refactor-guardrails.d.ts +48 -0
  110. package/dist/domain/refactor-guardrails.d.ts.map +1 -0
  111. package/dist/domain/refactor-guardrails.js +129 -0
  112. package/dist/domain/refactor-guardrails.js.map +1 -0
  113. package/dist/domain/routing/context-fit.d.ts +53 -0
  114. package/dist/domain/routing/context-fit.d.ts.map +1 -0
  115. package/dist/domain/routing/context-fit.js +142 -0
  116. package/dist/domain/routing/context-fit.js.map +1 -0
  117. package/dist/domain/routing/expected-cost.d.ts +63 -0
  118. package/dist/domain/routing/expected-cost.d.ts.map +1 -0
  119. package/dist/domain/routing/expected-cost.js +177 -0
  120. package/dist/domain/routing/expected-cost.js.map +1 -0
  121. package/dist/domain/routing/p-success-classifier.d.ts +130 -0
  122. package/dist/domain/routing/p-success-classifier.d.ts.map +1 -0
  123. package/dist/domain/routing/p-success-classifier.js +350 -0
  124. package/dist/domain/routing/p-success-classifier.js.map +1 -0
  125. package/dist/domain/routing/tier-features.d.ts +70 -0
  126. package/dist/domain/routing/tier-features.d.ts.map +1 -0
  127. package/dist/domain/routing/tier-features.js +174 -0
  128. package/dist/domain/routing/tier-features.js.map +1 -0
  129. package/dist/domain/routing/tool-history-guard.d.ts +32 -0
  130. package/dist/domain/routing/tool-history-guard.d.ts.map +1 -0
  131. package/dist/domain/routing/tool-history-guard.js +110 -0
  132. package/dist/domain/routing/tool-history-guard.js.map +1 -0
  133. package/dist/domain/scoring/multi-objective.d.ts +48 -0
  134. package/dist/domain/scoring/multi-objective.d.ts.map +1 -0
  135. package/dist/domain/scoring/multi-objective.js +116 -0
  136. package/dist/domain/scoring/multi-objective.js.map +1 -0
  137. package/dist/domain/triage/triage-engine.d.ts +43 -0
  138. package/dist/domain/triage/triage-engine.d.ts.map +1 -0
  139. package/dist/domain/triage/triage-engine.js +317 -0
  140. package/dist/domain/triage/triage-engine.js.map +1 -0
  141. package/dist/domain/triage/turn-envelope.d.ts +17 -0
  142. package/dist/domain/triage/turn-envelope.d.ts.map +1 -0
  143. package/dist/domain/triage/turn-envelope.js +81 -0
  144. package/dist/domain/triage/turn-envelope.js.map +1 -0
  145. package/dist/domain/types/entities.d.ts +303 -0
  146. package/dist/domain/types/entities.d.ts.map +1 -0
  147. package/dist/domain/types/entities.js +6 -0
  148. package/dist/domain/types/entities.js.map +1 -0
  149. package/dist/domain/types/index.d.ts +4 -0
  150. package/dist/domain/types/index.d.ts.map +1 -0
  151. package/dist/domain/types/index.js +2 -0
  152. package/dist/domain/types/index.js.map +1 -0
  153. package/dist/domain/types/schemas.d.ts +346 -0
  154. package/dist/domain/types/schemas.d.ts.map +1 -0
  155. package/dist/domain/types/schemas.js +241 -0
  156. package/dist/domain/types/schemas.js.map +1 -0
  157. package/dist/domain/types/store-port.d.ts +44 -0
  158. package/dist/domain/types/store-port.d.ts.map +1 -0
  159. package/dist/domain/types/store-port.js +6 -0
  160. package/dist/domain/types/store-port.js.map +1 -0
  161. package/dist/index.d.ts +39 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +36 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/infra/gemini-provider.d.ts +28 -0
  166. package/dist/infra/gemini-provider.d.ts.map +1 -0
  167. package/dist/infra/gemini-provider.js +112 -0
  168. package/dist/infra/gemini-provider.js.map +1 -0
  169. package/dist/infra/telemetry.d.ts +51 -0
  170. package/dist/infra/telemetry.d.ts.map +1 -0
  171. package/dist/infra/telemetry.js +100 -0
  172. package/dist/infra/telemetry.js.map +1 -0
  173. package/dist/infrastructure/delegation/provider-error.d.ts +44 -0
  174. package/dist/infrastructure/delegation/provider-error.d.ts.map +1 -0
  175. package/dist/infrastructure/delegation/provider-error.js +179 -0
  176. package/dist/infrastructure/delegation/provider-error.js.map +1 -0
  177. package/dist/infrastructure/gateway/circuit-breaker.d.ts +68 -0
  178. package/dist/infrastructure/gateway/circuit-breaker.d.ts.map +1 -0
  179. package/dist/infrastructure/gateway/circuit-breaker.js +150 -0
  180. package/dist/infrastructure/gateway/circuit-breaker.js.map +1 -0
  181. package/dist/infrastructure/gateway/gateway-dispatch.d.ts +138 -0
  182. package/dist/infrastructure/gateway/gateway-dispatch.d.ts.map +1 -0
  183. package/dist/infrastructure/gateway/gateway-dispatch.js +308 -0
  184. package/dist/infrastructure/gateway/gateway-dispatch.js.map +1 -0
  185. package/dist/infrastructure/hardware/hardware-probe.d.ts +32 -0
  186. package/dist/infrastructure/hardware/hardware-probe.d.ts.map +1 -0
  187. package/dist/infrastructure/hardware/hardware-probe.js +199 -0
  188. package/dist/infrastructure/hardware/hardware-probe.js.map +1 -0
  189. package/dist/infrastructure/local/local-zero-tier.d.ts +47 -0
  190. package/dist/infrastructure/local/local-zero-tier.d.ts.map +1 -0
  191. package/dist/infrastructure/local/local-zero-tier.js +92 -0
  192. package/dist/infrastructure/local/local-zero-tier.js.map +1 -0
  193. package/dist/infrastructure/persistence/memory-store.d.ts +31 -0
  194. package/dist/infrastructure/persistence/memory-store.d.ts.map +1 -0
  195. package/dist/infrastructure/persistence/memory-store.js +87 -0
  196. package/dist/infrastructure/persistence/memory-store.js.map +1 -0
  197. package/dist/infrastructure/persistence/sqlite-store.d.ts +81 -0
  198. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -0
  199. package/dist/infrastructure/persistence/sqlite-store.js +658 -0
  200. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -0
  201. package/dist/infrastructure/pricing/litellm-fetch.d.ts +46 -0
  202. package/dist/infrastructure/pricing/litellm-fetch.d.ts.map +1 -0
  203. package/dist/infrastructure/pricing/litellm-fetch.js +145 -0
  204. package/dist/infrastructure/pricing/litellm-fetch.js.map +1 -0
  205. package/dist/infrastructure/pricing/price-broker.d.ts +56 -0
  206. package/dist/infrastructure/pricing/price-broker.d.ts.map +1 -0
  207. package/dist/infrastructure/pricing/price-broker.js +133 -0
  208. package/dist/infrastructure/pricing/price-broker.js.map +1 -0
  209. package/dist/infrastructure/pricing/pricing-monitor.d.ts +27 -0
  210. package/dist/infrastructure/pricing/pricing-monitor.d.ts.map +1 -0
  211. package/dist/infrastructure/pricing/pricing-monitor.js +46 -0
  212. package/dist/infrastructure/pricing/pricing-monitor.js.map +1 -0
  213. package/dist/infrastructure/telemetry/dataset-limits.d.ts +13 -0
  214. package/dist/infrastructure/telemetry/dataset-limits.d.ts.map +1 -0
  215. package/dist/infrastructure/telemetry/dataset-limits.js +28 -0
  216. package/dist/infrastructure/telemetry/dataset-limits.js.map +1 -0
  217. package/dist/infrastructure/telemetry/dataset-recorder.d.ts +48 -0
  218. package/dist/infrastructure/telemetry/dataset-recorder.d.ts.map +1 -0
  219. package/dist/infrastructure/telemetry/dataset-recorder.js +154 -0
  220. package/dist/infrastructure/telemetry/dataset-recorder.js.map +1 -0
  221. package/dist/infrastructure/telemetry/outcome-limits.d.ts +12 -0
  222. package/dist/infrastructure/telemetry/outcome-limits.d.ts.map +1 -0
  223. package/dist/infrastructure/telemetry/outcome-limits.js +28 -0
  224. package/dist/infrastructure/telemetry/outcome-limits.js.map +1 -0
  225. package/dist/infrastructure/telemetry/outcome-recorder.d.ts +29 -0
  226. package/dist/infrastructure/telemetry/outcome-recorder.d.ts.map +1 -0
  227. package/dist/infrastructure/telemetry/outcome-recorder.js +49 -0
  228. package/dist/infrastructure/telemetry/outcome-recorder.js.map +1 -0
  229. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +115 -0
  230. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -0
  231. package/dist/infrastructure/telemetry/routing-telemetry.js +529 -0
  232. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -0
  233. package/dist/infrastructure/telemetry/telemetry-limits.d.ts +16 -0
  234. package/dist/infrastructure/telemetry/telemetry-limits.d.ts.map +1 -0
  235. package/dist/infrastructure/telemetry/telemetry-limits.js +35 -0
  236. package/dist/infrastructure/telemetry/telemetry-limits.js.map +1 -0
  237. package/package.json +110 -0
  238. package/skills/router-backlog-orchestrator/SKILL.md +183 -0
  239. package/skills/router-backlog-orchestrator/references/github-router-issue-template.md +78 -0
  240. package/skills/router-backlog-orchestrator/references/packet-from-issue.md +99 -0
  241. package/skills/router-backlog-orchestrator/references/prioritization-rubric.md +65 -0
  242. package/skills/router-backlog-orchestrator/scripts/collect-backlog.sh +44 -0
  243. package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +151 -0
  244. package/src/api/explain/.gitkeep +0 -0
  245. package/src/api/explain/router-explain.ts +146 -0
  246. package/src/api/middleware/.gitkeep +0 -0
  247. package/src/api/middleware/pi-router-middleware.ts +169 -0
  248. package/src/cli/smart-router-cli.ts +436 -0
  249. package/src/config/.gitkeep +0 -0
  250. package/src/config/defaults.ts +35 -0
  251. package/src/config/models-loader.ts +68 -0
  252. package/src/config/pi-model-mapper.ts +285 -0
  253. package/src/config/routing-clusters-loader.ts +148 -0
  254. package/src/domain/delegation/delegation-context.ts +111 -0
  255. package/src/domain/delegation/execution-ledger.ts +34 -0
  256. package/src/domain/delegation/output-headroom.ts +131 -0
  257. package/src/domain/matching/.gitkeep +0 -0
  258. package/src/domain/matching/cluster-matcher.ts +474 -0
  259. package/src/domain/matching/embedding-provider.ts +79 -0
  260. package/src/domain/matching/hydra-input.ts +53 -0
  261. package/src/domain/matching/hydra-matcher.ts +454 -0
  262. package/src/domain/pinning/.gitkeep +0 -0
  263. package/src/domain/pinning/cache-economics.ts +128 -0
  264. package/src/domain/pinning/loop-escalation.ts +181 -0
  265. package/src/domain/pinning/session-pinner.ts +388 -0
  266. package/src/domain/pinning/sub-route-policy.ts +119 -0
  267. package/src/domain/pipeline/.gitkeep +0 -0
  268. package/src/domain/pipeline/router-pipeline.ts +1139 -0
  269. package/src/domain/pipeline/safe-default.ts +70 -0
  270. package/src/domain/refactor-guardrails.ts +202 -0
  271. package/src/domain/routing/context-fit.ts +221 -0
  272. package/src/domain/routing/expected-cost.ts +320 -0
  273. package/src/domain/routing/p-success-classifier.ts +511 -0
  274. package/src/domain/routing/tier-features.ts +290 -0
  275. package/src/domain/routing/tool-history-guard.ts +159 -0
  276. package/src/domain/scoring/.gitkeep +0 -0
  277. package/src/domain/scoring/multi-objective.ts +175 -0
  278. package/src/domain/triage/.gitkeep +0 -0
  279. package/src/domain/triage/triage-engine.ts +373 -0
  280. package/src/domain/triage/turn-envelope.ts +104 -0
  281. package/src/domain/types/.gitkeep +0 -0
  282. package/src/domain/types/entities.ts +384 -0
  283. package/src/domain/types/index.ts +41 -0
  284. package/src/domain/types/schemas.ts +289 -0
  285. package/src/domain/types/store-port.ts +59 -0
  286. package/src/index.ts +103 -0
  287. package/src/infra/gemini-provider.ts +142 -0
  288. package/src/infra/telemetry.ts +169 -0
  289. package/src/infrastructure/delegation/provider-error.ts +251 -0
  290. package/src/infrastructure/gateway/.gitkeep +0 -0
  291. package/src/infrastructure/gateway/circuit-breaker.ts +196 -0
  292. package/src/infrastructure/gateway/gateway-dispatch.ts +444 -0
  293. package/src/infrastructure/hardware/.gitkeep +0 -0
  294. package/src/infrastructure/hardware/hardware-probe.ts +276 -0
  295. package/src/infrastructure/local/.gitkeep +0 -0
  296. package/src/infrastructure/local/local-zero-tier.ts +151 -0
  297. package/src/infrastructure/persistence/.gitkeep +0 -0
  298. package/src/infrastructure/persistence/memory-store.ts +122 -0
  299. package/src/infrastructure/persistence/sqlite-store.ts +900 -0
  300. package/src/infrastructure/pricing/.gitkeep +0 -0
  301. package/src/infrastructure/pricing/litellm-fetch.ts +215 -0
  302. package/src/infrastructure/pricing/price-broker.ts +183 -0
  303. package/src/infrastructure/pricing/pricing-monitor.ts +65 -0
  304. package/src/infrastructure/telemetry/.gitkeep +0 -0
  305. package/src/infrastructure/telemetry/dataset-limits.ts +39 -0
  306. package/src/infrastructure/telemetry/dataset-recorder.ts +215 -0
  307. package/src/infrastructure/telemetry/outcome-limits.ts +42 -0
  308. package/src/infrastructure/telemetry/outcome-recorder.ts +118 -0
  309. package/src/infrastructure/telemetry/routing-telemetry.ts +824 -0
  310. package/src/infrastructure/telemetry/telemetry-limits.ts +50 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * HyDRA embedding matcher — T048, FR-005.
3
+ *
4
+ * Embeds prompt text via ONNX (Xenova/all-MiniLM-L6-v2 384-dim),
5
+ * projects to requirement dimensions [reasoning, code_gen, tool_use],
6
+ * and scores fleet candidates with a shortfall gate.
7
+ *
8
+ * Shortfall gate (data-model §Validation): candidate excluded when
9
+ * any capability dimension shortfall > 0 (quality parity).
10
+ *
11
+ * Budget: 80–120 ms (configurable, default 100 ms).
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+
17
+ import { z } from 'zod';
18
+
19
+ import { DEFAULT_OPERATOR_CONFIG } from '../../config/defaults.js';
20
+ import { buildHydraInput } from './hydra-input.js';
21
+ import {
22
+ createOnnxTextEmbedder,
23
+ EMBEDDING_DIM,
24
+ type TextEmbedder,
25
+ } from './embedding-provider.js';
26
+ import {
27
+ scoreMultiObjective,
28
+ type FrugalityWeights,
29
+ } from '../scoring/multi-objective.js';
30
+ import type {
31
+ CandidateScore,
32
+ ModelCapabilities,
33
+ ModelProfile,
34
+ RoutingRequest,
35
+ } from '../types/index.js';
36
+
37
+ // ─── Requirement vector ──────────────────────────────────────────────────────
38
+
39
+ export interface RequirementVector {
40
+ readonly reasoning: number;
41
+ readonly code_gen: number;
42
+ readonly tool_use: number;
43
+ }
44
+
45
+ // ─── Embedding provider port ─────────────────────────────────────────────────
46
+
47
+ export interface EmbeddingProvider {
48
+ extractRequirements(text: string): Promise<RequirementVector>;
49
+ dispose(): Promise<void>;
50
+ }
51
+
52
+ // ─── Match result ────────────────────────────────────────────────────────────
53
+
54
+ export interface MatchResult {
55
+ readonly selected: CandidateScore | null;
56
+ readonly candidates: readonly CandidateScore[];
57
+ readonly requirements: RequirementVector;
58
+ readonly elapsedMs: number;
59
+ readonly budgetExceeded: boolean;
60
+ }
61
+
62
+ // ─── Configuration ───────────────────────────────────────────────────────────
63
+
64
+ export interface HydraMatcherConfig {
65
+ readonly artifactCachePath: string;
66
+ readonly budgetMs?: number;
67
+ readonly frugality?: FrugalityWeights;
68
+ readonly projectionWeightsPath?: string;
69
+ }
70
+
71
+ const DEFAULT_BUDGET_MS = 100;
72
+ const MIN_BUDGET_MS = 80;
73
+ const MAX_BUDGET_MS = 120;
74
+
75
+ export const DEFAULT_HYDRA_PROJECTION_WEIGHTS_PATH = resolve(
76
+ 'config',
77
+ 'hydra-projection-weights.json',
78
+ );
79
+
80
+ export const HYDRA_PROJECTION_OUTPUT_DIM = 3;
81
+
82
+ export const HydraProjectionWeightsSchema = z.object({
83
+ version: z.literal(1),
84
+ embedding_dim: z.literal(EMBEDDING_DIM),
85
+ weights: z
86
+ .array(z.array(z.number().finite()).length(EMBEDDING_DIM))
87
+ .length(HYDRA_PROJECTION_OUTPUT_DIM),
88
+ bias: z.array(z.number().finite()).length(HYDRA_PROJECTION_OUTPUT_DIM),
89
+ });
90
+
91
+ export interface HydraProjectionWeights {
92
+ readonly version: 1;
93
+ readonly embedding_dim: typeof EMBEDDING_DIM;
94
+ readonly weights: readonly (readonly number[])[];
95
+ readonly bias: readonly number[];
96
+ }
97
+
98
+ export interface LoadHydraProjectionWeightsOptions {
99
+ readonly filePath?: string;
100
+ }
101
+
102
+ export class HydraProjectionWeightsLoaderError extends Error {
103
+ override readonly name = 'HydraProjectionWeightsLoaderError';
104
+
105
+ constructor(message: string, options?: { cause?: unknown }) {
106
+ super(message, options);
107
+ }
108
+ }
109
+
110
+ function formatZodIssues(error: z.ZodError): string {
111
+ return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('\n');
112
+ }
113
+
114
+ export function parseHydraProjectionWeightsJson(raw: string): HydraProjectionWeights {
115
+ let parsed: unknown;
116
+ try {
117
+ parsed = JSON.parse(raw);
118
+ } catch (err: unknown) {
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ throw new HydraProjectionWeightsLoaderError(`Failed to parse JSON: ${message}`, {
121
+ cause: err,
122
+ });
123
+ }
124
+
125
+ const result = HydraProjectionWeightsSchema.safeParse(parsed);
126
+ if (!result.success) {
127
+ throw new HydraProjectionWeightsLoaderError(
128
+ `Invalid HyDRA projection weights artifact:\n${formatZodIssues(result.error)}`,
129
+ { cause: result.error },
130
+ );
131
+ }
132
+
133
+ return result.data;
134
+ }
135
+
136
+ /**
137
+ * Load HyDRA projection weights from disk. Returns null when the artifact is
138
+ * missing; throws only when the file exists but is invalid.
139
+ */
140
+ export function loadHydraProjectionWeights(
141
+ options?: LoadHydraProjectionWeightsOptions,
142
+ ): HydraProjectionWeights | null {
143
+ const filePath = options?.filePath ?? DEFAULT_HYDRA_PROJECTION_WEIGHTS_PATH;
144
+
145
+ if (!existsSync(filePath)) {
146
+ return null;
147
+ }
148
+
149
+ let raw: string;
150
+ try {
151
+ raw = readFileSync(filePath, 'utf8');
152
+ } catch (err: unknown) {
153
+ const message = err instanceof Error ? err.message : String(err);
154
+ throw new HydraProjectionWeightsLoaderError(`Failed to read weights file: ${message}`, {
155
+ cause: err,
156
+ });
157
+ }
158
+
159
+ return parseHydraProjectionWeightsJson(raw);
160
+ }
161
+
162
+ /** Resolve projection weights — missing artifacts fall back to placeholder projection. */
163
+ export function resolveHydraProjectionWeights(
164
+ options?: LoadHydraProjectionWeightsOptions,
165
+ ): HydraProjectionWeights | null {
166
+ try {
167
+ return loadHydraProjectionWeights(options);
168
+ } catch (err: unknown) {
169
+ console.warn('HyDRA projection weights artifact invalid; using placeholder projection', {
170
+ error: err instanceof Error ? err.message : String(err),
171
+ });
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // ─── Scoring helpers ─────────────────────────────────────────────────────────
177
+
178
+ function dimensionShortfall(requirement: number, capability: number): number {
179
+ return Math.max(0, requirement - capability);
180
+ }
181
+
182
+ interface ShortfallResult {
183
+ readonly perDimension: RequirementVector;
184
+ readonly max: number;
185
+ readonly total: number;
186
+ }
187
+
188
+ function computeShortfall(
189
+ requirement: RequirementVector,
190
+ capabilities: ModelCapabilities,
191
+ ): ShortfallResult {
192
+ const reasoning = dimensionShortfall(requirement.reasoning, capabilities.reasoning);
193
+ const codeGen = dimensionShortfall(requirement.code_gen, capabilities.code_gen);
194
+ const toolUse = dimensionShortfall(requirement.tool_use, capabilities.tool_use);
195
+
196
+ return {
197
+ perDimension: { reasoning, code_gen: codeGen, tool_use: toolUse },
198
+ max: Math.max(reasoning, codeGen, toolUse),
199
+ total: reasoning + codeGen + toolUse,
200
+ };
201
+ }
202
+
203
+ function cosineSimilarity(a: RequirementVector, b: ModelCapabilities): number {
204
+ const dot =
205
+ a.reasoning * b.reasoning + a.code_gen * b.code_gen + a.tool_use * b.tool_use;
206
+ const magA = Math.sqrt(
207
+ a.reasoning ** 2 + a.code_gen ** 2 + a.tool_use ** 2,
208
+ );
209
+ const magB = Math.sqrt(
210
+ b.reasoning ** 2 + b.code_gen ** 2 + b.tool_use ** 2,
211
+ );
212
+ if (magA === 0 || magB === 0) return 0;
213
+ return dot / (magA * magB);
214
+ }
215
+
216
+ // ─── Projection helpers (384-dim → 3 requirement dimensions) ─────────────────
217
+
218
+ function sigmoid(x: number): number {
219
+ return 1 / (1 + Math.exp(-x));
220
+ }
221
+
222
+ function meanSlice(embedding: Float32Array, start: number, end: number): number {
223
+ let sum = 0;
224
+ for (let i = start; i < end; i++) {
225
+ sum += embedding[i] ?? 0;
226
+ }
227
+ return sum / (end - start);
228
+ }
229
+
230
+ function projectToRequirementsPlaceholder(embedding: Float32Array): RequirementVector {
231
+ const third = Math.floor(EMBEDDING_DIM / 3);
232
+
233
+ return {
234
+ reasoning: sigmoid(meanSlice(embedding, 0, third)),
235
+ code_gen: sigmoid(meanSlice(embedding, third, 2 * third)),
236
+ tool_use: sigmoid(meanSlice(embedding, 2 * third, EMBEDDING_DIM)),
237
+ };
238
+ }
239
+
240
+ function linearProjection(
241
+ embedding: Float32Array,
242
+ weights: HydraProjectionWeights,
243
+ ): readonly [number, number, number] {
244
+ const logits: number[] = [];
245
+
246
+ for (let dim = 0; dim < HYDRA_PROJECTION_OUTPUT_DIM; dim++) {
247
+ const row = weights.weights[dim]!;
248
+ let sum = weights.bias[dim] ?? 0;
249
+ for (let i = 0; i < EMBEDDING_DIM; i++) {
250
+ sum += (embedding[i] ?? 0) * (row[i] ?? 0);
251
+ }
252
+ logits.push(sum);
253
+ }
254
+
255
+ return [logits[0]!, logits[1]!, logits[2]!];
256
+ }
257
+
258
+ function projectToRequirementsLearned(
259
+ embedding: Float32Array,
260
+ weights: HydraProjectionWeights,
261
+ ): RequirementVector {
262
+ const [reasoningLogit, codeGenLogit, toolUseLogit] = linearProjection(embedding, weights);
263
+
264
+ return {
265
+ reasoning: sigmoid(reasoningLogit),
266
+ code_gen: sigmoid(codeGenLogit),
267
+ tool_use: sigmoid(toolUseLogit),
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Project a 384-dim embedding to 3 requirement dimensions.
273
+ *
274
+ * Uses learned linear projection (sigmoid(embedding @ W + b)) when weights are
275
+ * provided; otherwise falls back to deterministic mean-pooled-thirds placeholder.
276
+ */
277
+ export function projectToRequirements(
278
+ embedding: Float32Array,
279
+ weights?: HydraProjectionWeights | null,
280
+ ): RequirementVector {
281
+ if (embedding.length !== EMBEDDING_DIM) {
282
+ throw new Error(
283
+ `Embedding shape mismatch: expected ${EMBEDDING_DIM}, got ${embedding.length}`,
284
+ );
285
+ }
286
+
287
+ if (weights) {
288
+ return projectToRequirementsLearned(embedding, weights);
289
+ }
290
+
291
+ return projectToRequirementsPlaceholder(embedding);
292
+ }
293
+
294
+ // ─── HyDRA embedding adapter ─────────────────────────────────────────────────
295
+
296
+ /**
297
+ * Adapts a shared TextEmbedder for HyDRA requirement extraction.
298
+ * dispose() delegates to the underlying embedder for shared lifecycle.
299
+ */
300
+ export function wrapHydraEmbeddingProvider(
301
+ embedder: TextEmbedder,
302
+ projectionWeights?: HydraProjectionWeights | null,
303
+ ): EmbeddingProvider {
304
+ return {
305
+ async extractRequirements(text: string): Promise<RequirementVector> {
306
+ const embedding = await embedder.embed(text);
307
+ return projectToRequirements(embedding, projectionWeights);
308
+ },
309
+
310
+ async dispose(): Promise<void> {
311
+ await embedder.dispose();
312
+ },
313
+ };
314
+ }
315
+
316
+ // ─── HydraMatcher ────────────────────────────────────────────────────────────
317
+
318
+ export class HydraMatcher {
319
+ private readonly provider: EmbeddingProvider;
320
+ private readonly budgetMs: number;
321
+ private readonly frugality: FrugalityWeights;
322
+ private readonly projectionWeights: HydraProjectionWeights | null;
323
+
324
+ constructor(provider: EmbeddingProvider, config: HydraMatcherConfig) {
325
+ const budget = config.budgetMs ?? DEFAULT_BUDGET_MS;
326
+ if (budget < MIN_BUDGET_MS || budget > MAX_BUDGET_MS) {
327
+ throw new Error(
328
+ `HyDRA budget must be ${MIN_BUDGET_MS}–${MAX_BUDGET_MS}ms, got ${budget}ms`,
329
+ );
330
+ }
331
+ this.provider = provider;
332
+ this.budgetMs = budget;
333
+ this.frugality = config.frugality ?? DEFAULT_OPERATOR_CONFIG.frugality;
334
+ this.projectionWeights = resolveHydraProjectionWeights(
335
+ config.projectionWeightsPath ? { filePath: config.projectionWeightsPath } : undefined,
336
+ );
337
+ }
338
+
339
+ /** Whether learned projection weights were loaded at init. */
340
+ usesLearnedProjection(): boolean {
341
+ return this.projectionWeights !== null;
342
+ }
343
+
344
+ async match(
345
+ request: RoutingRequest,
346
+ fleet: readonly ModelProfile[],
347
+ ): Promise<MatchResult> {
348
+ const start = performance.now();
349
+
350
+ const requirements = await this.provider.extractRequirements(
351
+ buildHydraInput(request),
352
+ );
353
+ this.validateRequirements(requirements);
354
+
355
+ const healthyFleet = fleet.filter((m) => m.healthy !== false);
356
+ const candidates: CandidateScore[] = [];
357
+
358
+ for (const model of healthyFleet) {
359
+ const elapsed = performance.now() - start;
360
+ if (elapsed > this.budgetMs) {
361
+ break;
362
+ }
363
+
364
+ const shortfall = computeShortfall(requirements, model.capabilities);
365
+ const hasShortfall = shortfall.max > 0;
366
+
367
+ const score = hasShortfall
368
+ ? 0
369
+ : cosineSimilarity(requirements, model.capabilities);
370
+
371
+ candidates.push({
372
+ model_id: model.id,
373
+ score,
374
+ shortfall: shortfall.max,
375
+ rejected_reason: hasShortfall ? 'shortfall_gate' : null,
376
+ });
377
+ }
378
+
379
+ const shortfallByModel = new Map(
380
+ candidates.map((c) => [c.model_id, c.shortfall]),
381
+ );
382
+ const multiObjective = scoreMultiObjective(
383
+ candidates,
384
+ healthyFleet,
385
+ this.frugality,
386
+ );
387
+
388
+ const toCandidateScore = (
389
+ scored: (typeof multiObjective.candidates)[number],
390
+ ): CandidateScore => ({
391
+ model_id: scored.model_id,
392
+ score: scored.composite_score,
393
+ shortfall: shortfallByModel.get(scored.model_id) ?? 0,
394
+ rejected_reason: scored.rejected_reason,
395
+ });
396
+
397
+ const rankedCandidates = multiObjective.candidates.map(toCandidateScore);
398
+ const selected = multiObjective.selected
399
+ ? toCandidateScore(multiObjective.selected)
400
+ : null;
401
+
402
+ const elapsedMs = performance.now() - start;
403
+
404
+ return {
405
+ selected,
406
+ candidates: rankedCandidates,
407
+ requirements,
408
+ elapsedMs,
409
+ budgetExceeded: elapsedMs > this.budgetMs,
410
+ };
411
+ }
412
+
413
+ async dispose(): Promise<void> {
414
+ await this.provider.dispose();
415
+ }
416
+
417
+ private validateRequirements(req: RequirementVector): void {
418
+ const dims: readonly (keyof RequirementVector)[] = [
419
+ 'reasoning',
420
+ 'code_gen',
421
+ 'tool_use',
422
+ ];
423
+ for (const key of dims) {
424
+ const value = req[key];
425
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
426
+ throw new Error(
427
+ `Invalid requirement dimension '${key}': ${String(value)}`,
428
+ );
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ // ─── ONNX embedding provider factory ─────────────────────────────────────────
435
+
436
+ export interface CreateOnnxEmbeddingProviderOptions {
437
+ readonly projectionWeightsPath?: string;
438
+ }
439
+
440
+ /**
441
+ * Creates an EmbeddingProvider backed by the shared ONNX text embedder.
442
+ * For multi-matcher setups, create one TextEmbedder via createOnnxTextEmbedder
443
+ * and pass wrapHydraEmbeddingProvider(embedder) to HyDRA while sharing embed().
444
+ */
445
+ export async function createOnnxEmbeddingProvider(
446
+ artifactCachePath: string,
447
+ options?: CreateOnnxEmbeddingProviderOptions,
448
+ ): Promise<EmbeddingProvider> {
449
+ const embedder = await createOnnxTextEmbedder(artifactCachePath);
450
+ const projectionWeights = resolveHydraProjectionWeights(
451
+ options?.projectionWeightsPath ? { filePath: options.projectionWeightsPath } : undefined,
452
+ );
453
+ return wrapHydraEmbeddingProvider(embedder, projectionWeights);
454
+ }
File without changes
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Cache-warmup economics — FR-008 rule #4, SC-006.
3
+ *
4
+ * Decides whether switching providers justifies the cache-warmup penalty.
5
+ * A provider switch invalidates upstream KV-cache, so the router must
6
+ * compare the warmup cost against projected savings from the candidate
7
+ * model before breaking a pin.
8
+ *
9
+ * When warmup cost exceeds projected savings the pin holds (User Story 4,
10
+ * scenario 4).
11
+ */
12
+
13
+ import type { ModelProfile, SessionPin } from '../types/index.js';
14
+
15
+ // ─── Types ────────────────────────────────────────────────────────────────────
16
+
17
+ export interface CacheEconomicsConfig {
18
+ /**
19
+ * Multiplier applied to estimated_input_tokens to approximate
20
+ * the token cost of re-prompting a cold provider cache.
21
+ * Default: 1.15 (15 % warmup overhead).
22
+ */
23
+ readonly warmupCostMultiplier?: number;
24
+
25
+ /**
26
+ * Minimum per-1M-token savings required to justify a provider switch.
27
+ * Prevents switching for negligible price differences.
28
+ * Default: 0.10 (USD).
29
+ */
30
+ readonly minSavingsThreshold?: number;
31
+
32
+ /**
33
+ * Number of remaining turns to amortize savings over.
34
+ * Higher values favor switching; lower values favor keeping the pin.
35
+ * Default: 5.
36
+ */
37
+ readonly projectedRemainingTurns?: number;
38
+ }
39
+
40
+ export interface CacheEconomicsResult {
41
+ readonly shouldSwitch: boolean;
42
+ readonly warmupCostUsd: number;
43
+ readonly projectedSavingsUsd: number;
44
+ readonly reason: string;
45
+ }
46
+
47
+ // ─── Defaults ─────────────────────────────────────────────────────────────────
48
+
49
+ const DEFAULT_WARMUP_COST_MULTIPLIER = 1.15;
50
+ const DEFAULT_MIN_SAVINGS_THRESHOLD = 0.10;
51
+ const DEFAULT_PROJECTED_REMAINING_TURNS = 5;
52
+
53
+ // ─── Evaluation ───────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Evaluate whether switching from the currently-pinned model to a candidate
57
+ * on a different provider is justified by projected cost savings.
58
+ *
59
+ * Returns `shouldSwitch: false` when:
60
+ * - Candidate is on the same provider (no cache penalty).
61
+ * Caller should handle same-provider switches without this check.
62
+ * - Warmup cost exceeds projected savings.
63
+ * - Savings fall below the minimum threshold.
64
+ *
65
+ * Returns `shouldSwitch: true` only when projected savings meaningfully
66
+ * exceed the cache-warmup penalty.
67
+ */
68
+ export function evaluateCacheEconomics(
69
+ pin: SessionPin,
70
+ pinnedModel: ModelProfile,
71
+ candidate: ModelProfile,
72
+ estimatedInputTokens: number,
73
+ config?: CacheEconomicsConfig,
74
+ ): CacheEconomicsResult {
75
+ const warmupMultiplier =
76
+ config?.warmupCostMultiplier ?? DEFAULT_WARMUP_COST_MULTIPLIER;
77
+ const minSavings =
78
+ config?.minSavingsThreshold ?? DEFAULT_MIN_SAVINGS_THRESHOLD;
79
+ const remainingTurns =
80
+ config?.projectedRemainingTurns ?? DEFAULT_PROJECTED_REMAINING_TURNS;
81
+
82
+ if (pinnedModel.provider === candidate.provider) {
83
+ return {
84
+ shouldSwitch: false,
85
+ warmupCostUsd: 0,
86
+ projectedSavingsUsd: 0,
87
+ reason: 'same_provider_no_cache_penalty',
88
+ };
89
+ }
90
+
91
+ const tokenCountM = estimatedInputTokens / 1_000_000;
92
+
93
+ const warmupTokens = estimatedInputTokens * (warmupMultiplier - 1);
94
+ const warmupCostUsd =
95
+ (warmupTokens / 1_000_000) * candidate.pricing.fallback_cost_per_1m;
96
+
97
+ const perTurnSavings =
98
+ (pinnedModel.pricing.fallback_cost_per_1m -
99
+ candidate.pricing.fallback_cost_per_1m) *
100
+ tokenCountM;
101
+
102
+ const projectedSavingsUsd = perTurnSavings * remainingTurns;
103
+
104
+ if (projectedSavingsUsd < minSavings) {
105
+ return {
106
+ shouldSwitch: false,
107
+ warmupCostUsd,
108
+ projectedSavingsUsd,
109
+ reason: 'savings_below_threshold',
110
+ };
111
+ }
112
+
113
+ if (warmupCostUsd >= projectedSavingsUsd) {
114
+ return {
115
+ shouldSwitch: false,
116
+ warmupCostUsd,
117
+ projectedSavingsUsd,
118
+ reason: 'warmup_exceeds_savings',
119
+ };
120
+ }
121
+
122
+ return {
123
+ shouldSwitch: true,
124
+ warmupCostUsd,
125
+ projectedSavingsUsd,
126
+ reason: 'switch_justified',
127
+ };
128
+ }