world-model-optimizer 0.2.0__py3-none-any.whl

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 (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/cli/route_app.py ADDED
@@ -0,0 +1,274 @@
1
+ """`wmo optimize route`: fit, tune, and report learned inference policies from outcome matrices.
2
+
3
+ The routing optimizer's CLI face, sitting beside `wmo optimize harness` in the optimizer
4
+ family. Consumes a persisted `OutcomeMatrix` (produced by closed-loop pool evaluation or a
5
+ research adapter such as RouterBench) and emits the policy artifact serving loads, plus the
6
+ improvement report the endpoint cites. `tune` is the one post-fit control: it moves a fitted
7
+ policy's cost/quality dial without refitting. Vocabulary note: "route" is developer-facing CLI
8
+ only; customer copy never says router.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from rich.console import Console
18
+
19
+ from wmo.optimize.knn import (
20
+ COST_QUALITY_ANCHORS,
21
+ apply_cost_quality,
22
+ cost_quality_knobs,
23
+ cost_quality_named_point,
24
+ fit_knn_policy,
25
+ )
26
+ from wmo.optimize.outcomes import OutcomeMatrix
27
+ from wmo.optimize.policy import (
28
+ KNN_BANK_FILENAME,
29
+ POLICY_FILENAME,
30
+ EmbedderSpec,
31
+ RoutingPolicy,
32
+ )
33
+ from wmo.optimize.report import build_report
34
+ from wmo.optimize.routing import evaluate_policy, fit_rank_policy, rerank_policy
35
+
36
+ route_app = typer.Typer(
37
+ help="Fit and report learned inference policies from closed-loop outcome matrices.",
38
+ no_args_is_help=True,
39
+ )
40
+
41
+ _console = Console()
42
+
43
+
44
+ @route_app.command("fit")
45
+ def fit(
46
+ matrix_file: str = typer.Argument(..., help="OutcomeMatrix JSON (closed-loop eval output)."),
47
+ kind: str = typer.Option(
48
+ "rank",
49
+ "--kind",
50
+ help="knn (guarded nearest-neighbor evidence, the validated champion) | rank "
51
+ "(Avengers cluster ranks).",
52
+ ),
53
+ out: str = typer.Option(
54
+ POLICY_FILENAME, "--out", help="Where to write the fitted policy JSON."
55
+ ),
56
+ fallback: str = typer.Option(
57
+ None,
58
+ "--fallback",
59
+ help="(knn) Baseline model every request uses unless the evidence says otherwise. "
60
+ "Default: the best single model on the fit set.",
61
+ ),
62
+ z: float = typer.Option(
63
+ 0.5,
64
+ "--z",
65
+ min=0.0,
66
+ help="(knn) Confidence knob: standard errors of paired evidence a pick must clear to "
67
+ "leave the fallback (doubled when it is also pricier). Higher = stricter = more "
68
+ "requests stay on the fallback; 0 routes on any positive difference.",
69
+ ),
70
+ rag_num: int = typer.Option(50, "--rag-num", min=1, help="(knn) Neighbor budget."),
71
+ rag_thres: float = typer.Option(
72
+ 0.95,
73
+ "--rag-thres",
74
+ min=0.0,
75
+ max=1.0,
76
+ help="(knn) Keep neighbors above this fraction of the rag-num-th best similarity.",
77
+ ),
78
+ min_pairs: int = typer.Option(
79
+ 8, "--min-pairs", min=0, help="(knn) Neighbors scored on both sides before routing away."
80
+ ),
81
+ floor_q: float = typer.Option(
82
+ 0.05,
83
+ "--floor-q",
84
+ min=0.0,
85
+ max=1.0,
86
+ help="Novelty floor quantile: abstain to the fallback when a query's best bank "
87
+ "similarity is below this quantile of the bank's own nearest-neighbor sims "
88
+ "(coverage/robustness knob for task drift; 0 = off, the exact validated champion).",
89
+ ),
90
+ se_floor: bool = typer.Option(
91
+ True,
92
+ "--se-floor/--no-se-floor",
93
+ help="(knn) Floor the guard's standard error on thin neighborhoods (small-bank safety).",
94
+ ),
95
+ clusters: int = typer.Option(64, "--clusters", min=1, help="k-means cluster count."),
96
+ seed: int = typer.Option(42, "--seed", help="Clustering seed."),
97
+ top_k_clusters: int = typer.Option(2, "--top-k-clusters", min=1),
98
+ beta: float = typer.Option(6.0, "--beta", help="Cluster softmax sharpness."),
99
+ cost_weight: float = typer.Option(
100
+ 0.0,
101
+ "--cost-weight",
102
+ min=0.0,
103
+ help="Quality/cost knob: reward points paid per average-call-cost unit (0 = pure "
104
+ "accuracy ranking, the Avengers reference behavior).",
105
+ ),
106
+ embedder: str = typer.Option("hashing", "--embedder", help="hashing | azure"),
107
+ dim: int = typer.Option(512, "--dim", help="Embedding dimension."),
108
+ deployment: str = typer.Option(None, "--deployment", help="(azure) embedding deployment."),
109
+ endpoint: str = typer.Option(None, "--endpoint", help="(azure) resource endpoint."),
110
+ api_key_env: str = typer.Option(
111
+ None, "--api-key-env", help="(azure) env var holding the account key."
112
+ ),
113
+ ) -> None:
114
+ """Fit a routing policy on an outcome matrix (kNN evidence or Avengers cluster ranks)."""
115
+ if kind not in ("rank", "knn"):
116
+ raise typer.BadParameter(f"unknown kind '{kind}'; use knn or rank")
117
+ matrix = OutcomeMatrix.load(Path(matrix_file))
118
+ if embedder not in ("hashing", "azure"):
119
+ raise typer.BadParameter(f"unknown embedder '{embedder}'; use hashing or azure")
120
+ spec = (
121
+ EmbedderSpec(dim=dim)
122
+ if embedder == "hashing"
123
+ else EmbedderSpec(
124
+ kind="azure",
125
+ dim=dim,
126
+ deployment=deployment,
127
+ endpoint=endpoint,
128
+ api_key_env=api_key_env,
129
+ )
130
+ )
131
+ out_path = Path(out)
132
+ if rag_thres <= 0.0:
133
+ # typer's min is inclusive but the artifact field requires > 0; fail before the fit
134
+ # writes a sidecar it will then abandon.
135
+ raise typer.BadParameter("--rag-thres must be greater than 0")
136
+ built = spec.build() # ONE embedder for fit and evaluation; azure would otherwise embed twice
137
+ if kind == "knn":
138
+ if cost_weight > 0.0:
139
+ raise typer.BadParameter(
140
+ "--cost-weight re-ranks cluster evidence and applies to --kind rank only; a knn "
141
+ "policy trades cost through its dial instead: fit it, then "
142
+ "`wmo optimize route tune <policy.json> --cost-quality <0..1>`"
143
+ )
144
+ # The sidecar goes beside the policy file: that is where serving resolves it from.
145
+ policy = fit_knn_policy(
146
+ matrix,
147
+ bank_path=out_path.parent / KNN_BANK_FILENAME,
148
+ embedder=spec,
149
+ embed_with=built,
150
+ guard_model=fallback,
151
+ rag_num=rag_num,
152
+ rag_thres=rag_thres,
153
+ z=z,
154
+ min_pairs=min_pairs,
155
+ se_floor=se_floor,
156
+ floor_q=floor_q,
157
+ fitted_from=f"{matrix_file} knn z={z} k={rag_num} q={floor_q} {embedder}-{dim}",
158
+ )
159
+ else:
160
+ policy = fit_rank_policy(
161
+ matrix,
162
+ embedder=spec,
163
+ n_clusters=clusters,
164
+ seed=seed,
165
+ top_k_clusters=top_k_clusters,
166
+ beta=beta,
167
+ fitted_from=f"{matrix_file} seed={seed} k={clusters} {embedder}-{dim}",
168
+ )
169
+ if cost_weight > 0.0:
170
+ policy = rerank_policy(policy, cost_weight=cost_weight)
171
+ policy.save(out_path)
172
+ result = evaluate_policy(policy, matrix, matrix.scenario_ids(), embedder=built)
173
+ if kind == "knn":
174
+ routed = 1.0 - result.model_mix.get(policy.default_model, 0.0)
175
+ _console.print(
176
+ f"[green]✓[/green] fitted knn policy over {result.scenarios} scenarios -> {out}\n"
177
+ f" bank {out_path.parent / KNN_BANK_FILENAME}, fallback {policy.default_model}, "
178
+ f"z={z}\n"
179
+ f" routed away from the fallback {routed:.1%} of the time; cost/scenario "
180
+ f"${result.cost_per_scenario:.5f}\n"
181
+ f" fit-set accuracy {result.accuracy:.4f} is IN-SAMPLE (every request retrieves its "
182
+ "own row); measure on held-out scenarios with `wmo optimize route report`"
183
+ )
184
+ return
185
+ _console.print(
186
+ f"[green]✓[/green] fitted {len(policy.clusters)} clusters over "
187
+ f"{result.scenarios} scenarios -> {out}\n"
188
+ f" fit-set accuracy {result.accuracy:.4f}, cost/scenario ${result.cost_per_scenario:.5f}"
189
+ )
190
+
191
+
192
+ @route_app.command("tune")
193
+ def tune(
194
+ policy_file: str = typer.Argument(POLICY_FILENAME, help="Fitted knn policy JSON to re-tune."),
195
+ cost_quality: float = typer.Option(
196
+ ...,
197
+ "--cost-quality",
198
+ min=0.0,
199
+ max=1.0,
200
+ help="The endpoint's one dial: 0.0 = max quality, 1.0 = max savings. 0.25 is the "
201
+ "shipped default. See the anchor table this command prints for what each end measured.",
202
+ ),
203
+ ) -> None:
204
+ """Set a fitted policy's cost/quality dial in place, without refitting anything.
205
+
206
+ The dial maps to the policy's knobs along the measured frontier (see
207
+ `wmo.optimize.knn.apply_cost_quality`). The first run copies the un-tuned artifact to
208
+ `policy.base.json` and every later run re-reads THAT, so the dial is always applied to the
209
+ policy as fitted and sliding twice never compounds:
210
+
211
+ wmo optimize route tune models/support/policy.json --cost-quality 0.6
212
+
213
+ The evidence bank is untouched, so this is instant. A served endpoint can be dialed without
214
+ touching files at all: `PUT /v1/endpoints/{name}/config`.
215
+ """
216
+ path = Path(policy_file)
217
+ if not path.is_file():
218
+ raise typer.BadParameter(f"no policy file at {path}")
219
+ base_path = path.with_name(f"{path.stem}.base{path.suffix}")
220
+ if not base_path.is_file():
221
+ # Preserve the artifact as fitted the first time, so `tune` is always re-appliable from
222
+ # the fit and never from an already-slid copy of itself.
223
+ base_path.write_bytes(path.read_bytes())
224
+ base = RoutingPolicy.load(base_path)
225
+ try:
226
+ tuned = apply_cost_quality(base, cost_quality)
227
+ except ValueError as exc:
228
+ raise typer.BadParameter(str(exc)) from exc
229
+ tuned.save(path)
230
+ knobs = cost_quality_knobs(cost_quality)
231
+ _console.print(
232
+ f"[green]✓[/green] cost_quality={cost_quality:g} "
233
+ f"({cost_quality_named_point(cost_quality)}) -> {path}\n"
234
+ f" knobs: floor_q={knobs.floor_q:g}, cost knob lam={knobs.pick_lam:g}, "
235
+ f"guard={knobs.guard_mode}, z={knobs.knn_z:g}\n"
236
+ f" as fitted: {base_path}\n"
237
+ f" measured on routerbench-ours9 (5 held-out splits, vs the best single model):"
238
+ )
239
+ for anchor in COST_QUALITY_ANCHORS:
240
+ marker = "->" if anchor.cost_quality == cost_quality else " "
241
+ _console.print(
242
+ f" {marker} {anchor.cost_quality:<5g} {anchor.quality_delta_points:+.2f}pt "
243
+ f"@ {anchor.cost_delta_percent:+.1f}% cost"
244
+ + (f" [dim]{anchor.named_point}[/dim]" if anchor.named_point != "Custom" else "")
245
+ )
246
+
247
+
248
+ @route_app.command("report")
249
+ def report(
250
+ matrix_file: str = typer.Argument(..., help="OutcomeMatrix JSON with held-out scenarios."),
251
+ policy_file: str = typer.Argument(..., help="Fitted policy JSON."),
252
+ baseline: str = typer.Option(
253
+ ..., "--baseline", help="Frontier pool model the report compares against."
254
+ ),
255
+ endpoint: str = typer.Option("endpoint", "--endpoint", help="Endpoint id for the report."),
256
+ out: str = typer.Option("report.json", "--out", help="Where to write the report JSON."),
257
+ ) -> None:
258
+ """Build the improvement report for a fitted policy over a matrix."""
259
+ matrix = OutcomeMatrix.load(Path(matrix_file))
260
+ policy = RoutingPolicy.load(Path(policy_file))
261
+ improvement = build_report(
262
+ matrix,
263
+ policy,
264
+ baseline=baseline,
265
+ endpoint=endpoint,
266
+ generated_at=datetime.now(tz=UTC).isoformat(),
267
+ )
268
+ Path(out).write_text(improvement.model_dump_json(indent=2), encoding="utf-8")
269
+ headline = improvement.headline
270
+ _console.print(
271
+ f"[green]✓[/green] report -> {out}\n"
272
+ f" routed acc {headline.accuracy:.4f} @ ${headline.cost_per_run_usd:.5f}/run vs "
273
+ f"{baseline} {headline.baseline_accuracy:.4f} @ ${headline.baseline_cost_per_run_usd:.5f}"
274
+ )
@@ -0,0 +1,243 @@
1
+ # Copyright (c) 2026 Experiential Labs. All rights reserved.
2
+
3
+ """Persisted local references to detached hosted agent sessions.
4
+
5
+ ``wmo run <agent-id> --detach`` leaves a platform-owned E2B session running
6
+ with no local process attached. The CLI remembers how to address it again
7
+ (platform URL, agent id, session id) plus the workspace-sync checkpoint in the
8
+ user-global WMO state directory (``$WMO_HOME`` or ``~/.wmo``), never inside
9
+ the directory being synchronized. Writes are atomic and owner-only; the state
10
+ directory is injectable so tests never touch real user state.
11
+
12
+ The workspace base archive is stored content-addressed next to the JSON state
13
+ and referenced by digest: the archive lands first and the state referencing it
14
+ lands last, so a crash between the two writes leaves the previous checkpoint
15
+ intact instead of a dangling pointer.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import hashlib
22
+ import os
23
+ import re
24
+ import tempfile
25
+ from typing import TYPE_CHECKING
26
+
27
+ from pydantic import BaseModel, ValidationError
28
+
29
+ from wmo.platform.credentials import wmo_home
30
+
31
+ if TYPE_CHECKING:
32
+ from pathlib import Path
33
+
34
+ SESSIONS_DIRNAME = "sessions"
35
+ _CURRENT_FILENAME = "current"
36
+ # Session ids come from the platform (UUIDs); refuse anything that could
37
+ # escape the state directory when used as a file-name component.
38
+ _SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
39
+
40
+
41
+ class SessionStateError(RuntimeError):
42
+ """Detached-session state on disk is missing, unsafe, or corrupted."""
43
+
44
+
45
+ class WorkspaceCheckpoint(BaseModel):
46
+ """Synchronization checkpoint between one local directory and the session."""
47
+
48
+ root: str
49
+ base_sha256: str = ""
50
+ conflicts: tuple[str, ...] = ()
51
+ # A patch applied locally whose acknowledgement did not reach the platform
52
+ # yet; the next command retries it so the server object never leaks.
53
+ pending_ack: str | None = None
54
+
55
+
56
+ class DetachedSessionState(BaseModel):
57
+ """One hosted agent session the CLI can send to, attach to, or end later."""
58
+
59
+ api_url: str
60
+ web_url: str | None = None
61
+ agent_id: str
62
+ agent_name: str
63
+ session_id: str
64
+ created_at: str
65
+ cursor: int = 0
66
+ workspace: WorkspaceCheckpoint | None = None
67
+
68
+
69
+ class SessionStateStore:
70
+ """Atomic, owner-only persistence for detached session references."""
71
+
72
+ def __init__(self, directory: Path | None = None) -> None:
73
+ """Store state under ``directory`` (default: the user-global WMO home)."""
74
+ self._directory = directory if directory is not None else wmo_home() / SESSIONS_DIRNAME
75
+
76
+ # -- session records ---------------------------------------------------------------------
77
+
78
+ def save(
79
+ self, state: DetachedSessionState, *, base_archive: bytes | None = None
80
+ ) -> DetachedSessionState:
81
+ """Persist one session's state, optionally with a new workspace base archive.
82
+
83
+ Returns:
84
+ The state as persisted (with ``base_sha256`` updated when an
85
+ archive was written).
86
+
87
+ Raises:
88
+ SessionStateError: If an archive is given without workspace
89
+ metadata, the id is unsafe, or a state path is a symlink.
90
+ """
91
+ self._ensure_directory()
92
+ session_id = self._validated(state.session_id)
93
+ if base_archive is not None:
94
+ if state.workspace is None:
95
+ msg = "a workspace base archive requires workspace checkpoint metadata"
96
+ raise SessionStateError(msg)
97
+ digest = hashlib.sha256(base_archive, usedforsecurity=False).hexdigest()
98
+ self._write_bytes(self._archive_path(session_id, digest), base_archive)
99
+ state = state.model_copy(
100
+ update={"workspace": state.workspace.model_copy(update={"base_sha256": digest})}
101
+ )
102
+ payload = state.model_dump_json(indent=2)
103
+ self._write_bytes(self._state_path(session_id), payload.encode("utf-8"))
104
+ self._prune_archives(state)
105
+ return state
106
+
107
+ def load(self, session_id: str) -> DetachedSessionState | None:
108
+ """Read one session's state; ``None`` when nothing is stored for the id."""
109
+ path = self._state_path(self._validated(session_id))
110
+ if not path.exists():
111
+ return None
112
+ try:
113
+ return DetachedSessionState.model_validate_json(path.read_text(encoding="utf-8"))
114
+ except (ValidationError, ValueError, OSError) as error:
115
+ msg = (
116
+ f"session state at {path} is unreadable; delete it and start a new "
117
+ "session with `wmo run <agent-id> --detach`"
118
+ )
119
+ raise SessionStateError(msg) from error
120
+
121
+ def load_base_archive(self, state: DetachedSessionState) -> bytes:
122
+ """Read and integrity-check the persisted workspace base archive."""
123
+ workspace = state.workspace
124
+ if workspace is None or not workspace.base_sha256:
125
+ msg = f"session {state.session_id} has no workspace checkpoint"
126
+ raise SessionStateError(msg)
127
+ path = self._archive_path(self._validated(state.session_id), workspace.base_sha256)
128
+ try:
129
+ content = path.read_bytes()
130
+ except OSError as error:
131
+ msg = (
132
+ f"workspace checkpoint archive is missing at {path}; end the session "
133
+ f"with `wmo run --session {state.session_id} --end` and recover from "
134
+ "the final workspace download"
135
+ )
136
+ raise SessionStateError(msg) from error
137
+ digest = hashlib.sha256(content, usedforsecurity=False).hexdigest()
138
+ if digest != workspace.base_sha256:
139
+ msg = (
140
+ f"workspace checkpoint archive at {path} failed its integrity check; "
141
+ f"delete it and end the session with `wmo run --session "
142
+ f"{state.session_id} --end`"
143
+ )
144
+ raise SessionStateError(msg)
145
+ return content
146
+
147
+ def write_recovery_archive(self, session_id: str, content: bytes) -> Path:
148
+ """Save a final workspace archive that could not be synchronized locally.
149
+
150
+ The file deliberately survives :meth:`delete`: it is the user's data,
151
+ not session state.
152
+ """
153
+ self._ensure_directory()
154
+ path = self._directory / f"{self._validated(session_id)}.recovered.tar.gz"
155
+ self._write_bytes(path, content)
156
+ return path
157
+
158
+ def delete(self, session_id: str) -> None:
159
+ """Remove the state, its checkpoint archives, and a matching current pointer."""
160
+ session_id = self._validated(session_id)
161
+ self._state_path(session_id).unlink(missing_ok=True)
162
+ for archive in self._directory.glob(f"{session_id}.workspace-*.tar.gz"):
163
+ archive.unlink(missing_ok=True)
164
+ if self.current_session_id() == session_id:
165
+ (self._directory / _CURRENT_FILENAME).unlink(missing_ok=True)
166
+
167
+ # -- current pointer ---------------------------------------------------------------------
168
+
169
+ def set_current(self, session_id: str) -> None:
170
+ """Publish ``session_id`` as the session bare send/attach/end commands use."""
171
+ self._ensure_directory()
172
+ content = f"{self._validated(session_id)}\n".encode()
173
+ self._write_bytes(self._directory / _CURRENT_FILENAME, content)
174
+
175
+ def current_session_id(self) -> str | None:
176
+ """The current session id, or ``None`` when no pointer is set."""
177
+ path = self._directory / _CURRENT_FILENAME
178
+ if not path.exists():
179
+ return None
180
+ value = path.read_text(encoding="utf-8").strip()
181
+ return value or None
182
+
183
+ # -- internals ---------------------------------------------------------------------------
184
+
185
+ def _ensure_directory(self) -> None:
186
+ """Create every missing directory level owner-only from the start.
187
+
188
+ ``mkdir(parents=True)`` would create intermediate levels with the
189
+ umask default, leaving a window (and, for ``~/.wmo``, a permanent
190
+ 0755) around checkpoint archives that contain the user's source
191
+ tree. A umask can only narrow 0o700, so creating each level with
192
+ that mode closes the race; pre-existing directories are left alone.
193
+ """
194
+ missing: list[Path] = []
195
+ current = self._directory
196
+ while not current.exists():
197
+ missing.append(current)
198
+ parent = current.parent
199
+ if parent == current:
200
+ break
201
+ current = parent
202
+ for directory in reversed(missing):
203
+ with contextlib.suppress(FileExistsError):
204
+ directory.mkdir(mode=0o700)
205
+
206
+ def _state_path(self, session_id: str) -> Path:
207
+ return self._directory / f"{session_id}.json"
208
+
209
+ def _archive_path(self, session_id: str, digest: str) -> Path:
210
+ return self._directory / f"{session_id}.workspace-{digest[:16]}.tar.gz"
211
+
212
+ def _prune_archives(self, state: DetachedSessionState) -> None:
213
+ """Drop archives the just-written state no longer references."""
214
+ keep: str | None = None
215
+ if state.workspace is not None and state.workspace.base_sha256:
216
+ keep = self._archive_path(state.session_id, state.workspace.base_sha256).name
217
+ for candidate in self._directory.glob(f"{state.session_id}.workspace-*.tar.gz"):
218
+ if candidate.name != keep:
219
+ candidate.unlink(missing_ok=True)
220
+
221
+ def _validated(self, session_id: str) -> str:
222
+ """Reject ids that are unsafe as file-name components."""
223
+ if _SESSION_ID.fullmatch(session_id) is None:
224
+ msg = f"invalid session id: {session_id!r}"
225
+ raise SessionStateError(msg)
226
+ return session_id
227
+
228
+ def _write_bytes(self, path: Path, content: bytes) -> None:
229
+ """Write through a 0600 temporary file and swap into place atomically."""
230
+ if path.is_symlink():
231
+ msg = f"refusing to write session state through the symlink {path}; remove the link"
232
+ raise SessionStateError(msg)
233
+ fd, tmp_name = tempfile.mkstemp(dir=self._directory, prefix=f"{path.name}.")
234
+ try:
235
+ with os.fdopen(fd, "wb") as handle:
236
+ handle.write(content)
237
+ os.replace(tmp_name, path)
238
+ except BaseException:
239
+ # The replace may already have consumed the temp file; a missing
240
+ # file must not mask the original exception (e.g. an interrupt).
241
+ with contextlib.suppress(OSError):
242
+ os.unlink(tmp_name)
243
+ raise