openpond-evals 0.1.0__tar.gz

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.
@@ -0,0 +1,126 @@
1
+ # Dependencies
2
+ node_modules/
3
+ apps/*/node_modules/
4
+ packages/*/node_modules/
5
+ packages/agent-sdk/examples/*/node_modules/
6
+
7
+ # Environment
8
+ .env
9
+ .env.*
10
+ apps/*/.env
11
+ apps/*/.env.*
12
+ packages/*/.env
13
+ packages/*/.env.*
14
+ packages/agent-sdk/examples/*/.env
15
+ packages/agent-sdk/examples/*/.env.*
16
+
17
+ # Build outputs
18
+ dist/
19
+ build/
20
+ !scripts/build/
21
+ !scripts/build/**
22
+ coverage/
23
+ .coverage
24
+ .coverage.*
25
+ htmlcov/
26
+ .phase5-profile-test/
27
+ .phase5-*/
28
+ .smoke-home/
29
+ .smoke-profile/
30
+ .cache/
31
+ .vite/
32
+ .next/
33
+ out/
34
+ release-assets/
35
+ release-smoke-artifacts/
36
+ release-smoke/
37
+ release-source-artifacts/
38
+ release/
39
+ release-cli/
40
+ apps/*/dist/
41
+ apps/desktop/stage/
42
+ apps/*/build/
43
+ !apps/desktop/build/
44
+ !apps/desktop/build/**
45
+ apps/*/coverage/
46
+ apps/*/.next/
47
+ apps/*/out/
48
+ apps/*/release-assets/
49
+ apps/*/release/
50
+ packages/*/dist/
51
+ packages/*/build/
52
+ packages/*/coverage/
53
+ packages/*/.next/
54
+ packages/*/out/
55
+ packages/*/release-assets/
56
+ packages/*/release/
57
+ apps/cli/artifacts/
58
+ apps/cli/runs/
59
+
60
+ # Generated TypeScript state
61
+ *.tsbuildinfo
62
+
63
+ # Python runtime state
64
+ __pycache__/
65
+ *.py[cod]
66
+ .venv/
67
+ .pytest_cache/
68
+ .mypy_cache/
69
+ .ruff_cache/
70
+ .tox/
71
+ .nox/
72
+ *.egg-info/
73
+
74
+ # Generated from @openpond/contracts before Python development, tests, and packaging
75
+ python/openpond-training/src/openpond_training/schemas/*.json
76
+
77
+ # Package artifacts
78
+ *.tgz
79
+
80
+ # Local planning docs
81
+ docs/working-docs/
82
+ docs/staging/
83
+
84
+ # Local generated run output
85
+ jobs/
86
+ tmp/
87
+ .openpond/
88
+ .local-examples/
89
+ .openpond-negative/
90
+ .openpond-test-fixtures/
91
+ output/
92
+ artifacts/
93
+
94
+ # Local/CDN video artifacts (restored with pnpm media:pull)
95
+ apps/web/public/courses/post-training/*.mp4
96
+ apps/web/public/tutorials/*.mp4
97
+
98
+ # Generated post-training course media (source code and metadata stay tracked)
99
+ docs/research/post-training-course/**/*.wav
100
+ docs/research/post-training-course/**/*.mp4
101
+
102
+ # Local research
103
+ docs/research/
104
+
105
+ # Local databases and process diagnostics
106
+ *.sqlite
107
+ *.sqlite-*
108
+ *.db
109
+ *.db-*
110
+ *.pid
111
+ *.prof
112
+ *.heapprofile
113
+ *.dmp
114
+
115
+ # Logs
116
+ *.log
117
+ tmp-*.png
118
+ npm-debug.log*
119
+ yarn-debug.log*
120
+ yarn-error.log*
121
+ bun-debug.log*
122
+
123
+ # OS/editor files
124
+ .DS_Store
125
+ .idea/
126
+ .vscode/
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: openpond-evals
3
+ Version: 0.1.0
4
+ Summary: Portable OpenPond evaluation and training telemetry contracts
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: pydantic<3,>=2.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # `openpond-evals`
11
+
12
+ Python producer SDK for the portable OpenPond evaluation and training telemetry
13
+ contracts. It is maintained beside `@openpond/evals` and uses the same schema
14
+ literals and conformance fixtures.
15
+
16
+ ```python
17
+ from datetime import datetime, timezone
18
+ from openpond_evals import TelemetryBuilder
19
+
20
+ builder = TelemetryBuilder(lineage)
21
+ event = builder.event(
22
+ occurred_at=datetime.now(timezone.utc),
23
+ source="optimizer",
24
+ event_type="optimizer_step_completed",
25
+ )
26
+ loss = builder.observation(
27
+ metric_id="optimizer.loss",
28
+ event=event,
29
+ value=0.42,
30
+ dimensions={"split": "train"},
31
+ )
32
+ ```
33
+
34
+ Use `AsyncTelemetryEmitter` around `BufferedTelemetryEmitter` to move network
35
+ delivery off the trainer hot path. Closing the asynchronous emitter drains its
36
+ queue and performs a terminal flush. Delivery failures are retained and raised
37
+ to the caller rather than silently dropping evidence.
38
+
39
+ This package does not contain trainers, optimizers, RunPod provisioning,
40
+ credentials, hosted storage, billing, or diagnostic-agent behavior.
@@ -0,0 +1,31 @@
1
+ # `openpond-evals`
2
+
3
+ Python producer SDK for the portable OpenPond evaluation and training telemetry
4
+ contracts. It is maintained beside `@openpond/evals` and uses the same schema
5
+ literals and conformance fixtures.
6
+
7
+ ```python
8
+ from datetime import datetime, timezone
9
+ from openpond_evals import TelemetryBuilder
10
+
11
+ builder = TelemetryBuilder(lineage)
12
+ event = builder.event(
13
+ occurred_at=datetime.now(timezone.utc),
14
+ source="optimizer",
15
+ event_type="optimizer_step_completed",
16
+ )
17
+ loss = builder.observation(
18
+ metric_id="optimizer.loss",
19
+ event=event,
20
+ value=0.42,
21
+ dimensions={"split": "train"},
22
+ )
23
+ ```
24
+
25
+ Use `AsyncTelemetryEmitter` around `BufferedTelemetryEmitter` to move network
26
+ delivery off the trainer hot path. Closing the asynchronous emitter drains its
27
+ queue and performs a terminal flush. Delivery failures are retained and raised
28
+ to the caller rather than silently dropping evidence.
29
+
30
+ This package does not contain trainers, optimizers, RunPod provisioning,
31
+ credentials, hosted storage, billing, or diagnostic-agent behavior.
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "openpond-evals"
7
+ version = "0.1.0"
8
+ description = "Portable OpenPond evaluation and training telemetry contracts"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ dependencies = ["pydantic>=2.11,<3"]
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/openpond_evals"]
16
+
17
+ [tool.pytest.ini_options]
18
+ testpaths = ["tests"]
@@ -0,0 +1,29 @@
1
+ """Portable OpenPond evaluation contracts."""
2
+
3
+ from .telemetry import (
4
+ BufferedTelemetryEmitter,
5
+ AsyncTelemetryEmitter,
6
+ CORE_METRIC_DIMENSIONS,
7
+ MetricDefinition,
8
+ MetricObservation,
9
+ EvidenceReference,
10
+ RunTelemetryBatch,
11
+ RunTelemetryEvent,
12
+ RunTelemetryLineage,
13
+ TelemetryBuilder,
14
+ telemetry_idempotency_key,
15
+ )
16
+
17
+ __all__ = [
18
+ "BufferedTelemetryEmitter",
19
+ "AsyncTelemetryEmitter",
20
+ "CORE_METRIC_DIMENSIONS",
21
+ "MetricDefinition",
22
+ "MetricObservation",
23
+ "EvidenceReference",
24
+ "RunTelemetryBatch",
25
+ "RunTelemetryEvent",
26
+ "RunTelemetryLineage",
27
+ "TelemetryBuilder",
28
+ "telemetry_idempotency_key",
29
+ ]
@@ -0,0 +1,348 @@
1
+ """Versioned training telemetry models and a bounded buffered HTTP emitter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import hashlib
7
+ import queue
8
+ import threading
9
+ import time
10
+ import urllib.request
11
+ from datetime import datetime
12
+ from typing import Any, Literal
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
+
16
+
17
+ CORE_METRIC_DIMENSIONS: dict[str, frozenset[str]] = {
18
+ "reward.mean": frozenset({"split", "grader"}),
19
+ "reward.variance": frozenset({"split"}),
20
+ "reward.constant_group_rate": frozenset({"split"}),
21
+ "attempt.valid_rate": frozenset({"split", "failureOwner"}),
22
+ "attempt.failure_count": frozenset({"split", "failureOwner", "failureClass"}),
23
+ "optimizer.loss": frozenset({"split"}),
24
+ "optimizer.learning_rate": frozenset({"split"}),
25
+ "optimizer.kl": frozenset({"split"}),
26
+ "optimizer.entropy": frozenset({"split"}),
27
+ "optimizer.gradient_norm": frozenset({"split"}),
28
+ "optimizer.clip_fraction": frozenset({"split"}),
29
+ "output.duplicate_rate": frozenset({"split"}),
30
+ "output.unique_count": frozenset({"split"}),
31
+ "tokens.input": frozenset({"split", "source"}),
32
+ "tokens.output": frozenset({"split", "source"}),
33
+ "runtime.latency_ms": frozenset({"operation", "provider"}),
34
+ "runtime.throughput": frozenset({"operation", "provider"}),
35
+ "gpu.memory_bytes": frozenset({"provider", "gpuType"}),
36
+ "gpu.utilization": frozenset({"provider", "gpuType"}),
37
+ "cost.usd": frozenset({"provider", "resource"}),
38
+ }
39
+ RATIO_METRICS = frozenset({"reward.constant_group_rate", "attempt.valid_rate", "optimizer.clip_fraction", "output.duplicate_rate", "gpu.utilization"})
40
+
41
+
42
+ class ContractModel(BaseModel):
43
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
44
+
45
+
46
+ class RunTelemetryLineage(ContractModel):
47
+ model_project_id: str = Field(alias="modelProjectId", min_length=1, max_length=200)
48
+ run_id: str = Field(alias="runId", min_length=1, max_length=200)
49
+ model_version_id: str | None = Field(alias="modelVersionId", default=None)
50
+ harness_release_hash: str = Field(alias="harnessReleaseHash", pattern=r"^[a-f0-9]{64}$")
51
+ taskset_release_hash: str = Field(alias="tasksetReleaseHash", pattern=r"^[a-f0-9]{64}$")
52
+ environment_release_hash: str | None = Field(alias="environmentReleaseHash", default=None, pattern=r"^[a-f0-9]{64}$")
53
+ checkpoint_id: str | None = Field(alias="checkpointId", default=None)
54
+ step: int | None = Field(default=None, ge=0)
55
+ rollout_group_id: str | None = Field(alias="rolloutGroupId", default=None)
56
+ attempt_id: str | None = Field(alias="attemptId", default=None)
57
+ scenario_id: str | None = Field(alias="scenarioId", default=None)
58
+
59
+
60
+ class RunTelemetryEvent(ContractModel):
61
+ schema_version: Literal["openpond.runTelemetryEvent.v1"] = Field(alias="schemaVersion")
62
+ event_id: str = Field(alias="eventId", min_length=1, max_length=200)
63
+ sequence: int = Field(ge=0)
64
+ occurred_at: datetime = Field(alias="occurredAt")
65
+ source: Literal["runtime", "environment", "grader", "optimizer", "control_plane", "evaluation"]
66
+ type: Literal[
67
+ "run_started", "run_state_changed", "rollout_group_started",
68
+ "attempt_completed", "grader_completed", "reward_composed",
69
+ "optimizer_step_completed", "checkpoint_committed",
70
+ "evaluation_completed", "run_completed", "run_failed", "cleanup_completed",
71
+ ]
72
+ visibility: Literal["policy_visible", "team_visible", "host_private"]
73
+ lineage: RunTelemetryLineage
74
+ attributes: dict[str, str | int | float | bool | None]
75
+
76
+
77
+ class MetricDefinition(ContractModel):
78
+ schema_version: Literal["openpond.metricDefinition.v1"] = Field(alias="schemaVersion")
79
+ id: str = Field(min_length=1, max_length=200)
80
+ display_name: str = Field(alias="displayName", min_length=1, max_length=200)
81
+ description: str = Field(min_length=1, max_length=2000)
82
+ value_type: Literal["gauge", "counter", "distribution"] = Field(alias="valueType")
83
+ unit: Literal["ratio", "count", "seconds", "milliseconds", "tokens", "bytes", "usd", "scalar"]
84
+ direction: Literal["higher", "lower", "neutral"]
85
+ aggregation: Literal["last", "sum", "mean", "min", "max", "p50", "p95"]
86
+ visibility: Literal["policy_visible", "team_visible", "host_private"]
87
+ bounded_dimensions: list[str] = Field(alias="boundedDimensions", max_length=32)
88
+
89
+
90
+ class MetricObservation(ContractModel):
91
+ schema_version: Literal["openpond.metricObservation.v1"] = Field(alias="schemaVersion")
92
+ observation_id: str = Field(alias="observationId", min_length=1, max_length=200)
93
+ metric_id: str = Field(alias="metricId", min_length=1, max_length=200)
94
+ event_id: str = Field(alias="eventId", min_length=1, max_length=200)
95
+ sequence: int = Field(ge=0)
96
+ observed_at: datetime = Field(alias="observedAt")
97
+ value: float
98
+ lineage: RunTelemetryLineage
99
+ dimensions: dict[str, str]
100
+
101
+ @model_validator(mode="after")
102
+ def validate_core_metric(self) -> "MetricObservation":
103
+ allowed = CORE_METRIC_DIMENSIONS.get(self.metric_id)
104
+ if allowed is None:
105
+ raise ValueError(f"unknown core metric: {self.metric_id}")
106
+ unexpected = set(self.dimensions) - allowed
107
+ if unexpected:
108
+ raise ValueError(f"unsupported metric dimensions: {sorted(unexpected)}")
109
+ if self.metric_id in RATIO_METRICS and not 0 <= self.value <= 1:
110
+ raise ValueError(f"ratio metric {self.metric_id} must be between zero and one")
111
+ return self
112
+
113
+
114
+ class RunTelemetryBatch(ContractModel):
115
+ schema_version: Literal["openpond.runTelemetryBatch.v1"] = Field(alias="schemaVersion")
116
+ events: list[RunTelemetryEvent] = Field(max_length=1000)
117
+ observations: list[MetricObservation] = Field(max_length=10000)
118
+
119
+ @model_validator(mode="after")
120
+ def validate_idempotency_keys(self) -> "RunTelemetryBatch":
121
+ if not self.events and not self.observations:
122
+ raise ValueError("telemetry batch cannot be empty")
123
+ keys = [telemetry_idempotency_key(item) for item in [*self.events, *self.observations]]
124
+ if len(keys) != len(set(keys)):
125
+ raise ValueError("telemetry batch contains a duplicate idempotency key")
126
+ return self
127
+
128
+
129
+ class EvidenceReference(ContractModel):
130
+ id: str = Field(min_length=1, max_length=200)
131
+ content_hash: str = Field(alias="contentHash", pattern=r"^[a-f0-9]{64}$")
132
+ kind: Literal["rollout", "attempt", "trace", "grader", "checkpoint", "artifact"]
133
+ visibility: Literal["policy_visible", "team_visible", "host_private"]
134
+
135
+
136
+ def _stable_id(prefix: str, value: dict[str, Any]) -> str:
137
+ canonical = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
138
+ return f"{prefix}-{hashlib.sha256(canonical).hexdigest()[:32]}"
139
+
140
+
141
+ def telemetry_idempotency_key(item: RunTelemetryEvent | MetricObservation) -> str:
142
+ item_id = item.event_id if isinstance(item, RunTelemetryEvent) else item.observation_id
143
+ return f"{item.lineage.run_id}:{item.sequence}:{item_id}"
144
+
145
+
146
+ class TelemetryBuilder:
147
+ """Constructs ordered, deterministic telemetry for one Run."""
148
+
149
+ def __init__(self, lineage: RunTelemetryLineage, starting_sequence: int = 0):
150
+ if starting_sequence < 0:
151
+ raise ValueError("starting_sequence must be nonnegative")
152
+ self.lineage = lineage
153
+ self._sequence = starting_sequence
154
+ self._lock = threading.Lock()
155
+
156
+ def _next_sequence(self) -> int:
157
+ with self._lock:
158
+ sequence = self._sequence
159
+ self._sequence += 1
160
+ return sequence
161
+
162
+ def event(
163
+ self,
164
+ *,
165
+ occurred_at: datetime,
166
+ source: str,
167
+ event_type: str,
168
+ visibility: str = "team_visible",
169
+ attributes: dict[str, str | int | float | bool | None] | None = None,
170
+ lineage: RunTelemetryLineage | None = None,
171
+ ) -> RunTelemetryEvent:
172
+ event_lineage = lineage or self.lineage
173
+ sequence = self._next_sequence()
174
+ event_id = _stable_id("telemetry", {"runId": event_lineage.run_id, "sequence": sequence, "type": event_type, "source": source})
175
+ return RunTelemetryEvent.model_validate({
176
+ "schemaVersion": "openpond.runTelemetryEvent.v1",
177
+ "eventId": event_id,
178
+ "sequence": sequence,
179
+ "occurredAt": occurred_at,
180
+ "source": source,
181
+ "type": event_type,
182
+ "visibility": visibility,
183
+ "lineage": event_lineage,
184
+ "attributes": attributes or {},
185
+ })
186
+
187
+ def observation(
188
+ self,
189
+ *,
190
+ metric_id: str,
191
+ event: RunTelemetryEvent,
192
+ value: float,
193
+ dimensions: dict[str, str] | None = None,
194
+ lineage: RunTelemetryLineage | None = None,
195
+ ) -> MetricObservation:
196
+ observation_lineage = lineage or event.lineage
197
+ sequence = self._next_sequence()
198
+ observation_id = _stable_id("metric", {"runId": observation_lineage.run_id, "metricId": metric_id, "sequence": sequence})
199
+ return MetricObservation.model_validate({
200
+ "schemaVersion": "openpond.metricObservation.v1",
201
+ "observationId": observation_id,
202
+ "metricId": metric_id,
203
+ "eventId": event.event_id,
204
+ "sequence": sequence,
205
+ "observedAt": event.occurred_at,
206
+ "value": value,
207
+ "lineage": observation_lineage,
208
+ "dimensions": dimensions or {},
209
+ })
210
+
211
+
212
+ class BufferedTelemetryEmitter:
213
+ """Buffers portable events and observations; callers control retry policy."""
214
+
215
+ def __init__(self, endpoint: str, token: str, batch_size: int = 100, timeout_seconds: int = 15, max_buffer_items: int = 10_000, retry_attempts: int = 3):
216
+ if batch_size < 1 or batch_size > 1000:
217
+ raise ValueError("batch_size must be between 1 and 1000")
218
+ self._endpoint = endpoint
219
+ self._token = token
220
+ self._batch_size = batch_size
221
+ self._timeout_seconds = timeout_seconds
222
+ self._max_buffer_items = max_buffer_items
223
+ self._retry_attempts = retry_attempts
224
+ self._events: list[RunTelemetryEvent] = []
225
+ self._observations: list[MetricObservation] = []
226
+ self._lock = threading.Lock()
227
+
228
+ def emit_event(self, event: RunTelemetryEvent) -> None:
229
+ with self._lock:
230
+ if len(self._events) + len(self._observations) >= self._max_buffer_items:
231
+ raise BufferError("telemetry buffer is full")
232
+ self._events.append(event)
233
+ should_flush = len(self._events) + len(self._observations) >= self._batch_size
234
+ if should_flush:
235
+ self.flush()
236
+
237
+ def emit_observation(self, observation: MetricObservation) -> None:
238
+ with self._lock:
239
+ if len(self._events) + len(self._observations) >= self._max_buffer_items:
240
+ raise BufferError("telemetry buffer is full")
241
+ self._observations.append(observation)
242
+ should_flush = len(self._events) + len(self._observations) >= self._batch_size
243
+ if should_flush:
244
+ self.flush()
245
+
246
+ def flush(self) -> int:
247
+ with self._lock:
248
+ if not self._events and not self._observations:
249
+ return 0
250
+ events, observations = self._events, self._observations
251
+ self._events, self._observations = [], []
252
+ batch = RunTelemetryBatch(
253
+ schemaVersion="openpond.runTelemetryBatch.v1",
254
+ events=events,
255
+ observations=observations,
256
+ )
257
+ body = batch.model_dump_json(by_alias=True).encode("utf-8")
258
+ request = urllib.request.Request(
259
+ self._endpoint,
260
+ data=body,
261
+ headers={"content-type": "application/json", "authorization": f"Bearer {self._token}"},
262
+ method="POST",
263
+ )
264
+ last_error: Exception | None = None
265
+ for attempt in range(self._retry_attempts):
266
+ try:
267
+ with urllib.request.urlopen(request, timeout=self._timeout_seconds) as response:
268
+ json.loads(response.read().decode("utf-8"))
269
+ return len(events) + len(observations)
270
+ except Exception as error:
271
+ last_error = error
272
+ if attempt + 1 < self._retry_attempts:
273
+ time.sleep(min(2 ** attempt, 4))
274
+ if last_error is not None:
275
+ with self._lock:
276
+ self._events = events + self._events
277
+ self._observations = observations + self._observations
278
+ raise last_error
279
+ return 0
280
+
281
+ def close(self) -> int:
282
+ return self.flush()
283
+
284
+ def __enter__(self) -> "BufferedTelemetryEmitter":
285
+ return self
286
+
287
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
288
+ self.close()
289
+
290
+
291
+ class AsyncTelemetryEmitter:
292
+ """Moves HTTP delivery off the trainer hot path and flushes on close."""
293
+
294
+ def __init__(self, emitter: BufferedTelemetryEmitter, max_queue_items: int = 10_000):
295
+ self._emitter = emitter
296
+ self._queue: queue.Queue[RunTelemetryEvent | MetricObservation | None] = queue.Queue(maxsize=max_queue_items)
297
+ self._error: Exception | None = None
298
+ self._closed = False
299
+ self._thread = threading.Thread(target=self._run, name="openpond-telemetry", daemon=True)
300
+ self._thread.start()
301
+
302
+ def emit_event(self, event: RunTelemetryEvent) -> None:
303
+ self._enqueue(event)
304
+
305
+ def emit_observation(self, observation: MetricObservation) -> None:
306
+ self._enqueue(observation)
307
+
308
+ def _enqueue(self, item: RunTelemetryEvent | MetricObservation) -> None:
309
+ if self._closed:
310
+ raise RuntimeError("telemetry emitter is closed")
311
+ if self._error is not None:
312
+ raise RuntimeError("telemetry delivery failed") from self._error
313
+ try:
314
+ self._queue.put_nowait(item)
315
+ except queue.Full as error:
316
+ raise BufferError("telemetry delivery queue is full") from error
317
+
318
+ def _run(self) -> None:
319
+ try:
320
+ while True:
321
+ item = self._queue.get()
322
+ try:
323
+ if item is None:
324
+ self._emitter.flush()
325
+ return
326
+ if isinstance(item, RunTelemetryEvent):
327
+ self._emitter.emit_event(item)
328
+ else:
329
+ self._emitter.emit_observation(item)
330
+ finally:
331
+ self._queue.task_done()
332
+ except Exception as error:
333
+ self._error = error
334
+
335
+ def close(self) -> None:
336
+ if self._closed:
337
+ return
338
+ self._closed = True
339
+ self._queue.put(None)
340
+ self._thread.join()
341
+ if self._error is not None:
342
+ raise RuntimeError("telemetry delivery failed") from self._error
343
+
344
+ def __enter__(self) -> "AsyncTelemetryEmitter":
345
+ return self
346
+
347
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
348
+ self.close()
@@ -0,0 +1,125 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+ from pydantic import ValidationError
6
+ from datetime import datetime, timezone
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ import threading
9
+ from unittest.mock import patch
10
+
11
+ from openpond_evals.telemetry import (
12
+ BufferedTelemetryEmitter,
13
+ AsyncTelemetryEmitter,
14
+ RunTelemetryBatch,
15
+ RunTelemetryLineage,
16
+ TelemetryBuilder,
17
+ telemetry_idempotency_key,
18
+ )
19
+
20
+
21
+ FIXTURES = Path(__file__).parents[2] / "conformance" / "telemetry" / "v1"
22
+
23
+
24
+ def test_shared_conformance_fixtures() -> None:
25
+ valid = json.loads((FIXTURES / "valid-batch.json").read_text())
26
+ invalid = json.loads((FIXTURES / "invalid-batch.json").read_text())
27
+ assert len(RunTelemetryBatch.model_validate(valid).events) == 1
28
+ with pytest.raises(ValidationError):
29
+ RunTelemetryBatch.model_validate(invalid)
30
+
31
+
32
+ def test_builder_assigns_ordered_stable_ids_and_validates_metrics() -> None:
33
+ lineage = RunTelemetryLineage.model_validate({
34
+ "modelProjectId": "project-1", "runId": "run-1", "modelVersionId": "version-1",
35
+ "harnessReleaseHash": "a" * 64, "tasksetReleaseHash": "b" * 64,
36
+ "environmentReleaseHash": None, "checkpointId": None, "step": 1,
37
+ "rolloutGroupId": "group-1", "attemptId": None, "scenarioId": "scenario-1",
38
+ })
39
+ builder = TelemetryBuilder(lineage)
40
+ event = builder.event(occurred_at=datetime(2026, 8, 25, tzinfo=timezone.utc), source="optimizer", event_type="optimizer_step_completed")
41
+ observation = builder.observation(metric_id="optimizer.loss", event=event, value=0.5, dimensions={"split": "train"})
42
+ assert event.sequence == 0
43
+ assert observation.sequence == 1
44
+ assert telemetry_idempotency_key(observation).startswith("run-1:1:metric-")
45
+ with pytest.raises(ValidationError):
46
+ builder.observation(metric_id="optimizer.loss", event=event, value=0.5, dimensions={"unbounded": "no"})
47
+
48
+
49
+ def test_emitter_requeues_failed_batches() -> None:
50
+ valid = RunTelemetryBatch.model_validate(json.loads((FIXTURES / "valid-batch.json").read_text()))
51
+ emitter = BufferedTelemetryEmitter("https://example.invalid/telemetry", "token", retry_attempts=1)
52
+ emitter.emit_event(valid.events[0])
53
+ with patch("urllib.request.urlopen", side_effect=OSError("offline")):
54
+ with pytest.raises(OSError):
55
+ emitter.flush()
56
+ with patch("urllib.request.urlopen") as request:
57
+ request.return_value.__enter__.return_value.read.return_value = b'{"accepted":1}'
58
+ assert emitter.flush() == 1
59
+
60
+
61
+ def test_builder_preserves_unique_sequence_under_concurrency() -> None:
62
+ valid = RunTelemetryBatch.model_validate(json.loads((FIXTURES / "valid-batch.json").read_text()))
63
+ builder = TelemetryBuilder(valid.events[0].lineage)
64
+ with ThreadPoolExecutor(max_workers=8) as pool:
65
+ events = list(pool.map(lambda _: builder.event(
66
+ occurred_at=datetime(2026, 8, 25, tzinfo=timezone.utc),
67
+ source="runtime",
68
+ event_type="run_state_changed",
69
+ ), range(100)))
70
+ assert sorted(event.sequence for event in events) == list(range(100))
71
+ assert len({event.event_id for event in events}) == 100
72
+
73
+
74
+ def test_async_emitter_applies_backpressure_and_flushes_on_close() -> None:
75
+ valid = RunTelemetryBatch.model_validate(json.loads((FIXTURES / "valid-batch.json").read_text()))
76
+ entered = threading.Event()
77
+ release = threading.Event()
78
+
79
+ class BlockingEmitter:
80
+ def __init__(self) -> None:
81
+ self.events = []
82
+ self.flushed = False
83
+
84
+ def emit_event(self, event) -> None:
85
+ entered.set()
86
+ release.wait(timeout=2)
87
+ self.events.append(event)
88
+
89
+ def emit_observation(self, observation) -> None:
90
+ self.events.append(observation)
91
+
92
+ def flush(self) -> int:
93
+ self.flushed = True
94
+ return len(self.events)
95
+
96
+ target = BlockingEmitter()
97
+ emitter = AsyncTelemetryEmitter(target, max_queue_items=1)
98
+ emitter.emit_event(valid.events[0])
99
+ assert entered.wait(timeout=2)
100
+ emitter.emit_event(valid.events[0])
101
+ with pytest.raises(BufferError):
102
+ emitter.emit_event(valid.events[0])
103
+ release.set()
104
+ emitter.close()
105
+ assert len(target.events) == 2
106
+ assert target.flushed is True
107
+
108
+
109
+ def test_async_emitter_surfaces_delivery_failure_at_terminal_flush() -> None:
110
+ valid = RunTelemetryBatch.model_validate(json.loads((FIXTURES / "valid-batch.json").read_text()))
111
+
112
+ class FailingEmitter:
113
+ def emit_event(self, event) -> None:
114
+ raise OSError("delivery failed")
115
+
116
+ def emit_observation(self, observation) -> None:
117
+ raise OSError("delivery failed")
118
+
119
+ def flush(self) -> int:
120
+ return 0
121
+
122
+ emitter = AsyncTelemetryEmitter(FailingEmitter())
123
+ emitter.emit_event(valid.events[0])
124
+ with pytest.raises(RuntimeError, match="telemetry delivery failed"):
125
+ emitter.close()