loopotel 0.1.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.
loopotel/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Loop Trace Format (LTF) instrumentation for loop runs."""
2
+
3
+ from loopotel.tracer import LoopTracer, current_tracer, emit_iteration, trace_loop
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["LoopTracer", "current_tracer", "emit_iteration", "trace_loop", "__version__"]
loopotel/cli.py ADDED
@@ -0,0 +1,40 @@
1
+ """CLI for LTF validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from loopotel.validate import validate_trace
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(description="Validate LTF trace JSON or JSONL")
15
+ parser.add_argument("path", type=Path)
16
+ args = parser.parse_args(argv)
17
+
18
+ if not args.path.exists():
19
+ print(f"Missing: {args.path}", file=sys.stderr)
20
+ return 1
21
+
22
+ lines = args.path.read_text(encoding="utf-8").strip().splitlines()
23
+ docs = [json.loads(line) for line in lines if line.strip()] if args.path.suffix == ".jsonl" else [json.loads(args.path.read_text(encoding="utf-8"))]
24
+
25
+ failed = False
26
+ for index, doc in enumerate(docs, start=1):
27
+ valid, errors = validate_trace(doc)
28
+ label = str(args.path) if len(docs) == 1 else f"{args.path}#{index}"
29
+ if valid:
30
+ print(f"VALID: {label}")
31
+ else:
32
+ failed = True
33
+ print(f"INVALID: {label}", file=sys.stderr)
34
+ for err in errors:
35
+ print(f" - {err}", file=sys.stderr)
36
+ return 1 if failed else 0
37
+
38
+
39
+ if __name__ == "__main__":
40
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Trace exporters."""
2
+
3
+ from loopotel.exporter.jsonl import JsonlExporter
4
+ from loopotel.exporter.loopnet import trajectory_from_trace
5
+
6
+ __all__ = ["JsonlExporter", "trajectory_from_trace"]
@@ -0,0 +1,22 @@
1
+ """Write LTF traces to JSONL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class JsonlExporter:
11
+ """Append LTF trace documents to a JSONL file."""
12
+
13
+ def __init__(self, path: str | Path) -> None:
14
+ self.path = Path(path)
15
+
16
+ def export(self, trace: dict[str, Any]) -> None:
17
+ self.path.parent.mkdir(parents=True, exist_ok=True)
18
+ with self.path.open("a", encoding="utf-8") as handle:
19
+ handle.write(json.dumps(trace, separators=(",", ":")) + "\n")
20
+
21
+ def export_tracer(self, tracer: Any) -> None:
22
+ self.export(tracer.build_trace())
@@ -0,0 +1,35 @@
1
+ """Map LTF iteration spans to LoopNet trajectory steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def trajectory_from_trace(trace: dict[str, Any]) -> list[dict[str, Any]]:
9
+ """Extract ln/record-v1-compatible trajectory steps from iteration spans."""
10
+ steps: list[dict[str, Any]] = []
11
+ for span in trace.get("spans") or []:
12
+ if span.get("kind") != "iteration":
13
+ continue
14
+ attrs = span.get("attributes") or {}
15
+ iteration = int(attrs.get("loop.iteration", len(steps) + 1))
16
+ goal_score = float(attrs.get("loop.goal_score", 0.0))
17
+ steps.append(
18
+ {
19
+ "iteration": iteration,
20
+ "goal_score": goal_score,
21
+ "primary_quality": goal_score,
22
+ "cost_usd": attrs.get("loop.cost_usd"),
23
+ "latency_seconds": (
24
+ float(attrs["loop.latency_ms"]) / 1000.0
25
+ if attrs.get("loop.latency_ms") is not None
26
+ else None
27
+ ),
28
+ "tokens": attrs.get("loop.tokens_delta"),
29
+ "failure_codes": [],
30
+ "safety_events": 0,
31
+ "human_intervention": False,
32
+ }
33
+ )
34
+ steps.sort(key=lambda s: s["iteration"])
35
+ return steps
@@ -0,0 +1,57 @@
1
+ """Optional OTLP export (requires opentelemetry-sdk)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class OtlpExporter:
9
+ """Export LTF spans to an OTLP endpoint via OpenTelemetry SDK."""
10
+
11
+ def __init__(self, endpoint: str | None = None, service_name: str = "loop-runtime") -> None:
12
+ self.endpoint = endpoint
13
+ self.service_name = service_name
14
+ self._provider = None
15
+
16
+ def _ensure_provider(self) -> None:
17
+ if self._provider is not None:
18
+ return
19
+ try:
20
+ from opentelemetry import trace
21
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
22
+ from opentelemetry.sdk.resources import Resource
23
+ from opentelemetry.sdk.trace import TracerProvider
24
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
25
+ except ImportError as exc:
26
+ raise SystemExit(
27
+ "OTLP export requires: pip install loopotel[otlp]"
28
+ ) from exc
29
+
30
+ resource = Resource.create({"service.name": self.service_name})
31
+ provider = TracerProvider(resource=resource)
32
+ kwargs: dict[str, Any] = {}
33
+ if self.endpoint:
34
+ kwargs["endpoint"] = self.endpoint
35
+ exporter = OTLPSpanExporter(**kwargs)
36
+ provider.add_span_processor(BatchSpanProcessor(exporter))
37
+ trace.set_tracer_provider(provider)
38
+ self._provider = provider
39
+
40
+ def export(self, trace_doc: dict[str, Any]) -> None:
41
+ """Best-effort map of LTF document to OTel spans."""
42
+ self._ensure_provider()
43
+ from opentelemetry import trace
44
+
45
+ otel = trace.get_tracer("loopotel")
46
+ root = next((s for s in trace_doc.get("spans", []) if s.get("kind") == "loop"), None)
47
+ if root is None:
48
+ return
49
+ with otel.start_as_current_span(root["name"]) as span:
50
+ for key, value in (root.get("attributes") or {}).items():
51
+ span.set_attribute(key, value)
52
+ for child in trace_doc.get("spans") or []:
53
+ if child.get("parent_span_id") != root.get("span_id"):
54
+ continue
55
+ with otel.start_as_current_span(child["name"]) as child_span:
56
+ for key, value in (child.get("attributes") or {}).items():
57
+ child_span.set_attribute(key, value)
@@ -0,0 +1,5 @@
1
+ """Third-party integrations."""
2
+
3
+ from loopotel.integrations.loopgym import run_traced_episode, trace_live_episode
4
+
5
+ __all__ = ["run_traced_episode", "trace_live_episode"]
@@ -0,0 +1,104 @@
1
+ """LoopGym integration — trace SimEnv / LiveEnv episodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from loopotel.tracer import LoopTracer
9
+
10
+
11
+ def _worker_evaluator_ids(spec: dict[str, Any]) -> tuple[str | None, str | None]:
12
+ workers = spec.get("workers") or []
13
+ evaluators = spec.get("evaluators") or []
14
+ worker_id = str(workers[0]["id"]) if workers and workers[0].get("id") else None
15
+ evaluator_id = str(evaluators[0]["id"]) if evaluators and evaluators[0].get("id") else None
16
+ return worker_id, evaluator_id
17
+
18
+
19
+ def _goal_target_from_spec(spec: dict[str, Any]) -> float:
20
+ term = spec.get("termination_conditions")
21
+ if isinstance(term, dict):
22
+ for success in term.get("success") or []:
23
+ if success.get("value") is not None:
24
+ return float(success["value"])
25
+ for ev in spec.get("evaluators") or []:
26
+ rubric = ev.get("rubric") or {}
27
+ if rubric.get("pass_threshold") is not None:
28
+ return float(rubric["pass_threshold"])
29
+ return 0.8
30
+
31
+
32
+ def run_traced_episode(
33
+ env: Any,
34
+ *,
35
+ task_id: str = "",
36
+ seed: int | None = None,
37
+ enabled: bool = True,
38
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
39
+ """Run a LoopGym episode and return (episode_result, ltf_trace).
40
+
41
+ Tracing is opt-in (`enabled=True`). Default off matches SimEnv policy;
42
+ pass `enabled=True` explicitly or use `trace_live=True` wrappers.
43
+ """
44
+ spec = getattr(env, "spec", {}) or {}
45
+ loop_name = str(spec.get("loop_name", "unknown-loop"))
46
+ goal_target = _goal_target_from_spec(spec)
47
+ worker_id, evaluator_id = _worker_evaluator_ids(spec)
48
+
49
+ tracer = LoopTracer(
50
+ loop_name=loop_name,
51
+ env_id=getattr(env, "env_id", None),
52
+ task_id=task_id or None,
53
+ goal_target=goal_target,
54
+ enabled=enabled,
55
+ )
56
+
57
+ with tracer:
58
+ obs = env.reset(task_id=task_id, seed=seed)
59
+ prev_tokens = 0
60
+ if getattr(env, "_runtime", None) and getattr(env._runtime, "llm", None):
61
+ prev_tokens = getattr(env._runtime.llm, "tokens_used", 0)
62
+
63
+ last_latency_ms = 0.0
64
+ while not env.done:
65
+ t0 = time.perf_counter()
66
+ obs, _reward, _done, info = env.step()
67
+ last_latency_ms = (time.perf_counter() - t0) * 1000.0
68
+ tokens_now = 0
69
+ if getattr(env, "_runtime", None) and getattr(env._runtime, "llm", None):
70
+ tokens_now = getattr(env._runtime.llm, "tokens_used", 0)
71
+ tokens_delta = max(0, tokens_now - prev_tokens)
72
+ prev_tokens = tokens_now
73
+
74
+ if obs.iteration > 0:
75
+ tracer.emit_iteration(
76
+ iteration=obs.iteration,
77
+ goal_score=obs.quality_score,
78
+ goal_target=goal_target,
79
+ tokens_delta=tokens_delta,
80
+ latency_ms=round(last_latency_ms, 2),
81
+ worker_id=worker_id,
82
+ evaluator_id=evaluator_id,
83
+ )
84
+
85
+ outcome = "success" if info.get("success") else "failure"
86
+ tracer.finish(
87
+ outcome=outcome,
88
+ termination_reason=str(info.get("termination_reason", "")),
89
+ )
90
+
91
+ result = {
92
+ "task_id": task_id,
93
+ "seed": seed,
94
+ "env_id": getattr(env, "env_id", None),
95
+ "success": info.get("success", False),
96
+ "quality_score": obs.quality_score,
97
+ "termination_reason": info.get("termination_reason", ""),
98
+ }
99
+ return result, tracer.build_trace()
100
+
101
+
102
+ def trace_live_episode(env: Any, **kwargs: Any) -> tuple[dict[str, Any], dict[str, Any]]:
103
+ """LiveEnv helper — tracing on by default."""
104
+ return run_traced_episode(env, enabled=True, **kwargs)
loopotel/les.py ADDED
@@ -0,0 +1,18 @@
1
+ """Point-in-loop LES proxies (v0.1 — effectiveness-focused)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DEFAULT_GOAL_TARGET = 0.8
6
+
7
+
8
+ def effectiveness(goal_score: float, goal_target: float = DEFAULT_GOAL_TARGET) -> float:
9
+ if goal_target <= 0:
10
+ return 0.0
11
+ return max(0.0, min(1.0, goal_score / goal_target))
12
+
13
+
14
+ def cumulative_les(scores: list[float], goal_target: float = DEFAULT_GOAL_TARGET) -> float:
15
+ if not scores:
16
+ return 0.0
17
+ values = [effectiveness(s, goal_target) for s in scores]
18
+ return round(sum(values) / len(values), 4)
loopotel/models.py ADDED
@@ -0,0 +1,71 @@
1
+ """LTF document builders and identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+
11
+ def utc_now() -> datetime:
12
+ return datetime.now(timezone.utc)
13
+
14
+
15
+ def isoformat(dt: datetime) -> str:
16
+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
17
+
18
+
19
+ def new_trace_id() -> str:
20
+ return f"ltf-{uuid4()}"
21
+
22
+
23
+ def new_span_id() -> str:
24
+ return secrets.token_hex(8)
25
+
26
+
27
+ def spec_pins() -> dict[str, str]:
28
+ return {"lss": "lss@1.0.0", "les": "les@1.0.0", "ltf": "ltf@0.1.0"}
29
+
30
+
31
+ def otel_attributes(
32
+ *,
33
+ loop_name: str,
34
+ env_id: str | None = None,
35
+ task_id: str | None = None,
36
+ iteration: int | None = None,
37
+ goal_score: float | None = None,
38
+ goal_target: float | None = None,
39
+ tokens_delta: int | None = None,
40
+ cost_usd: float | None = None,
41
+ latency_ms: float | None = None,
42
+ les_cumulative: float | None = None,
43
+ les_effectiveness: float | None = None,
44
+ worker_id: str | None = None,
45
+ evaluator_id: str | None = None,
46
+ outcome: str | None = None,
47
+ termination_reason: str | None = None,
48
+ ) -> dict[str, Any]:
49
+ attrs: dict[str, Any] = {"loop.name": loop_name}
50
+ mapping = {
51
+ "loop.env_id": env_id,
52
+ "loop.task_id": task_id,
53
+ "loop.iteration": iteration,
54
+ "loop.goal_score": goal_score,
55
+ "loop.goal_target": goal_target,
56
+ "loop.tokens_delta": tokens_delta,
57
+ "loop.cost_usd": cost_usd,
58
+ "loop.latency_ms": latency_ms,
59
+ "loop.les.cumulative_normalized": les_cumulative,
60
+ "loop.les.effectiveness": les_effectiveness,
61
+ "loop.worker.id": worker_id,
62
+ "loop.evaluator.id": evaluator_id,
63
+ "loop.outcome": outcome,
64
+ "loop.termination_reason": termination_reason,
65
+ "loop.lss_version": "1.0.0",
66
+ "loop.les_version": "1.0.0",
67
+ }
68
+ for key, value in mapping.items():
69
+ if value is not None:
70
+ attrs[key] = value
71
+ return attrs
@@ -0,0 +1,105 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://loop-engineering.org/schema/ltf-0.1.schema.json",
4
+ "title": "Loop Trace Format 0.1",
5
+ "description": "Canonical schema for loop execution traces: one root span per run, iteration spans with worker/evaluator events, and point-in-loop LES metrics.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema_version",
10
+ "trace_id",
11
+ "loop_name",
12
+ "started_at",
13
+ "ended_at",
14
+ "spec_pins",
15
+ "spans"
16
+ ],
17
+ "properties": {
18
+ "schema_version": {
19
+ "type": "string",
20
+ "const": "ltf/0.1"
21
+ },
22
+ "trace_id": {
23
+ "type": "string",
24
+ "pattern": "^ltf-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
25
+ },
26
+ "loop_name": {
27
+ "type": "string",
28
+ "minLength": 3,
29
+ "maxLength": 64
30
+ },
31
+ "env_id": { "type": "string" },
32
+ "task_id": { "type": "string" },
33
+ "started_at": { "type": "string", "format": "date-time" },
34
+ "ended_at": { "type": "string", "format": "date-time" },
35
+ "outcome": {
36
+ "type": "string",
37
+ "enum": ["success", "failure", "partial", "unknown"]
38
+ },
39
+ "termination_reason": { "type": "string" },
40
+ "spec_pins": {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "required": ["lss", "les", "ltf"],
44
+ "properties": {
45
+ "lss": { "type": "string", "const": "lss@1.0.0" },
46
+ "les": { "type": "string", "const": "les@1.0.0" },
47
+ "ltf": { "type": "string", "const": "ltf@0.1.0" }
48
+ }
49
+ },
50
+ "spans": {
51
+ "type": "array",
52
+ "minItems": 1,
53
+ "items": { "$ref": "#/$defs/Span" }
54
+ },
55
+ "metadata": {
56
+ "type": "object",
57
+ "additionalProperties": true
58
+ }
59
+ },
60
+ "$defs": {
61
+ "SpanKind": {
62
+ "type": "string",
63
+ "enum": ["loop", "iteration", "worker", "evaluator"]
64
+ },
65
+ "Span": {
66
+ "type": "object",
67
+ "additionalProperties": false,
68
+ "required": ["span_id", "name", "kind", "start_time", "attributes"],
69
+ "properties": {
70
+ "span_id": {
71
+ "type": "string",
72
+ "pattern": "^[0-9a-f]{16}$"
73
+ },
74
+ "parent_span_id": {
75
+ "type": ["string", "null"]
76
+ },
77
+ "name": { "type": "string", "minLength": 1 },
78
+ "kind": { "$ref": "#/$defs/SpanKind" },
79
+ "start_time": { "type": "string", "format": "date-time" },
80
+ "end_time": { "type": "string", "format": "date-time" },
81
+ "attributes": {
82
+ "type": "object",
83
+ "additionalProperties": true
84
+ },
85
+ "events": {
86
+ "type": "array",
87
+ "items": { "$ref": "#/$defs/SpanEvent" }
88
+ }
89
+ }
90
+ },
91
+ "SpanEvent": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "required": ["name", "timestamp"],
95
+ "properties": {
96
+ "name": { "type": "string" },
97
+ "timestamp": { "type": "string", "format": "date-time" },
98
+ "attributes": {
99
+ "type": "object",
100
+ "additionalProperties": true
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
loopotel/tracer.py ADDED
@@ -0,0 +1,235 @@
1
+ """LoopTracer — emit LTF spans for loop runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import time
7
+ from contextvars import ContextVar
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Callable
10
+
11
+ from loopotel import les as les_metrics
12
+ from loopotel.models import isoformat, new_span_id, new_trace_id, otel_attributes, spec_pins, utc_now
13
+
14
+ _current_tracer: ContextVar[LoopTracer | None] = ContextVar("loopotel_current_tracer", default=None)
15
+
16
+
17
+ def current_tracer() -> LoopTracer | None:
18
+ return _current_tracer.get()
19
+
20
+
21
+ def emit_iteration(
22
+ *,
23
+ iteration: int,
24
+ goal_score: float,
25
+ goal_target: float | None = None,
26
+ tokens_delta: int = 0,
27
+ cost_usd: float | None = None,
28
+ latency_ms: float | None = None,
29
+ worker_id: str | None = None,
30
+ evaluator_id: str | None = None,
31
+ ) -> None:
32
+ """Emit an iteration span on the active tracer (no-op if tracing disabled)."""
33
+ tracer = current_tracer()
34
+ if tracer is None or not tracer.enabled:
35
+ return
36
+ tracer.emit_iteration(
37
+ iteration=iteration,
38
+ goal_score=goal_score,
39
+ goal_target=goal_target,
40
+ tokens_delta=tokens_delta,
41
+ cost_usd=cost_usd,
42
+ latency_ms=latency_ms,
43
+ worker_id=worker_id,
44
+ evaluator_id=evaluator_id,
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class LoopTracer:
50
+ """Collect LTF spans for one loop run."""
51
+
52
+ loop_name: str
53
+ env_id: str | None = None
54
+ task_id: str | None = None
55
+ goal_target: float = les_metrics.DEFAULT_GOAL_TARGET
56
+ enabled: bool = True
57
+ trace_id: str = field(default_factory=new_trace_id)
58
+ started_at: Any = field(default_factory=utc_now)
59
+ ended_at: Any | None = None
60
+ outcome: str = "unknown"
61
+ termination_reason: str = ""
62
+ metadata: dict[str, Any] = field(default_factory=dict)
63
+ _root_span_id: str = field(default_factory=new_span_id)
64
+ _goal_scores: list[float] = field(default_factory=list)
65
+ _spans: list[dict[str, Any]] = field(default_factory=list)
66
+ _token_total: int = 0
67
+
68
+ def __enter__(self) -> LoopTracer:
69
+ if self.enabled:
70
+ _current_tracer.set(self)
71
+ self._spans.append(
72
+ {
73
+ "span_id": self._root_span_id,
74
+ "parent_span_id": None,
75
+ "name": "loop.run",
76
+ "kind": "loop",
77
+ "start_time": isoformat(self.started_at),
78
+ "attributes": otel_attributes(
79
+ loop_name=self.loop_name,
80
+ env_id=self.env_id,
81
+ task_id=self.task_id,
82
+ goal_target=self.goal_target,
83
+ ),
84
+ "events": [],
85
+ }
86
+ )
87
+ return self
88
+
89
+ def __exit__(self, exc_type, exc, tb) -> None:
90
+ if self.enabled:
91
+ self.finish(
92
+ outcome="failure" if exc else self.outcome,
93
+ termination_reason=self.termination_reason or ("error" if exc else ""),
94
+ )
95
+ _current_tracer.set(None)
96
+
97
+ def emit_iteration(
98
+ self,
99
+ *,
100
+ iteration: int,
101
+ goal_score: float,
102
+ goal_target: float | None = None,
103
+ tokens_delta: int = 0,
104
+ cost_usd: float | None = None,
105
+ latency_ms: float | None = None,
106
+ worker_id: str | None = None,
107
+ evaluator_id: str | None = None,
108
+ ) -> None:
109
+ if not self.enabled:
110
+ return
111
+ target = goal_target if goal_target is not None else self.goal_target
112
+ self._goal_scores.append(goal_score)
113
+ self._token_total += tokens_delta
114
+ eff = les_metrics.effectiveness(goal_score, target)
115
+ cumulative = les_metrics.cumulative_les(self._goal_scores, target)
116
+ now = utc_now()
117
+ span_id = new_span_id()
118
+ events: list[dict[str, Any]] = [
119
+ {
120
+ "name": "loop.iteration.start",
121
+ "timestamp": isoformat(now),
122
+ "attributes": {"loop.iteration": iteration},
123
+ }
124
+ ]
125
+ if worker_id:
126
+ events.append(
127
+ {
128
+ "name": "loop.worker.complete",
129
+ "timestamp": isoformat(now),
130
+ "attributes": {"loop.worker.id": worker_id},
131
+ }
132
+ )
133
+ if evaluator_id:
134
+ events.append(
135
+ {
136
+ "name": "loop.evaluator.complete",
137
+ "timestamp": isoformat(now),
138
+ "attributes": {"loop.evaluator.id": evaluator_id, "loop.goal_score": goal_score},
139
+ }
140
+ )
141
+ events.append(
142
+ {
143
+ "name": "loop.iteration.end",
144
+ "timestamp": isoformat(now),
145
+ "attributes": {"loop.iteration": iteration, "loop.goal_score": goal_score},
146
+ }
147
+ )
148
+ self._spans.append(
149
+ {
150
+ "span_id": span_id,
151
+ "parent_span_id": self._root_span_id,
152
+ "name": "loop.iteration",
153
+ "kind": "iteration",
154
+ "start_time": isoformat(now),
155
+ "end_time": isoformat(now),
156
+ "attributes": otel_attributes(
157
+ loop_name=self.loop_name,
158
+ env_id=self.env_id,
159
+ task_id=self.task_id,
160
+ iteration=iteration,
161
+ goal_score=round(goal_score, 4),
162
+ goal_target=target,
163
+ tokens_delta=tokens_delta,
164
+ cost_usd=cost_usd,
165
+ latency_ms=latency_ms,
166
+ les_cumulative=cumulative,
167
+ les_effectiveness=round(eff, 4),
168
+ worker_id=worker_id,
169
+ evaluator_id=evaluator_id,
170
+ ),
171
+ "events": events,
172
+ }
173
+ )
174
+
175
+ def finish(
176
+ self,
177
+ *,
178
+ outcome: str = "unknown",
179
+ termination_reason: str = "",
180
+ ) -> None:
181
+ if not self.enabled:
182
+ return
183
+ self.outcome = outcome
184
+ self.termination_reason = termination_reason
185
+ self.ended_at = utc_now()
186
+ if self._spans:
187
+ root = self._spans[0]
188
+ root["end_time"] = isoformat(self.ended_at)
189
+ root["attributes"].update(
190
+ otel_attributes(
191
+ loop_name=self.loop_name,
192
+ env_id=self.env_id,
193
+ task_id=self.task_id,
194
+ outcome=outcome,
195
+ termination_reason=termination_reason,
196
+ )
197
+ )
198
+ root["attributes"]["loop.tokens_total"] = self._token_total
199
+
200
+ def build_trace(self) -> dict[str, Any]:
201
+ if self.ended_at is None:
202
+ self.finish()
203
+ return {
204
+ "schema_version": "ltf/0.1",
205
+ "trace_id": self.trace_id,
206
+ "loop_name": self.loop_name,
207
+ "env_id": self.env_id,
208
+ "task_id": self.task_id,
209
+ "started_at": isoformat(self.started_at),
210
+ "ended_at": isoformat(self.ended_at or utc_now()),
211
+ "outcome": self.outcome,
212
+ "termination_reason": self.termination_reason,
213
+ "spec_pins": spec_pins(),
214
+ "spans": self._spans,
215
+ "metadata": dict(self.metadata),
216
+ }
217
+
218
+
219
+ def trace_loop(
220
+ *,
221
+ loop_name: str,
222
+ env_id: str | None = None,
223
+ enabled: bool = True,
224
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
225
+ """Decorator: wrap a function in a LoopTracer context."""
226
+
227
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
228
+ @functools.wraps(fn)
229
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
230
+ with LoopTracer(loop_name=loop_name, env_id=env_id, enabled=enabled):
231
+ return fn(*args, **kwargs)
232
+
233
+ return wrapper
234
+
235
+ return decorator
loopotel/validate.py ADDED
@@ -0,0 +1,32 @@
1
+ """Validate LTF documents against ltf-0.1.schema.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ def load_schema() -> dict[str, Any]:
10
+ bundled = Path(__file__).resolve().parent / "schemas" / "ltf-0.1.schema.json"
11
+ repo = Path(__file__).resolve().parents[2] / "specs" / "ltf-0.1.schema.json"
12
+ path = bundled if bundled.exists() else repo
13
+ with path.open(encoding="utf-8") as handle:
14
+ return json.load(handle)
15
+
16
+
17
+ def validate_trace(trace: dict[str, Any]) -> tuple[bool, list[str]]:
18
+ try:
19
+ import jsonschema
20
+ except ImportError as exc:
21
+ raise SystemExit("jsonschema required: pip install loopotel[dev]") from exc
22
+
23
+ schema = load_schema()
24
+ validator = jsonschema.Draft202012Validator(schema)
25
+ errors = sorted({f"{e.json_path}: {e.message}" for e in validator.iter_errors(trace)})
26
+ return len(errors) == 0, errors
27
+
28
+
29
+ def validate_file(path: Path) -> tuple[bool, list[str]]:
30
+ with path.open(encoding="utf-8") as handle:
31
+ trace = json.load(handle)
32
+ return validate_trace(trace)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: loopotel
3
+ Version: 0.1.0
4
+ Summary: Loop Trace Format (LTF) instrumentation and exporters for loop observability
5
+ Project-URL: Homepage, https://github.com/KanakMalpani/loop-observability
6
+ Project-URL: Repository, https://github.com/KanakMalpani/loop-observability
7
+ Project-URL: Loop Core Engineering, https://github.com/KanakMalpani/Loop-Core-Engineering
8
+ Project-URL: LoopGym, https://github.com/KanakMalpani/LoopGym
9
+ Author: Kanak Malpani
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: les,loop-engineering,ltf,observability,opentelemetry
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: jsonschema>=4.21
18
+ Provides-Extra: dev
19
+ Requires-Dist: loopgym>=0.1.0; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.4; extra == 'dev'
22
+ Provides-Extra: loopgym
23
+ Requires-Dist: loopgym>=0.1.0; extra == 'loopgym'
24
+ Provides-Extra: otlp
25
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27; extra == 'otlp'
26
+ Requires-Dist: opentelemetry-sdk>=1.27; extra == 'otlp'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Loop Observability
30
+
31
+ **Loop Trace Format (LTF)** and OpenTelemetry conventions for production loop monitoring.
32
+
33
+ SREs need spans for iterations, evaluators, token burn, and LES deltas — not raw chat logs. This repo defines the format and ships `loopotel`, a minimal Python instrumentation library.
34
+
35
+ [![CI](https://github.com/KanakMalpani/loop-observability/actions/workflows/test.yml/badge.svg)](https://github.com/KanakMalpani/loop-observability/actions/workflows/test.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/loopotel.svg)](https://pypi.org/project/loopotel/)
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install loopotel
42
+ pip install "loopotel[loopgym]" # LoopGym episode tracing
43
+ pip install "loopotel[otlp]" # OTLP export
44
+ ```
45
+
46
+ ## Quick start — trace a LoopGym run
47
+
48
+ ```python
49
+ import loopgym as lg
50
+ from loopotel.integrations.loopgym import run_traced_episode
51
+ from loopotel.exporter.jsonl import JsonlExporter
52
+
53
+ env = lg.make("loopbench/code-repair-v1")
54
+ result, trace = run_traced_episode(env, task_id="cr-001", seed=42, enabled=True)
55
+
56
+ JsonlExporter("traces.jsonl").export(trace)
57
+ print(result["success"], trace["trace_id"])
58
+ ```
59
+
60
+ Or run the example:
61
+
62
+ ```bash
63
+ pip install loopgym loopotel
64
+ python examples/export_loopgym_ltf.py
65
+ ```
66
+
67
+ ## API
68
+
69
+ ```python
70
+ from loopotel import LoopTracer, emit_iteration, trace_loop
71
+
72
+ with LoopTracer(loop_name="my-loop", env_id="prod/agent") as tracer:
73
+ emit_iteration(iteration=1, goal_score=0.55, tokens_delta=120,
74
+ worker_id="implementer", evaluator_id="rubric")
75
+ tracer.finish(outcome="success", termination_reason="goal_met")
76
+
77
+ trace = tracer.build_trace() # ltf/0.1 document
78
+ ```
79
+
80
+ ## Specs
81
+
82
+ | Document | Purpose |
83
+ |----------|---------|
84
+ | [`specs/ltf-0.1.schema.json`](specs/ltf-0.1.schema.json) | LTF JSON schema |
85
+ | [`specs/otel-semconv-loop.md`](specs/otel-semconv-loop.md) | `loop.*` OTel attributes |
86
+ | [`specs/les-timeseries.md`](specs/les-timeseries.md) | Point-in-loop LES metrics |
87
+
88
+ ## Grafana
89
+
90
+ Import [`examples/grafana-dashboard.json`](examples/grafana-dashboard.json) for iteration vs goal score, cumulative LES, and token burn panels (sample data included).
91
+
92
+ ## Validate
93
+
94
+ ```bash
95
+ loopotel-validate examples/sample-trace.jsonl
96
+ python scripts/validate_ltf.py path/to/trace.json
97
+ ```
98
+
99
+ ## Design
100
+
101
+ - **Minimal overhead** — tracing off by default; pass `enabled=True` for SimEnv, use `trace_live_episode()` for LiveEnv
102
+ - **Exporters** — JSONL (built-in), OTLP (optional), LoopNet trajectory mapping
103
+ - **Pins** — `lss@1.0.0`, `les@1.0.0`, `ltf@0.1.0`
104
+
105
+ ## Links
106
+
107
+ - [LoopNet end-to-end tutorial](https://github.com/KanakMalpani/loopnet/blob/main/guides/END-TO-END-TUTORIAL.md) — HF → replay → LoopBench
108
+ - [Loop Core Engineering](https://github.com/KanakMalpani/Loop-Core-Engineering) — LES / LSS
109
+ - [LoopGym](https://github.com/KanakMalpani/LoopGym) — instrumentation target
110
+ - [LoopNet](https://github.com/KanakMalpani/loopnet) — trajectory corpus export
111
+ - [Publishing](PUBLISHING.md) · [PyPI](https://pypi.org/project/loopotel/)
@@ -0,0 +1,18 @@
1
+ loopotel/__init__.py,sha256=9gYQAUK1IZPf8lOTivqWkC6Dy_ORi3I_EjJrpZm0cJE,258
2
+ loopotel/cli.py,sha256=U7EFR43OZC6CR5BLx-DPlBkxZDe5WXFh5ql6ulJwPpE,1232
3
+ loopotel/les.py,sha256=dszwqZRczUehACvmXUNFK-cx9_E6bnK0TeTSREmkI6A,560
4
+ loopotel/models.py,sha256=b2v2KlgpK5SUV5hBOZmK0Fkrk2PpZYdnTFyBHMLnLTA,2014
5
+ loopotel/tracer.py,sha256=JKxVV9odJ1NOoV44B5ypBJdXMds8ORG7TBmVSnz8N90,7862
6
+ loopotel/validate.py,sha256=og2kofmzMcgFABxIp7kNaidFKewd6F1XOV_Uz-7KQOI,1092
7
+ loopotel/exporter/__init__.py,sha256=O-WCrPSQmxX4WgoHcGW7brJUm_dIS47s8Oj0IUyWrJ4,188
8
+ loopotel/exporter/jsonl.py,sha256=7DczOf7LG9y7RICUyenLfO7HnbKQUV1-ennTYliCnQo,632
9
+ loopotel/exporter/loopnet.py,sha256=DFQ85wCLXF62vEzEbeusyByzfov4MJV3xggLmv7dMzA,1290
10
+ loopotel/exporter/otlp.py,sha256=bN00TdKgHOvR3jlis_B_1wVwn7FwZ8rWP7xbVfyqPM0,2367
11
+ loopotel/integrations/__init__.py,sha256=fZk5P9F8WBVVHVglKzVHk6XY2i9HN7L7hdlVulsk4FU,170
12
+ loopotel/integrations/loopgym.py,sha256=3sv5VZgpG0vwQwVxItZqZfWvAjm1s6ha7Odb7mrwbzQ,3730
13
+ loopotel/schemas/ltf-0.1.schema.json,sha256=_z1Lz2IrVTRO-i-sEDGM3LZTvAd_Hgm9Uq0JH91ZiwM,3026
14
+ loopotel-0.1.0.dist-info/METADATA,sha256=VONwjqQAYqkRvJ2hWliAz-Y7ArBk-DuYHsKGviTrKyU,4178
15
+ loopotel-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ loopotel-0.1.0.dist-info/entry_points.txt,sha256=5ulPBAoYxBQi-fZ_4aeVTOvI4g1P3VWroct2OK5K21k,56
17
+ loopotel-0.1.0.dist-info/licenses/LICENSE,sha256=evRYU4i8S6LPZ42e9jNkROkb-chKgbu-HyltWnYncvk,1069
18
+ loopotel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loopotel-validate = loopotel.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KanakMalpani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.