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.
- openpond_evals-0.1.0/.gitignore +126 -0
- openpond_evals-0.1.0/PKG-INFO +40 -0
- openpond_evals-0.1.0/README.md +31 -0
- openpond_evals-0.1.0/pyproject.toml +18 -0
- openpond_evals-0.1.0/src/openpond_evals/__init__.py +29 -0
- openpond_evals-0.1.0/src/openpond_evals/telemetry.py +348 -0
- openpond_evals-0.1.0/tests/test_telemetry.py +125 -0
|
@@ -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()
|