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
@@ -0,0 +1,206 @@
1
+ """Serve-side trace access: read a model's local traces, or fetch them from the Hugging Face Hub.
2
+
3
+ The raw trace corpus (`traces.otel.jsonl`) is large and need not be committed. When it is present
4
+ locally it is used directly; otherwise, if the model's card declares a `traces_hf` source, the
5
+ backend streams it from the Hub's public resolve URL (no auth, no client-side Hub API) into the
6
+ model directory, reporting byte progress the website can poll. A local copy always supersedes the
7
+ Hub. Recorded traces are grouped by task into replayable scenarios for the Explore-traces tab.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from collections.abc import Callable
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+ from pydantic import BaseModel, Field
19
+
20
+ from wmo.config.card import TracesSource
21
+ from wmo.core.types import Action
22
+ from wmo.ingest import get_adapter
23
+
24
+ TRACES_FILENAME = "traces.otel.jsonl"
25
+
26
+
27
+ def resolve_url(source: TracesSource) -> str:
28
+ """The public Hub resolve URL for a traces source (works unauthenticated for public repos)."""
29
+ prefix = "datasets/" if source.kind == "dataset" else ""
30
+ return f"https://huggingface.co/{prefix}{source.repo}/resolve/{source.revision}/{source.path}"
31
+
32
+
33
+ def local_traces_path(model_dir: Path) -> Path | None:
34
+ """The traces file for a model, if present: a downloaded copy, else the example sibling."""
35
+ downloaded = model_dir / TRACES_FILENAME
36
+ if downloaded.is_file():
37
+ return downloaded
38
+ # examples/<task>/traces.otel.jsonl sits two levels above examples/<task>/models/<name>/.
39
+ sibling = model_dir.parent.parent / TRACES_FILENAME
40
+ if sibling.is_file():
41
+ return sibling
42
+ return None
43
+
44
+
45
+ def _action_label(action: Action) -> str:
46
+ """Format an action in the wmo-play grammar (matches the web index generator)."""
47
+ if action.kind.value == "tool_call":
48
+ return f"{action.name} {action.arguments}" if action.arguments else (action.name or "")
49
+ return f"say {action.content or ''}"
50
+
51
+
52
+ class ScenarioStep(BaseModel):
53
+ action: Action
54
+ action_label: str
55
+ observation: str
56
+ is_error: bool
57
+
58
+
59
+ class TraceScenario(BaseModel):
60
+ id: str
61
+ label: str
62
+ task: str | None
63
+ steps: list[ScenarioStep]
64
+
65
+
66
+ def _clip(text: str, limit: int) -> str:
67
+ flat = " ".join(text.split())
68
+ return flat if len(flat) <= limit else flat[: limit - 1] + "…"
69
+
70
+
71
+ def scenarios_from_traces(
72
+ path: Path, *, max_scenarios: int = 6, max_steps: int = 10
73
+ ) -> list[TraceScenario]:
74
+ """Normalize a traces.otel.jsonl into a bounded list of replayable scenarios (one per trace)."""
75
+ traces = get_adapter("otel-genai").from_file(str(path))
76
+ out: list[TraceScenario] = []
77
+ for i, trace in enumerate(traces):
78
+ steps: list[ScenarioStep] = []
79
+ for step in trace.steps:
80
+ if step.observation.content is None:
81
+ continue
82
+ steps.append(
83
+ ScenarioStep(
84
+ action=step.action,
85
+ action_label=_clip(_action_label(step.action), 100),
86
+ observation=step.observation.content,
87
+ is_error=step.observation.is_error,
88
+ )
89
+ )
90
+ if len(steps) >= max_steps:
91
+ break
92
+ if not steps:
93
+ continue
94
+ task = trace.steps[0].task if trace.steps else None
95
+ out.append(
96
+ TraceScenario(id=f"t{i}", label=_scenario_label(task, i), task=task, steps=steps)
97
+ )
98
+ if len(out) >= max_scenarios:
99
+ break
100
+ return out
101
+
102
+
103
+ def _scenario_label(task: str | None, index: int) -> str:
104
+ if not task:
105
+ return f"Scenario {index + 1}"
106
+ import json
107
+
108
+ try:
109
+ parsed = json.loads(task)
110
+ text = parsed.get("reason_for_call") or parsed.get("task_instructions") or task
111
+ except (json.JSONDecodeError, AttributeError):
112
+ text = task
113
+ return _clip(text, 68)
114
+
115
+
116
+ class DownloadStatus(StrEnum):
117
+ RUNNING = "running"
118
+ DONE = "done"
119
+ FAILED = "failed"
120
+
121
+
122
+ class DownloadProgress(BaseModel):
123
+ status: DownloadStatus
124
+ downloaded: int = 0
125
+ total: int | None = None # bytes, when the server reports Content-Length
126
+ error: str | None = None
127
+
128
+
129
+ class _DownloadState:
130
+ def __init__(self) -> None:
131
+ self.progress = DownloadProgress(status=DownloadStatus.RUNNING)
132
+ self.lock = threading.Lock()
133
+
134
+
135
+ class TracesDownloader:
136
+ """Runs Hub trace downloads on background threads, one in flight per model name."""
137
+
138
+ def __init__(
139
+ self, *, fetch: Callable[[str, Path, Callable[[int, int | None], None]], None] | None = None
140
+ ) -> None:
141
+ self._fetch = fetch or _stream_to_file
142
+ self._states: dict[str, _DownloadState] = {}
143
+ self._lock = threading.Lock()
144
+
145
+ def start(self, name: str, url: str, dest: Path) -> None:
146
+ with self._lock:
147
+ existing = self._states.get(name)
148
+ if existing and existing.progress.status is DownloadStatus.RUNNING:
149
+ return # already downloading; the client just polls
150
+ self._states[name] = _DownloadState()
151
+ thread = threading.Thread(
152
+ target=self._run, args=(name, url, dest), name=f"wmo-traces-{name}", daemon=True
153
+ )
154
+ thread.start()
155
+
156
+ def _run(self, name: str, url: str, dest: Path) -> None:
157
+ state = self._states[name]
158
+
159
+ def on_progress(downloaded: int, total: int | None) -> None:
160
+ with state.lock:
161
+ state.progress.downloaded = downloaded
162
+ state.progress.total = total
163
+
164
+ dest.parent.mkdir(parents=True, exist_ok=True)
165
+ tmp = dest.with_suffix(dest.suffix + ".part")
166
+ try:
167
+ self._fetch(url, tmp, on_progress)
168
+ tmp.replace(dest) # atomic: a partial download never looks complete
169
+ except Exception as exc: # noqa: BLE001 - report any failure to the client
170
+ tmp.unlink(missing_ok=True)
171
+ with state.lock:
172
+ state.progress.status = DownloadStatus.FAILED
173
+ state.progress.error = str(exc)
174
+ return
175
+ with state.lock:
176
+ state.progress.status = DownloadStatus.DONE
177
+
178
+ def progress(self, name: str) -> DownloadProgress | None:
179
+ state = self._states.get(name)
180
+ if state is None:
181
+ return None
182
+ with state.lock:
183
+ return state.progress.model_copy()
184
+
185
+
186
+ def _stream_to_file(url: str, dest: Path, on_progress: Callable[[int, int | None], None]) -> None:
187
+ """Stream a public Hub file to disk in chunks, following the CDN redirect."""
188
+ with httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as resp:
189
+ resp.raise_for_status()
190
+ raw = resp.headers.get("content-length")
191
+ total = int(raw) if raw and raw.isdigit() else None
192
+ downloaded = 0
193
+ with dest.open("wb") as fh:
194
+ for chunk in resp.iter_bytes(1024 * 1024):
195
+ fh.write(chunk)
196
+ downloaded += len(chunk)
197
+ on_progress(downloaded, total)
198
+
199
+
200
+ class TracesResponse(BaseModel):
201
+ """What the Explore-traces tab needs: local scenarios if present, else a Hub download offer."""
202
+
203
+ source: str # "local" | "hub" | "none"
204
+ downloadable: bool
205
+ scenarios: list[TraceScenario] = Field(default_factory=list)
206
+ download: DownloadProgress | None = None
wmo/telemetry.py ADDED
@@ -0,0 +1,213 @@
1
+ """Best-effort anonymous usage telemetry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from atexit import register
8
+ from dataclasses import dataclass
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from pathlib import Path
11
+
12
+ from posthog import Posthog
13
+
14
+ from wmo.config import ARTIFACT_DIR
15
+ from wmo.config.settings import ensure_telemetry_anonymous_id, load_settings
16
+ from wmo.engine.reporting import BuildReporter
17
+ from wmo.tracking import RunRecord
18
+
19
+ POSTHOG_PROJECT_API_KEY = "phc_rPFfCufWpxyctR7duEZTTXovP4k5kbHqSqzd4Z4MQJdL"
20
+ POSTHOG_HOST = "https://us.i.posthog.com"
21
+
22
+ TelemetryValue = str | int | float | bool | None
23
+ TelemetryProperties = dict[str, TelemetryValue]
24
+
25
+ _FALSE_VALUES = {"0", "false", "off", "no"}
26
+ _TRUE_VALUES = {"1", "true", "on", "yes"}
27
+ _CLIENTS: dict[tuple[str, str], Posthog] = {}
28
+
29
+
30
+ @dataclass
31
+ class BuildTelemetryStats:
32
+ input_trace_count: int = 0
33
+ input_step_count: int = 0
34
+ train_trace_count: int = 0
35
+ val_trace_count: int = 0
36
+ heldout_trace_count: int = 0
37
+ indexed_step_count: int = 0
38
+
39
+
40
+ class TelemetryBuildReporter:
41
+ def __init__(self, inner: BuildReporter, stats: BuildTelemetryStats) -> None:
42
+ self._inner = inner
43
+ self._stats = stats
44
+
45
+ def ingest_done(self, traces: int, steps: int) -> None:
46
+ self._stats.input_trace_count = traces
47
+ self._stats.input_step_count = steps
48
+ self._inner.ingest_done(traces, steps)
49
+
50
+ def split_done(self, train: int, val: int, test: int) -> None:
51
+ self._stats.train_trace_count = train
52
+ self._stats.val_trace_count = val
53
+ self._stats.heldout_trace_count = test
54
+ self._inner.split_done(train, val, test)
55
+
56
+ def index_done(self, steps: int) -> None:
57
+ self._stats.indexed_step_count = steps
58
+ self._inner.index_done(steps)
59
+
60
+ def optimize_start(self, budget: int) -> None:
61
+ self._inner.optimize_start(budget)
62
+
63
+ def activity(self, line: str) -> None:
64
+ self._inner.activity(line)
65
+
66
+ def rollout(self, done: int, budget: int, score: float | None) -> None:
67
+ self._inner.rollout(done, budget, score)
68
+
69
+ def optimize_done(self, held_out_accuracy: float, frontier_size: int, rollouts: int) -> None:
70
+ self._inner.optimize_done(held_out_accuracy, frontier_size, rollouts)
71
+
72
+
73
+ def capture(
74
+ event: str,
75
+ properties: TelemetryProperties | None = None,
76
+ *,
77
+ root: str | Path = ARTIFACT_DIR,
78
+ ) -> bool:
79
+ """Send one anonymous metadata-only event. Returns False when skipped or failed."""
80
+ if not _enabled(root):
81
+ return False
82
+ api_key = os.getenv("WMO_POSTHOG_PROJECT_API_KEY", POSTHOG_PROJECT_API_KEY).strip()
83
+ if not api_key:
84
+ return False
85
+ host = os.getenv("WMO_POSTHOG_HOST", POSTHOG_HOST).rstrip("/")
86
+ try:
87
+ distinct_id = ensure_telemetry_anonymous_id(root)
88
+ event_properties: TelemetryProperties = {
89
+ "$process_person_profile": False,
90
+ "wmo_version": _wmo_version(),
91
+ "python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
92
+ **(properties or {}),
93
+ }
94
+ # Never log prompts, traces, actions, observations, paths, models, credentials, or text.
95
+ message_id = _posthog_client(api_key, host).capture(
96
+ event,
97
+ distinct_id=distinct_id,
98
+ properties=event_properties,
99
+ )
100
+ return message_id is not None
101
+ except (OSError, ValueError):
102
+ return False
103
+
104
+
105
+ def capture_build_completed(
106
+ *,
107
+ stats: BuildTelemetryStats,
108
+ gepa_budget: int,
109
+ rollouts_used: int,
110
+ frontier_size: int,
111
+ record: RunRecord,
112
+ root: str | Path,
113
+ ) -> None:
114
+ capture(
115
+ "wmo build completed",
116
+ {
117
+ "success": True,
118
+ "input_trace_count": stats.input_trace_count,
119
+ "input_step_count": stats.input_step_count,
120
+ "train_trace_count": stats.train_trace_count,
121
+ "val_trace_count": stats.val_trace_count,
122
+ "heldout_trace_count": stats.heldout_trace_count,
123
+ "indexed_step_count": stats.indexed_step_count,
124
+ "gepa_budget": gepa_budget,
125
+ "rollouts_used": rollouts_used,
126
+ "frontier_size": frontier_size,
127
+ "duration_seconds": round(record.duration_seconds, 3),
128
+ "llm_call_count": record.total.calls,
129
+ "input_tokens": record.total.input_tokens,
130
+ "output_tokens": record.total.output_tokens,
131
+ "cost_usd": round(record.total.cost_usd, 6),
132
+ },
133
+ root=root,
134
+ )
135
+
136
+
137
+ def capture_eval_completed(
138
+ *,
139
+ mode: str,
140
+ file_count: int,
141
+ scored_step_count: int,
142
+ rag_enabled: bool,
143
+ sample_turns: str,
144
+ train_split: float,
145
+ top_k: int,
146
+ root: str | Path,
147
+ ) -> None:
148
+ capture(
149
+ "wmo eval completed",
150
+ {
151
+ "success": True,
152
+ "eval_mode": mode,
153
+ "file_count": file_count,
154
+ "scored_step_count": scored_step_count,
155
+ "rag_enabled": rag_enabled,
156
+ "sample_turns": sample_turns,
157
+ "train_split": train_split,
158
+ "top_k": top_k,
159
+ },
160
+ root=root,
161
+ )
162
+
163
+
164
+ def settings_root_from_results_root(results_root: str) -> Path:
165
+ path = Path(results_root)
166
+ return path.parent if path.name == "evals" else Path(ARTIFACT_DIR)
167
+
168
+
169
+ def _enabled(root: str | Path) -> bool:
170
+ if _env_truthy("DO_NOT_TRACK"):
171
+ return False
172
+ env = os.getenv("WMO_TELEMETRY")
173
+ if env is not None:
174
+ return env.strip().lower() in _TRUE_VALUES
175
+ if os.getenv("PYTEST_CURRENT_TEST"):
176
+ return False
177
+ try:
178
+ return load_settings(root).telemetry.enabled
179
+ except (OSError, ValueError):
180
+ return False
181
+
182
+
183
+ def _env_truthy(name: str) -> bool:
184
+ value = os.getenv(name)
185
+ if value is None:
186
+ return False
187
+ normalized = value.strip().lower()
188
+ if normalized in _FALSE_VALUES:
189
+ return False
190
+ return bool(normalized)
191
+
192
+
193
+ def _wmo_version() -> str:
194
+ try:
195
+ return version("world-model-optimizer")
196
+ except PackageNotFoundError:
197
+ return "unknown"
198
+
199
+
200
+ def _posthog_client(api_key: str, host: str) -> Posthog:
201
+ key = (api_key, host)
202
+ client = _CLIENTS.get(key)
203
+ if client is None:
204
+ client = Posthog(
205
+ api_key,
206
+ host=host,
207
+ flush_interval=1.0,
208
+ max_retries=1,
209
+ timeout=0.5,
210
+ )
211
+ _CLIENTS[key] = client
212
+ register(client.shutdown)
213
+ return client
@@ -0,0 +1,36 @@
1
+ """Run tracking: time + cost + tokens across the harness lifecycle.
2
+
3
+ Instrument at the provider boundary (`MeteredProvider`) so the world model, GEPA, and the judge are
4
+ all metered without changes to those modules. `RunTracker` aggregates `UsageEvent`s into
5
+ `UsageTotals` (priced via `wmo.tracking.pricing`) plus a wall-clock duration from an injectable
6
+ `Clock`; `RunRecord`s persist under `.wmo/runs/`.
7
+ """
8
+
9
+ from wmo.tracking.clock import Clock, SystemClock
10
+ from wmo.tracking.metered import MeteredProvider, classify_build_call
11
+ from wmo.tracking.pricing import ModelPrice, cost_usd, price_for
12
+ from wmo.tracking.store import load_runs, save_run
13
+ from wmo.tracking.tracker import (
14
+ Phase,
15
+ RunRecord,
16
+ RunTracker,
17
+ UsageEvent,
18
+ UsageTotals,
19
+ )
20
+
21
+ __all__ = [
22
+ "Clock",
23
+ "SystemClock",
24
+ "MeteredProvider",
25
+ "classify_build_call",
26
+ "ModelPrice",
27
+ "cost_usd",
28
+ "price_for",
29
+ "load_runs",
30
+ "save_run",
31
+ "Phase",
32
+ "RunRecord",
33
+ "RunTracker",
34
+ "UsageEvent",
35
+ "UsageTotals",
36
+ ]
wmo/tracking/clock.py ADDED
@@ -0,0 +1,24 @@
1
+ """Injectable clock so run-tracking durations are deterministic in tests.
2
+
3
+ Production uses `SystemClock` (real `time.monotonic`). Tests pass a `FakeClock` with scripted ticks
4
+ so cost/time assertions don't depend on wall-clock timing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Protocol, runtime_checkable
11
+
12
+
13
+ @runtime_checkable
14
+ class Clock(Protocol):
15
+ def monotonic(self) -> float:
16
+ """Seconds from an arbitrary epoch; only differences are meaningful (for durations)."""
17
+ ...
18
+
19
+
20
+ class SystemClock:
21
+ """Default clock backed by `time.monotonic` (monotonic, unaffected by wall-clock changes)."""
22
+
23
+ def monotonic(self) -> float:
24
+ return time.monotonic()
@@ -0,0 +1,125 @@
1
+ """`MeteredProvider`: a Provider wrapper that records every call onto a RunTracker.
2
+
3
+ Instrumenting at the provider boundary means the optimizer, the judge, and the world model are all
4
+ metered without any of them knowing about tracking — we don't edit `gepa.py` or the judge. The
5
+ wrapper forwards `complete`/`embed`/`verify`/`config` to the wrapped provider unchanged and records
6
+ a `UsageEvent` per call.
7
+
8
+ Phase attribution: build and serve share one provider, and within build the *same* provider serves
9
+ GEPA rollouts, GEPA reflection, and the judge. We tell them apart by the system prompt each path
10
+ uses (a stable, boundary-visible signal), defaulting `complete` to whatever base phase the wrapper
11
+ was constructed with. Callers that want exact control can pass their own `classify`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable, Iterator
17
+
18
+ from wmo.optimize.judge import JUDGE_MARKER
19
+ from wmo.providers.base import (
20
+ DEFAULT_MAX_TOKENS,
21
+ Completion,
22
+ Message,
23
+ Provider,
24
+ ProviderConfig,
25
+ StreamChunk,
26
+ StreamingProvider,
27
+ TokenUsage,
28
+ VerifyResult,
29
+ )
30
+ from wmo.tracking.tracker import Phase, RunTracker
31
+
32
+
33
+ def classify_build_call(system: str) -> Phase:
34
+ """Default phase classifier for a build-time `complete`: judge vs GEPA (rollout/reflection).
35
+
36
+ The judge is recognized by `JUDGE_MARKER` (owned by judge.py, so the prompt and this classifier
37
+ can't drift). Everything else during build — GEPA rollouts (env-sim) and reflection — is GEPA.
38
+ """
39
+ if JUDGE_MARKER in system:
40
+ return Phase.JUDGE
41
+ return Phase.GEPA
42
+
43
+
44
+ class MeteredProvider:
45
+ """Wraps a `Provider`, recording token usage + cost per call onto a `RunTracker`.
46
+
47
+ `base_phase` is the phase for `complete` calls when no `classify` is given (e.g. `Phase.SERVE`
48
+ for the live world model). For build, pass `classify=classify_build_call` to split judge from
49
+ GEPA.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ provider: Provider,
55
+ tracker: RunTracker,
56
+ *,
57
+ base_phase: Phase = Phase.OTHER,
58
+ classify: Callable[[str], Phase] | None = None,
59
+ ) -> None:
60
+ self._provider = provider
61
+ self._tracker = tracker
62
+ self._base_phase = base_phase
63
+ self._classify = classify
64
+
65
+ @property
66
+ def config(self) -> ProviderConfig:
67
+ return self._provider.config
68
+
69
+ def complete(
70
+ self,
71
+ system: str,
72
+ messages: list[Message],
73
+ *,
74
+ temperature: float = 0.7,
75
+ max_tokens: int = DEFAULT_MAX_TOKENS,
76
+ ) -> Completion:
77
+ completion = self._provider.complete(
78
+ system, messages, temperature=temperature, max_tokens=max_tokens
79
+ )
80
+ phase = self._classify(system) if self._classify is not None else self._base_phase
81
+ # Prefer the model the completion says actually served (failover chains set it); the
82
+ # configured model otherwise (Completion.model validates non-empty, so `or` is exact).
83
+ # Pricing a failed-over call at the primary's rate would silently mis-report cost.
84
+ model = completion.model or self._provider.config.model
85
+ self._tracker.record(phase, model, completion.usage)
86
+ return completion
87
+
88
+ def stream(
89
+ self,
90
+ system: str,
91
+ messages: list[Message],
92
+ *,
93
+ temperature: float = 0.7,
94
+ max_tokens: int = DEFAULT_MAX_TOKENS,
95
+ ) -> Iterator[StreamChunk]:
96
+ """Forward a native stream, recording usage from the terminal chunk.
97
+
98
+ A stream abandoned before its terminal chunk records nothing even though the provider
99
+ billed the partial generation; serving owns closing streams it starts.
100
+ """
101
+ if not isinstance(self._provider, StreamingProvider):
102
+ raise TypeError(
103
+ f"{type(self._provider).__name__} does not implement StreamingProvider; "
104
+ "stream() is only available for native backends"
105
+ )
106
+ phase = self._classify(system) if self._classify is not None else self._base_phase
107
+ for chunk in self._provider.stream(
108
+ system, messages, temperature=temperature, max_tokens=max_tokens
109
+ ):
110
+ if chunk.done and chunk.usage is not None:
111
+ model = chunk.model or self._provider.config.model
112
+ self._tracker.record(phase, model, chunk.usage)
113
+ yield chunk
114
+
115
+ def embed(self, texts: list[str]) -> list[list[float]]:
116
+ # Embeddings carry no token usage from our providers; record a zero-usage event for the
117
+ # call count so EMBED shows up in the breakdown. Attribute it to the embeddings model
118
+ # (`embed_model`), not the completion model, so any future embed pricing is keyed right.
119
+ vectors = self._provider.embed(texts)
120
+ embed_model = self._provider.config.embed_model or self._provider.config.model
121
+ self._tracker.record(Phase.EMBED, embed_model, TokenUsage())
122
+ return vectors
123
+
124
+ def verify(self) -> VerifyResult:
125
+ return self._provider.verify()