traceforge-toolkit 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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,297 @@
1
+ """Data-driven adapter for file-watch frameworks.
2
+
3
+ Parses JSON events using a declarative YAML mapping config. Each framework
4
+ gets a YAML file describing how to extract event type, timestamp, session ID,
5
+ and payload fields — no custom Python code needed per framework.
6
+
7
+ Frameworks with non-flat event schemas use a preprocessor that normalizes
8
+ raw dicts into a flat {type_field: value, ...} shape before YAML mapping.
9
+ Preprocessors live in the traceforge.preprocessors package.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import uuid
16
+ from collections.abc import Iterator
17
+ from datetime import datetime, timezone
18
+ from typing import Any, Literal
19
+
20
+ from pydantic import Field
21
+
22
+ from traceforge.adapters.base import JsonLineAdapter
23
+ from traceforge.models import StrictModel
24
+ from traceforge.preprocessors import get_preprocessor
25
+ from traceforge.types import EventKind, EventMetadata, IngestionMode, SessionEvent, ToolMotivation
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # ─── Dot-path access ─────────────────────────────────────────────────────────
31
+
32
+
33
+ def _resolve_path(obj: Any, path: str) -> Any:
34
+ """Resolve a dot-separated path against a nested dict/list.
35
+
36
+ Supports: "foo.bar", "foo.0.bar" (integer index), "foo" (top-level).
37
+ Returns None if any segment is missing.
38
+ """
39
+ current = obj
40
+ for segment in path.split("."):
41
+ if current is None:
42
+ return None
43
+ if isinstance(current, dict):
44
+ current = current.get(segment)
45
+ elif isinstance(current, (list, tuple)):
46
+ try:
47
+ current = current[int(segment)]
48
+ except (ValueError, IndexError):
49
+ return None
50
+ else:
51
+ return None
52
+ return current
53
+
54
+
55
+ # ─── Mapping Config ──────────────────────────────────────────────────────────
56
+
57
+
58
+ class EventMapping(StrictModel):
59
+ """Mapping for a single raw event type → canonical kind + payload extraction."""
60
+
61
+ kind: str # canonical EventKind string
62
+ payload: dict[str, str] = Field(default_factory=dict) # field_name → dot-path
63
+
64
+
65
+ class SpanMapping(StrictModel):
66
+ """Mapping for an OTel span name → canonical kind + attribute extraction."""
67
+
68
+ kind: str # canonical EventKind string
69
+ attributes: dict[str, str] = Field(default_factory=dict) # payload_field → otel_attr_key
70
+
71
+
72
+ class MotivationSource(StrictModel):
73
+ """A single source of motivation text from the event stream."""
74
+
75
+ events: list[str] # raw event type keys that carry this motivation
76
+ field: str = "content" # payload field (after mapping) containing the text
77
+ role: Literal["intent", "reasoning"] = "intent" # which slot this fills
78
+
79
+
80
+ class MotivationConfig(StrictModel):
81
+ """Declarative motivation tracking configuration for a framework."""
82
+
83
+ sources: list[MotivationSource] = Field(default_factory=list)
84
+ targets: list[str] = Field(default_factory=lambda: ["tool.call.started", "tool.call.completed"])
85
+ source_window: int = Field(default=10, ge=1) # max source_event_ids to keep (rolling window)
86
+
87
+
88
+ class FrameworkMapping(StrictModel):
89
+ """Declarative mapping config for a framework's JSON events."""
90
+
91
+ framework: str # e.g. "crewai", "openhands"
92
+ framework_version: str # version of the framework's event format this mapping targets
93
+ ingestion_mode: IngestionMode # must be explicit in YAML
94
+ type_field: str = "type" # dot-path to event type in raw JSON
95
+ timestamp_field: str | None = None # dot-path to timestamp
96
+ default_kind: str = EventKind.RAW # kind for unmapped event types
97
+ preprocessor: str | None = None # registered preprocessor name (optional)
98
+ events: dict[str, EventMapping] = Field(default_factory=dict) # raw_type → mapping
99
+ spans: dict[str, SpanMapping] = Field(default_factory=dict) # otel_span_name → mapping
100
+
101
+ # Motivation tracking config
102
+ motivation: MotivationConfig | None = None
103
+
104
+ def get_motivation_config(self) -> MotivationConfig:
105
+ """Return the effective MotivationConfig."""
106
+ if self.motivation is not None:
107
+ return self.motivation
108
+ return MotivationConfig()
109
+
110
+
111
+ # ─── Adapter ─────────────────────────────────────────────────────────────────
112
+
113
+
114
+ class MappedJsonAdapter(JsonLineAdapter):
115
+ """Generic adapter driven by a FrameworkMapping config.
116
+
117
+ Parses JSON lines using declarative dot-path extraction. Adding support
118
+ for a new framework requires only a YAML mapping file, not Python code.
119
+ """
120
+
121
+ def __init__(self, mapping: FrameworkMapping, session_id: str):
122
+ self._mapping = mapping
123
+ self._session_id = session_id
124
+ self._motivation_config = mapping.get_motivation_config()
125
+ # Build lookup: raw_type → list of (field, role) for quick matching
126
+ self._motivation_lookup: dict[str, list[tuple[str, str]]] = {}
127
+ for source in self._motivation_config.sources:
128
+ for event_type in source.events:
129
+ self._motivation_lookup.setdefault(event_type, []).append(
130
+ (source.field, source.role)
131
+ )
132
+ self._target_kinds = frozenset(self._motivation_config.targets)
133
+ # Accumulated motivation state
134
+ self._latest_intent: str | None = None
135
+ self._latest_reasoning: str | None = None
136
+ self._source_event_ids: list[str] = []
137
+
138
+ @property
139
+ def framework(self) -> str:
140
+ return self._mapping.framework
141
+
142
+ def parse_dict(self, obj: dict[str, Any]) -> Iterator[SessionEvent]:
143
+ """Extract event type, timestamp, session, payload from JSON dict.
144
+
145
+ If a preprocessor is registered for this framework, the raw dict is
146
+ first normalized into one or more flat dicts before mapping.
147
+ """
148
+ # Apply preprocessor if configured
149
+ preprocessor_name = self._mapping.preprocessor
150
+ if preprocessor_name:
151
+ preprocessor = get_preprocessor(preprocessor_name)
152
+ if preprocessor:
153
+ normalized_dicts = preprocessor(obj)
154
+ else:
155
+ normalized_dicts = [obj]
156
+ else:
157
+ normalized_dicts = [obj]
158
+
159
+ for norm_obj in normalized_dicts:
160
+ yield from self._map_single(norm_obj)
161
+
162
+ def _map_single(self, obj: dict[str, Any]) -> Iterator[SessionEvent]:
163
+ """Map a single normalized dict to a SessionEvent."""
164
+ # Extract event type
165
+ raw_type = _resolve_path(obj, self._mapping.type_field)
166
+ if raw_type is None:
167
+ raw_type = "unknown"
168
+ raw_type = str(raw_type)
169
+
170
+ # Resolve mapping
171
+ event_mapping = self._mapping.events.get(raw_type)
172
+ kind = event_mapping.kind if event_mapping else self._mapping.default_kind
173
+ if event_mapping is None and not kind:
174
+ return
175
+
176
+ # Extract timestamp
177
+ timestamp = datetime.now(timezone.utc)
178
+ if self._mapping.timestamp_field:
179
+ ts_raw = _resolve_path(obj, self._mapping.timestamp_field)
180
+ if ts_raw is not None:
181
+ timestamp = self._parse_timestamp(ts_raw)
182
+
183
+ # Extract payload
184
+ payload: dict[str, Any] = {}
185
+ if event_mapping and event_mapping.payload:
186
+ for field_name, dot_path in event_mapping.payload.items():
187
+ if dot_path.startswith("literal:"):
188
+ # Explicit literal value (not a path resolution)
189
+ value = dot_path[len("literal:") :]
190
+ else:
191
+ value = _resolve_path(obj, dot_path)
192
+ if value is not None:
193
+ payload[field_name] = value
194
+
195
+ # For unmapped or RAW events, note the original type
196
+ if kind == EventKind.RAW or not event_mapping:
197
+ payload["original_type"] = raw_type
198
+
199
+ # Generate event ID upfront (needed for source_event_ids tracking)
200
+ event_id = str(uuid.uuid4())
201
+
202
+ # Track motivation from designated source events
203
+ if raw_type in self._motivation_lookup:
204
+ seen_this_event = False
205
+ for field, role in self._motivation_lookup[raw_type]:
206
+ text = self._extract_text(payload, field)
207
+ if role == "intent":
208
+ self._latest_intent = text
209
+ else:
210
+ self._latest_reasoning = text
211
+ # Record source event ID once per raw event (not per role)
212
+ if not seen_this_event:
213
+ self._source_event_ids.append(event_id)
214
+ seen_this_event = True
215
+ # Enforce rolling window
216
+ if len(self._source_event_ids) > self._motivation_config.source_window:
217
+ self._source_event_ids = self._source_event_ids[
218
+ -self._motivation_config.source_window :
219
+ ]
220
+
221
+ # Build motivation for target events (None if no content in either slot)
222
+ motivation: ToolMotivation | None = None
223
+ if kind in self._target_kinds and (self._latest_intent or self._latest_reasoning):
224
+ motivation = ToolMotivation(
225
+ intent=self._latest_intent,
226
+ reasoning=self._latest_reasoning,
227
+ source_event_ids=tuple(self._source_event_ids),
228
+ )
229
+
230
+ metadata = EventMetadata(
231
+ source_framework=self._mapping.framework,
232
+ ingestion_mode=self._mapping.ingestion_mode,
233
+ raw_kind=raw_type,
234
+ motivation=motivation,
235
+ )
236
+
237
+ yield SessionEvent(
238
+ id=event_id,
239
+ kind=kind,
240
+ session_id=self._session_id,
241
+ timestamp=timestamp,
242
+ payload=payload,
243
+ metadata=metadata,
244
+ )
245
+
246
+ @staticmethod
247
+ def _extract_text(payload: dict[str, Any], field: str) -> str | None:
248
+ """Extract text from a payload field, handling list-type content."""
249
+ value = payload.get(field)
250
+ if value is None:
251
+ return None
252
+ # List-type content (e.g. Claude content blocks): join text items
253
+ if isinstance(value, list):
254
+ text_parts = [str(item) for item in value if item]
255
+ value = "\n".join(text_parts)
256
+ else:
257
+ value = str(value)
258
+ # Empty string → treat as None
259
+ if not value.strip():
260
+ return None
261
+ return value
262
+
263
+ @staticmethod
264
+ def _parse_timestamp(value: Any) -> datetime:
265
+ """Best-effort timestamp parsing."""
266
+ if isinstance(value, datetime):
267
+ return value
268
+ if isinstance(value, (int, float)):
269
+ try:
270
+ # Heuristic: >1e15 = nanoseconds, >1e12 = milliseconds
271
+ if value > 1e15:
272
+ value = value / 1_000_000_000
273
+ elif value > 1e12:
274
+ value = value / 1_000
275
+ return datetime.fromtimestamp(value, tz=timezone.utc)
276
+ except (OSError, OverflowError, ValueError):
277
+ return datetime.now(timezone.utc)
278
+ if isinstance(value, str):
279
+ # Try ISO format
280
+ try:
281
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
282
+ if dt.tzinfo is None:
283
+ dt = dt.replace(tzinfo=timezone.utc)
284
+ return dt
285
+ except ValueError:
286
+ pass
287
+ return datetime.now(timezone.utc)
288
+
289
+ @classmethod
290
+ def from_yaml(cls, yaml_path: str, session_id: str) -> "MappedJsonAdapter":
291
+ """Load a MappedJsonAdapter from a YAML mapping file."""
292
+ import yaml
293
+
294
+ with open(yaml_path) as f:
295
+ data = yaml.safe_load(f)
296
+ mapping = FrameworkMapping.model_validate(data)
297
+ return cls(mapping=mapping, session_id=session_id)
@@ -0,0 +1,220 @@
1
+ """Adapter for Microsoft 365 Agents SDK (MAF) OTel spans.
2
+
3
+ The Microsoft 365 Agents SDK emits OpenTelemetry spans, not JSON lines.
4
+ This adapter ingests exported OTLP span data (as JSON dicts) and maps
5
+ MAF span names to canonical traceforge event kinds.
6
+
7
+ Span-to-kind mapping and attribute extraction rules are loaded from
8
+ ``mappings/maf.yaml`` — no hardcoded dispatch tables.
9
+
10
+ Ingestion mode is always "stream" — spans are received from an OTel
11
+ exporter (e.g., InMemorySpanExporter or OTLP collector).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections.abc import Iterator
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+ from traceforge.adapters.base import Adapter
25
+ from traceforge.types import EventKind, EventMetadata, IngestionMode, SessionEvent
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ─── Load span mapping from YAML ─────────────────────────────────────────────
30
+
31
+ _MAPPINGS_DIR = Path(__file__).resolve().parent.parent / "mappings"
32
+
33
+
34
+ def _load_maf_mapping() -> tuple[dict[str, str], dict[str, dict[str, str]]]:
35
+ """Load span kind map and attribute extractors from maf.yaml."""
36
+ yaml_path = _MAPPINGS_DIR / "maf.yaml"
37
+ with open(yaml_path) as f:
38
+ data = yaml.safe_load(f)
39
+
40
+ span_kind_map: dict[str, str] = {}
41
+ attribute_extractors: dict[str, dict[str, str]] = {}
42
+
43
+ for span_name, config in data.get("spans", {}).items():
44
+ span_kind_map[span_name] = config["kind"]
45
+ attrs = config.get("attributes")
46
+ if attrs:
47
+ attribute_extractors[span_name] = attrs
48
+
49
+ return span_kind_map, attribute_extractors
50
+
51
+
52
+ _SPAN_KIND_MAP, _ATTRIBUTE_EXTRACTORS = _load_maf_mapping()
53
+
54
+ # OTel status codes
55
+ _STATUS_OK = 1
56
+ _STATUS_ERROR = 2
57
+
58
+
59
+ class OtelSpanAdapter(Adapter):
60
+ """Ingests OTel span dicts (exported from MAF) into canonical SessionEvents.
61
+
62
+ Accepts spans as dicts with standard OTel JSON export fields:
63
+ - name: span name
64
+ - start_time_unix_nano / end_time_unix_nano: timestamps
65
+ - status: {status_code: int}
66
+ - attributes: [{key, value}] or dict
67
+ - resource: {attributes: ...}
68
+
69
+ Works with both OTLP JSON export format and simplified dict format.
70
+ """
71
+
72
+ SOURCE_FRAMEWORK = "maf"
73
+
74
+ def __init__(self, ingestion_mode: IngestionMode, session_id: str) -> None:
75
+ self._session_id = session_id
76
+ self._ingestion_mode = ingestion_mode
77
+
78
+ def parse_span(self, span: dict[str, Any]) -> Iterator[SessionEvent]:
79
+ """Convert a single OTel span dict to SessionEvent(s).
80
+
81
+ Handles both snake_case (simplified) and camelCase (OTLP JSON) keys.
82
+ Never raises — logs and skips malformed spans.
83
+ """
84
+ try:
85
+ yield from self._parse_span_inner(span)
86
+ except Exception as exc:
87
+ logger.debug("OtelSpanAdapter: skipping malformed span: %s", exc)
88
+
89
+ def _parse_span_inner(self, span: dict[str, Any]) -> Iterator[SessionEvent]:
90
+ span_name = span.get("name", "")
91
+ if not span_name:
92
+ return
93
+
94
+ kind = _SPAN_KIND_MAP.get(span_name, EventKind.RAW)
95
+ attributes = _normalize_attributes(span.get("attributes", {}))
96
+ status = span.get("status", {})
97
+ status_code = (
98
+ status.get("status_code", _STATUS_OK) if isinstance(status, dict) else _STATUS_OK
99
+ )
100
+
101
+ # Timestamps — support both snake_case and camelCase keys, coerce strings
102
+ start_ns = (
103
+ span.get("start_time_unix_nano")
104
+ or span.get("startTimeUnixNano")
105
+ or span.get("start_time")
106
+ )
107
+ end_ns = (
108
+ span.get("end_time_unix_nano") or span.get("endTimeUnixNano") or span.get("end_time")
109
+ )
110
+ if start_ns is not None:
111
+ start_ns = int(start_ns)
112
+ if end_ns is not None:
113
+ end_ns = int(end_ns)
114
+
115
+ start_time = _ns_to_datetime(start_ns) if start_ns else datetime.now(timezone.utc)
116
+ end_time = _ns_to_datetime(end_ns) if end_ns else start_time
117
+
118
+ duration_ms: float | None = None
119
+ if start_ns and end_ns:
120
+ duration_ms = max(0.0, (end_ns - start_ns) / 1_000_000)
121
+
122
+ # Determine if this is an error
123
+ is_error = status_code == _STATUS_ERROR
124
+ if is_error:
125
+ kind = EventKind.ERROR
126
+
127
+ # Build payload from span attributes
128
+ payload = self._build_payload(span_name, attributes, duration_ms, is_error, status)
129
+
130
+ metadata = EventMetadata(
131
+ source_framework=self.SOURCE_FRAMEWORK,
132
+ ingestion_mode=self._ingestion_mode,
133
+ raw_kind=span_name,
134
+ duration_ms=duration_ms,
135
+ )
136
+
137
+ yield SessionEvent(
138
+ kind=kind,
139
+ session_id=self._session_id,
140
+ timestamp=end_time,
141
+ payload=payload,
142
+ metadata=metadata,
143
+ raw_event=span,
144
+ )
145
+
146
+ def parse(self, raw: str) -> Iterator[SessionEvent]:
147
+ """Parse a JSON string containing an OTel span."""
148
+ import json
149
+
150
+ try:
151
+ span = json.loads(raw)
152
+ except (json.JSONDecodeError, TypeError) as exc:
153
+ logger.debug("OtelSpanAdapter: JSON parse failed: %s", exc)
154
+ return
155
+
156
+ if isinstance(span, dict):
157
+ yield from self.parse_span(span)
158
+ elif isinstance(span, list):
159
+ for item in span:
160
+ if isinstance(item, dict):
161
+ yield from self.parse_span(item)
162
+
163
+ def _build_payload(
164
+ self,
165
+ span_name: str,
166
+ attributes: dict[str, Any],
167
+ duration_ms: float | None,
168
+ is_error: bool,
169
+ status: dict[str, Any],
170
+ ) -> dict[str, Any]:
171
+ """Build event payload from OTel span attributes."""
172
+ payload: dict[str, Any] = {}
173
+
174
+ # Extract known MAF attributes
175
+ attr_map = _ATTRIBUTE_EXTRACTORS.get(span_name)
176
+ if attr_map:
177
+ for payload_key, attr_key in attr_map.items():
178
+ val = attributes.get(attr_key)
179
+ if val is not None:
180
+ payload[payload_key] = val
181
+
182
+ if is_error:
183
+ payload["message"] = status.get("message", "Unknown error")
184
+
185
+ if duration_ms is not None:
186
+ payload["duration_ms"] = duration_ms
187
+
188
+ # For unmapped spans, always include the span name
189
+ if span_name not in _SPAN_KIND_MAP:
190
+ payload["original_type"] = span_name
191
+
192
+ return payload
193
+
194
+
195
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
196
+
197
+
198
+ def _ns_to_datetime(ns: int | float) -> datetime:
199
+ """Convert nanosecond unix timestamp to datetime."""
200
+ return datetime.fromtimestamp(ns / 1_000_000_000, tz=timezone.utc)
201
+
202
+
203
+ def _normalize_attributes(attrs: Any) -> dict[str, Any]:
204
+ """Normalize OTel attributes from either list-of-dicts or flat dict format."""
205
+ if isinstance(attrs, dict):
206
+ return attrs
207
+ if isinstance(attrs, list):
208
+ result: dict[str, Any] = {}
209
+ for item in attrs:
210
+ if isinstance(item, dict) and "key" in item:
211
+ val = item.get("value", {})
212
+ if isinstance(val, dict):
213
+ # OTel proto format: value is {stringValue: x} or {intValue: x}
214
+ for v in val.values():
215
+ result[item["key"]] = v
216
+ break
217
+ else:
218
+ result[item["key"]] = val
219
+ return result
220
+ return {}
@@ -0,0 +1,46 @@
1
+ """Per-gap activity/step boundary classifier (production-loadable).
2
+
3
+ Mirrors :mod:`traceforge.phase` but for the *segmentation* task: each gap between
4
+ two consecutive events is labelled ``noise`` / ``activity-boundary`` /
5
+ ``step-boundary``. The featuriser is shared with the phase classifier
6
+ (``traceforge.phase.features``) so there is no train/serve skew, and every
7
+ feature is **causal** — a gap after event ``t`` is decided once ``t+1`` has
8
+ arrived, using only trailing-window segmentation state.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .decode import DecodeParams, StreamingBoundaryDecoder, decode_scores
14
+ from .features import GapExample, build_gap_example, featurize_session_gaps
15
+ from .inference import (
16
+ BOUNDARY_CLASSES,
17
+ DEFAULT_FEATURE_SET,
18
+ BoundaryModel,
19
+ decode_examples,
20
+ fit_boundary_model,
21
+ load,
22
+ predict_examples,
23
+ predict_session,
24
+ save,
25
+ )
26
+ from .inferencer import BoundaryInferencer, SessionBoundaryStream
27
+
28
+ __all__ = [
29
+ "BOUNDARY_CLASSES",
30
+ "DEFAULT_FEATURE_SET",
31
+ "BoundaryInferencer",
32
+ "BoundaryModel",
33
+ "DecodeParams",
34
+ "GapExample",
35
+ "SessionBoundaryStream",
36
+ "StreamingBoundaryDecoder",
37
+ "build_gap_example",
38
+ "decode_examples",
39
+ "decode_scores",
40
+ "featurize_session_gaps",
41
+ "fit_boundary_model",
42
+ "load",
43
+ "predict_examples",
44
+ "predict_session",
45
+ "save",
46
+ ]
@@ -0,0 +1,87 @@
1
+ """Causal, streamable decoding of per-gap boundary scores into a session TOC.
2
+
3
+ The classifier (:mod:`traceforge.boundary.inference`) emits per-gap class scores.
4
+ Taking ``argmax`` over them floods false positives (boundaries are 1-4% of gaps,
5
+ and the balanced estimator over-predicts the minority classes) and clusters
6
+ several predictions around each true boundary. This module turns scores into
7
+ labels with two data-derived levers, both applied **causally** so the same code
8
+ runs live as events stream:
9
+
10
+ * per-class **threshold** ``t_c`` (learned as the F1-optimal point of the
11
+ precision-recall curve at persist time) instead of ``argmax``; and
12
+ * a per-class **refractory min-gap** ``g_c`` (learned from the gold spacing
13
+ distribution) that suppresses a fresh boundary of class ``c`` within ``g_c``
14
+ events of the previously emitted one — the streamable analogue of non-max
15
+ suppression.
16
+
17
+ The decoder is O(1) per gap and holds only one integer per class (the index of
18
+ the last emitted boundary), so it adds no measurable CPU/memory footprint over
19
+ the existing per-gap inference.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+
26
+ import numpy as np
27
+
28
+ #: Coarser-first priority: at a gap that clears multiple thresholds, the coarser
29
+ #: class claims it (and its refractory decides emit-vs-noise).
30
+ DEFAULT_PRIORITY: tuple[str, ...] = ("activity-boundary", "step-boundary")
31
+
32
+
33
+ @dataclass
34
+ class DecodeParams:
35
+ """Learned decode levers, persisted in the model bundle.
36
+
37
+ ``thresholds`` and ``min_gaps`` are keyed by non-``noise`` class. A class
38
+ absent from ``thresholds`` falls back to never firing on threshold; a class
39
+ absent from ``min_gaps`` uses a refractory of 1 (no suppression).
40
+ """
41
+
42
+ thresholds: dict[str, float] = field(default_factory=dict)
43
+ min_gaps: dict[str, int] = field(default_factory=dict)
44
+ priority: tuple[str, ...] = DEFAULT_PRIORITY
45
+
46
+
47
+ class StreamingBoundaryDecoder:
48
+ """Stateful causal decoder. Feed per-gap scores in seq order via :meth:`push`.
49
+
50
+ Holds only ``i`` (the current gap index) and the last-emitted index per
51
+ class, so it is safe to run indefinitely on a live event stream.
52
+ """
53
+
54
+ def __init__(self, params: DecodeParams) -> None:
55
+ self._p = params
56
+ self._i = -1
57
+ self._last: dict[str, int] = {c: -(10**9) for c in params.priority}
58
+
59
+ def push(self, scores: dict[str, float]) -> str:
60
+ """Return the decoded label for the next gap given its class scores."""
61
+ self._i += 1
62
+ for c in self._p.priority:
63
+ t = self._p.thresholds.get(c)
64
+ if t is None or scores.get(c, 0.0) < t:
65
+ continue
66
+ # This class claims the gap; the refractory decides emit vs. noise so
67
+ # a suppressed coarser boundary is never relabelled as a finer one.
68
+ if self._i - self._last[c] >= self._p.min_gaps.get(c, 1):
69
+ self._last[c] = self._i
70
+ return c
71
+ return "noise"
72
+ return "noise"
73
+
74
+
75
+ def decode_scores(params: DecodeParams, classes: tuple[str, ...], scores: np.ndarray) -> list[str]:
76
+ """Decode a session's per-gap score matrix (``n_gaps x n_classes``, aligned
77
+ to ``classes``) into labels, causally and in order."""
78
+ dec = StreamingBoundaryDecoder(params)
79
+ idx = {c: i for i, c in enumerate(classes)}
80
+ cols = {c: idx[c] for c in params.priority if c in idx}
81
+ out: list[str] = []
82
+ for row in scores:
83
+ out.append(dec.push({c: float(row[j]) for c, j in cols.items()}))
84
+ return out
85
+
86
+
87
+ __all__ = ["DEFAULT_PRIORITY", "DecodeParams", "StreamingBoundaryDecoder", "decode_scores"]