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,270 @@
1
+ # Copyright (c) 2026 Experiential Labs. All rights reserved.
2
+
3
+ """Hash-guarded incremental patch protocol for synchronized workspaces."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import io
9
+ import json
10
+ import tarfile
11
+ from dataclasses import dataclass
12
+ from pathlib import PurePosixPath
13
+
14
+ MAX_WORKSPACE_PATCH_BYTES = 50 * 1024 * 1024
15
+ MAX_WORKSPACE_PATCH_UNPACKED_BYTES = 512 * 1024 * 1024
16
+ MAX_WORKSPACE_PATCH_ENTRIES = 100_000
17
+
18
+ _MANIFEST_PATH = "manifest.json"
19
+ _DATA_PREFIX = "data/"
20
+ _VERSION = 1
21
+
22
+
23
+ class WorkspacePatchError(ValueError):
24
+ """An incremental workspace patch is malformed or unsafe."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PatchFileState:
29
+ """The content and executable mode expected before or after one operation."""
30
+
31
+ sha256: str
32
+ mode: int
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class WorkspacePatchOperation:
37
+ """One conditional path transition in an incremental patch."""
38
+
39
+ path: str
40
+ before: PatchFileState | None
41
+ after: PatchFileState | None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class WorkspacePatch:
46
+ """Validated operations and replacement bytes keyed by workspace path."""
47
+
48
+ operations: tuple[WorkspacePatchOperation, ...]
49
+ files: dict[str, bytes]
50
+
51
+
52
+ def build_workspace_patch(before_archive: bytes, after_archive: bytes) -> bytes | None:
53
+ """Build a deterministic patch between two full workspace archives."""
54
+ before = _read_workspace_archive(before_archive)
55
+ after = _read_workspace_archive(after_archive)
56
+ operations: list[WorkspacePatchOperation] = []
57
+ for path in sorted(set(before) | set(after)):
58
+ before_state = _state(before.get(path))
59
+ after_state = _state(after.get(path))
60
+ if before_state != after_state:
61
+ operations.append(
62
+ WorkspacePatchOperation(path=path, before=before_state, after=after_state)
63
+ )
64
+ if not operations:
65
+ return None
66
+
67
+ manifest = {
68
+ "version": _VERSION,
69
+ "operations": [
70
+ {
71
+ "path": operation.path,
72
+ "before": _state_json(operation.before),
73
+ "after": _state_json(operation.after),
74
+ }
75
+ for operation in operations
76
+ ],
77
+ }
78
+ manifest_bytes = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
79
+ buffer = io.BytesIO()
80
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
81
+ _add_bytes(archive, _MANIFEST_PATH, manifest_bytes, 0o600)
82
+ for operation in operations:
83
+ if operation.after is None:
84
+ continue
85
+ body, _mode = after[operation.path]
86
+ _add_bytes(archive, f"{_DATA_PREFIX}{operation.path}", body, operation.after.mode)
87
+ content = buffer.getvalue()
88
+ if len(content) > MAX_WORKSPACE_PATCH_BYTES:
89
+ raise WorkspacePatchError(f"workspace patch exceeds {MAX_WORKSPACE_PATCH_BYTES} bytes")
90
+ return content
91
+
92
+
93
+ def parse_workspace_patch(content: bytes) -> WorkspacePatch:
94
+ """Validate and decode one patch without writing any filesystem paths."""
95
+ if len(content) > MAX_WORKSPACE_PATCH_BYTES:
96
+ raise WorkspacePatchError(f"workspace patch exceeds {MAX_WORKSPACE_PATCH_BYTES} bytes")
97
+ members: dict[str, tuple[bytes, int]] = {}
98
+ total = 0
99
+ try:
100
+ with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as archive:
101
+ entries = archive.getmembers()
102
+ if len(entries) > MAX_WORKSPACE_PATCH_ENTRIES + 1:
103
+ raise WorkspacePatchError("workspace patch has too many entries")
104
+ for member in entries:
105
+ name = _normalized_name(member.name)
106
+ if name in members:
107
+ raise WorkspacePatchError(f"duplicate workspace patch path: {name}")
108
+ if not member.isfile():
109
+ raise WorkspacePatchError(
110
+ f"workspace patch entry must be a regular file: {member.name}"
111
+ )
112
+ total += member.size
113
+ if total > MAX_WORKSPACE_PATCH_UNPACKED_BYTES:
114
+ raise WorkspacePatchError("workspace patch expands beyond its limit")
115
+ source = archive.extractfile(member)
116
+ if source is None:
117
+ raise WorkspacePatchError(
118
+ f"workspace patch entry has no content: {member.name}"
119
+ )
120
+ members[name] = (source.read(), member.mode & 0o777)
121
+ except WorkspacePatchError:
122
+ raise
123
+ except (tarfile.TarError, OSError, EOFError) as error:
124
+ raise WorkspacePatchError("workspace patch must be a gzip tar archive") from error
125
+
126
+ manifest_entry = members.pop(_MANIFEST_PATH, None)
127
+ if manifest_entry is None:
128
+ raise WorkspacePatchError("workspace patch has no manifest")
129
+ try:
130
+ raw = json.loads(manifest_entry[0])
131
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
132
+ raise WorkspacePatchError("workspace patch manifest is not valid JSON") from error
133
+ if not isinstance(raw, dict) or raw.get("version") != _VERSION:
134
+ raise WorkspacePatchError("unsupported workspace patch version")
135
+ raw_operations = raw.get("operations")
136
+ if not isinstance(raw_operations, list) or not raw_operations:
137
+ raise WorkspacePatchError("workspace patch has no operations")
138
+
139
+ operations: list[WorkspacePatchOperation] = []
140
+ files: dict[str, bytes] = {}
141
+ seen: set[str] = set()
142
+ for raw_operation in raw_operations:
143
+ operation = _parse_operation(raw_operation)
144
+ if operation.path in seen:
145
+ raise WorkspacePatchError(f"duplicate workspace patch operation: {operation.path}")
146
+ seen.add(operation.path)
147
+ data_name = f"{_DATA_PREFIX}{operation.path}"
148
+ entry = members.pop(data_name, None)
149
+ if operation.after is None:
150
+ if entry is not None:
151
+ raise WorkspacePatchError(
152
+ f"deleted workspace patch path has replacement data: {operation.path}"
153
+ )
154
+ else:
155
+ if entry is None:
156
+ raise WorkspacePatchError(
157
+ f"workspace patch has no replacement data: {operation.path}"
158
+ )
159
+ body, mode = entry
160
+ if _digest(body) != operation.after.sha256:
161
+ raise WorkspacePatchError(f"workspace patch digest mismatch: {operation.path}")
162
+ if mode != operation.after.mode:
163
+ raise WorkspacePatchError(f"workspace patch mode mismatch: {operation.path}")
164
+ files[operation.path] = body
165
+ operations.append(operation)
166
+ if members:
167
+ extra = next(iter(sorted(members)))
168
+ raise WorkspacePatchError(f"unexpected workspace patch entry: {extra}")
169
+ return WorkspacePatch(operations=tuple(operations), files=files)
170
+
171
+
172
+ def _read_workspace_archive(content: bytes) -> dict[str, tuple[bytes, int]]:
173
+ """Read regular files from a trusted full snapshot used to calculate a patch."""
174
+ files: dict[str, tuple[bytes, int]] = {}
175
+ try:
176
+ with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as archive:
177
+ entries = archive.getmembers()
178
+ if len(entries) > MAX_WORKSPACE_PATCH_ENTRIES:
179
+ raise WorkspacePatchError("workspace archive has too many entries")
180
+ total = 0
181
+ for member in entries:
182
+ name = _normalized_name(member.name)
183
+ if member.isdir():
184
+ continue
185
+ if not member.isfile():
186
+ raise WorkspacePatchError(
187
+ f"workspace entry must be a regular file or directory: {member.name}"
188
+ )
189
+ if name in files:
190
+ raise WorkspacePatchError(f"duplicate workspace path: {name}")
191
+ total += member.size
192
+ if total > MAX_WORKSPACE_PATCH_UNPACKED_BYTES:
193
+ raise WorkspacePatchError("workspace archive expands beyond its limit")
194
+ source = archive.extractfile(member)
195
+ if source is None:
196
+ raise WorkspacePatchError(f"workspace file has no content: {name}")
197
+ files[name] = (source.read(), member.mode & 0o777)
198
+ except WorkspacePatchError:
199
+ raise
200
+ except (tarfile.TarError, OSError, EOFError) as error:
201
+ raise WorkspacePatchError("workspace must be a gzip tar archive") from error
202
+ return files
203
+
204
+
205
+ def _parse_operation(value: object) -> WorkspacePatchOperation:
206
+ if not isinstance(value, dict):
207
+ raise WorkspacePatchError("workspace patch operation must be an object")
208
+ path_value = value.get("path")
209
+ if not isinstance(path_value, str):
210
+ raise WorkspacePatchError("workspace patch operation has no path")
211
+ path = _normalized_name(path_value)
212
+ if path == ".":
213
+ raise WorkspacePatchError("workspace patch operation cannot target the root")
214
+ before = _parse_state(value.get("before"))
215
+ after = _parse_state(value.get("after"))
216
+ if before == after:
217
+ raise WorkspacePatchError(f"workspace patch operation does not change: {path}")
218
+ return WorkspacePatchOperation(path=path, before=before, after=after)
219
+
220
+
221
+ def _parse_state(value: object) -> PatchFileState | None:
222
+ if value is None:
223
+ return None
224
+ if not isinstance(value, dict):
225
+ raise WorkspacePatchError("workspace patch file state must be an object")
226
+ sha256 = value.get("sha256")
227
+ mode = value.get("mode")
228
+ if (
229
+ not isinstance(sha256, str)
230
+ or len(sha256) != 64
231
+ or any(character not in "0123456789abcdef" for character in sha256)
232
+ or not isinstance(mode, int)
233
+ or isinstance(mode, bool)
234
+ or not 0 <= mode <= 0o777
235
+ ):
236
+ raise WorkspacePatchError("workspace patch file state is invalid")
237
+ return PatchFileState(sha256=sha256, mode=mode)
238
+
239
+
240
+ def _state(entry: tuple[bytes, int] | None) -> PatchFileState | None:
241
+ if entry is None:
242
+ return None
243
+ body, mode = entry
244
+ return PatchFileState(sha256=_digest(body), mode=mode)
245
+
246
+
247
+ def _state_json(state: PatchFileState | None) -> dict[str, object] | None:
248
+ if state is None:
249
+ return None
250
+ return {"sha256": state.sha256, "mode": state.mode}
251
+
252
+
253
+ def _add_bytes(archive: tarfile.TarFile, name: str, body: bytes, mode: int) -> None:
254
+ info = tarfile.TarInfo(name)
255
+ info.size = len(body)
256
+ info.mode = mode
257
+ info.mtime = 0
258
+ archive.addfile(info, io.BytesIO(body))
259
+
260
+
261
+ def _normalized_name(name: str) -> str:
262
+ path = PurePosixPath(name)
263
+ if path.is_absolute() or ".." in path.parts:
264
+ raise WorkspacePatchError(f"unsafe workspace patch path: {name}")
265
+ parts = tuple(part for part in path.parts if part not in {"", "."})
266
+ return PurePosixPath(*parts).as_posix() if parts else "."
267
+
268
+
269
+ def _digest(body: bytes) -> str:
270
+ return hashlib.sha256(body, usedforsecurity=False).hexdigest()
wmo/ingest/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """Trace ingestion: file uploads and vendor pulls -> normalized `Trace` objects.
2
+
3
+ A `TraceAdapter` turns one source's telemetry into the generic `Trace` schema. Adapters register
4
+ themselves on import and are looked up by name (`get_adapter`) or listed (`list_adapters`). The
5
+ span-based adapters share one normalizer (`wmo.ingest.normalize`) and the `BaseTraceAdapter`
6
+ scaffolding, so adding a new source is transport + attribute-mapping, not a rewrite. See
7
+ `docs/reference/ingest.md`.
8
+
9
+ Bundled adapters:
10
+ - `otel-genai` : OTLP-JSON spans following the OTel GenAI semantic conventions (file or pull).
11
+ - `chat-json` : recorded OpenAI-style chat/tool-call conversations (file).
12
+ Provider adapters (Braintrust, Phoenix/Arize, Langfuse, LangSmith) register when their module is
13
+ imported; their heavy SDKs are optional extras, imported lazily inside the adapter.
14
+ """
15
+
16
+ # Import for the registration side effect so `get_adapter(...)` works on package import. The
17
+ # provider adapters are SDK-free (they parse exports as JSON and pull over httpx), so importing them
18
+ # here is cheap and brings no heavy dependency — their optional extras only matter if a user drives
19
+ # the provider's own SDK alongside `wmo ingest`.
20
+ from wmo.ingest import braintrust as braintrust # noqa: F401
21
+ from wmo.ingest import langfuse as langfuse # noqa: F401
22
+ from wmo.ingest import langsmith as langsmith # noqa: F401
23
+ from wmo.ingest import mastra as mastra # noqa: F401
24
+ from wmo.ingest import messages as messages # noqa: F401
25
+ from wmo.ingest import otel_genai as otel_genai # noqa: F401
26
+ from wmo.ingest import phoenix as phoenix # noqa: F401
27
+ from wmo.ingest import postgres as postgres # noqa: F401
28
+ from wmo.ingest import posthog as posthog # noqa: F401
29
+ from wmo.ingest.adapter import (
30
+ TraceAdapter,
31
+ VendorPull,
32
+ get_adapter,
33
+ list_adapters,
34
+ register_adapter,
35
+ )
36
+ from wmo.ingest.base import BaseTraceAdapter
37
+ from wmo.ingest.quality import drop_degenerate_traces
38
+
39
+ __all__ = [
40
+ "BaseTraceAdapter",
41
+ "TraceAdapter",
42
+ "VendorPull",
43
+ "drop_degenerate_traces",
44
+ "get_adapter",
45
+ "list_adapters",
46
+ "register_adapter",
47
+ ]
wmo/ingest/adapter.py ADDED
@@ -0,0 +1,72 @@
1
+ """TraceAdapter protocol + a small registry.
2
+
3
+ Sources differ in two ways: *transport* (file vs. vendor SDK) and *schema* (which OTel semantic
4
+ convention the spans follow). An adapter owns both: it pulls/reads raw spans and normalizes them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from wmo.core.types import Trace
14
+
15
+
16
+ class SourceCredentialError(PermissionError):
17
+ """A source rejected the supplied credentials (API key, DSN password).
18
+
19
+ Adapters raise this instead of bare `PermissionError` so the streaming ingest can map it
20
+ to the `bad_credentials` wire code without misclassifying OS-level permission failures
21
+ (e.g. an unreadable local file) as credential problems. Subclassing `PermissionError`
22
+ keeps it a stdlib type: callers never need a driver import to catch it.
23
+ """
24
+
25
+
26
+ class VendorPull(BaseModel):
27
+ """Parameters for pulling traces from an observability vendor's API or a database."""
28
+
29
+ api_key: str | None = None # falls back to the vendor's env var when None
30
+ project: str | None = None # vendor project / workspace to pull from
31
+ since: str | None = None # ISO-8601 lower bound on trace start time
32
+ limit: int | None = None # max traces to pull
33
+
34
+ # Database-source transport params (the `postgres` adapter); API-vendor adapters ignore them.
35
+ dsn: str | None = None # connection string; falls back to $WMO_POSTGRES_DSN
36
+ table: str | None = None # table holding the trace rows
37
+ trace_id_column: str | None = None # override; default "trace_id" (absent -> row-per-trace)
38
+ payload_column: str | None = None # override; default "payload"
39
+ order_column: str | None = None # override; default "created_at" when the table has it
40
+
41
+
42
+ @runtime_checkable
43
+ class TraceAdapter(Protocol):
44
+ """Turns one source's raw telemetry into normalized `Trace` objects."""
45
+
46
+ name: str
47
+
48
+ def from_file(self, path: str) -> list[Trace]:
49
+ """Read traces from an exported file (OTLP-JSON / vendor JSONL)."""
50
+ ...
51
+
52
+ def from_vendor(self, pull: VendorPull) -> list[Trace]:
53
+ """Pull traces via a vendor SDK/API."""
54
+ ...
55
+
56
+
57
+ _ADAPTERS: dict[str, TraceAdapter] = {}
58
+
59
+
60
+ def register_adapter(adapter: TraceAdapter) -> None:
61
+ _ADAPTERS[adapter.name] = adapter
62
+
63
+
64
+ def get_adapter(name: str) -> TraceAdapter:
65
+ if name not in _ADAPTERS:
66
+ raise ValueError(f"no trace adapter registered for {name!r}; have {list(_ADAPTERS)}")
67
+ return _ADAPTERS[name]
68
+
69
+
70
+ def list_adapters() -> list[str]:
71
+ """Names of all registered trace adapters, sorted (what the build source picker shows)."""
72
+ return sorted(_ADAPTERS)
wmo/ingest/base.py ADDED
@@ -0,0 +1,114 @@
1
+ """`BaseTraceAdapter` — shared scaffolding so a new source is transport + mapping, not a rewrite.
2
+
3
+ A span-based provider adapter only needs to answer two questions:
4
+ 1. how do I get raw bytes/objects? (a file path, or a vendor API/SDK pull)
5
+ 2. how do I turn one raw payload into `SpanRecord`s? (the provider's export shape)
6
+ Everything after that — JSON/JSONL loading, grouping spans into `Trace`s, honoring `wmo.*`
7
+ enrichments — is shared (`wmo.ingest.normalize`). `BaseTraceAdapter` wires (1) and (2) together so
8
+ a concrete adapter is typically ~30 lines: set `name`, implement `spans_from_payload`, and (if it
9
+ supports live pulls) `_pull_payloads`.
10
+
11
+ Subclasses override:
12
+ - `name`: the registry key (e.g. "phoenix").
13
+ - `spans_from_payload(payload) -> list[SpanRecord]`: map ONE decoded JSON payload to spans. The
14
+ default delegates to `wmo.ingest.normalize.collect_spans` (OTLP/OpenInference-JSON); override it
15
+ when the provider's export is not OTLP-shaped.
16
+ - `_pull_payloads(pull) -> list[JsonValue]`: yield raw payloads from the vendor API/SDK. The
17
+ default raises a friendly "not implemented" — so `from_file` works with no override, and
18
+ `from_vendor` is opt-in.
19
+
20
+ `from_file` accepts a single JSON document (object or array) OR JSONL (one payload per line), and
21
+ skips a single corrupt JSONL line rather than aborting the whole ingest.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from pathlib import Path
28
+
29
+ from pydantic import JsonValue
30
+
31
+ from wmo.core.types import Trace
32
+ from wmo.ingest.adapter import VendorPull
33
+ from wmo.ingest.normalize import SpanRecord, collect_spans, spans_to_traces
34
+
35
+
36
+ def load_payloads(text: str) -> list[JsonValue]:
37
+ """Parse a whole-document JSON payload, or per-line JSONL on a top-level decode failure.
38
+
39
+ Module-level (not only a `BaseTraceAdapter` method) because format auto-detection and the
40
+ streaming ingest need the payloads BEFORE an adapter has been chosen.
41
+ """
42
+ try:
43
+ return [json.loads(text)]
44
+ except json.JSONDecodeError:
45
+ payloads: list[JsonValue] = []
46
+ for line in text.splitlines():
47
+ stripped = line.strip()
48
+ if not stripped:
49
+ continue
50
+ try:
51
+ payloads.append(json.loads(stripped))
52
+ except json.JSONDecodeError:
53
+ continue # tolerate a truncated/corrupt line; keep the rest
54
+ return payloads
55
+
56
+
57
+ class BaseTraceAdapter:
58
+ """Default file+vendor plumbing around the shared span normalizer."""
59
+
60
+ name: str = "base"
61
+
62
+ # --- mapping hook (override for non-OTLP export shapes) -----------------------------------
63
+
64
+ def spans_from_payload(self, payload: JsonValue) -> list[SpanRecord]:
65
+ """Map ONE decoded payload to `SpanRecord`s. Default: OTLP/OpenInference-JSON collection."""
66
+ return collect_spans(payload)
67
+
68
+ # --- transport hooks ----------------------------------------------------------------------
69
+
70
+ def _pull_payloads(self, pull: VendorPull) -> list[JsonValue]:
71
+ """Fetch raw payloads from the vendor API/SDK. Override to support live pulls."""
72
+ raise ValueError(
73
+ f"{self.name!r} does not support live vendor pulls yet; export traces to a file and "
74
+ f"use `from_file` (or `wmo ingest --source {self.name} --file <export>`)"
75
+ )
76
+
77
+ # --- public API (rarely overridden) -------------------------------------------------------
78
+
79
+ def collect_all(self, payloads: list[JsonValue]) -> list[SpanRecord]:
80
+ """Map every payload to spans and re-stamp `span_id` globally unique in emission order.
81
+
82
+ Adapters assign `span_id`/`start_nano` per payload, so a trace split across payloads (e.g.
83
+ one row/observation/run per JSONL line) would otherwise emit colliding ids and identical
84
+ `start_nano`. `spans_to_traces` sorts by `(start_nano, span_id)`, so the collision scrambles
85
+ the action/observation pairing. Stamping a globally monotonic `span_id` makes equal-time
86
+ spans (the row-adapter case, all `start_nano=0`) order by emission, while real timestamps
87
+ (e.g. Phoenix) still dominate the sort. Uniqueness is owned here so adapters can't get it
88
+ wrong individually.
89
+ """
90
+ spans: list[SpanRecord] = []
91
+ for payload in payloads:
92
+ for span in self.spans_from_payload(payload):
93
+ span.span_id = f"{len(spans):012d}-{span.span_id}"
94
+ spans.append(span)
95
+ return spans
96
+
97
+ def spans_from_file(self, path: str) -> list[SpanRecord]:
98
+ """All (uniquely re-stamped) spans in a file export: the streaming ingest's seam."""
99
+ return self.collect_all(load_payloads(Path(path).read_text(encoding="utf-8")))
100
+
101
+ def spans_from_pull(self, pull: VendorPull) -> list[SpanRecord]:
102
+ """All (uniquely re-stamped) spans from a vendor pull: the streaming ingest's seam."""
103
+ return self.collect_all(self._pull_payloads(pull))
104
+
105
+ def from_file(self, path: str) -> list[Trace]:
106
+ return spans_to_traces(self.spans_from_file(path), source=f"{self.name}:{path}")
107
+
108
+ def from_vendor(self, pull: VendorPull) -> list[Trace]:
109
+ traces = spans_to_traces(
110
+ self.spans_from_pull(pull), source=f"{self.name}:{pull.project or 'vendor'}"
111
+ )
112
+ if pull.limit is not None:
113
+ traces = traces[: pull.limit]
114
+ return traces