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,107 @@
1
+ """Project a :class:`SessionEvent` onto the stable feature-row schema.
2
+
3
+ This is the single train/serve contract. The labelling corpus that the phase
4
+ classifier trains on is produced by running the production pipeline and writing
5
+ enriched events through :class:`traceforge.sinks.parquet.ParquetSink`, which
6
+ serialises each event via :func:`event_to_feature_row`. Runtime inference
7
+ projects live events through the **same** function, so there is no train/serve
8
+ skew by construction.
9
+
10
+ The :class:`ParquetSink` imports these helpers, so the projection lives in
11
+ exactly one place.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ from traceforge.types import SessionEvent
20
+
21
+
22
+ def enum_value(v: Any) -> str | None:
23
+ """Coerce StrEnum / Enum / str / None to a plain string."""
24
+ if v is None:
25
+ return None
26
+ return v.value if hasattr(v, "value") and not isinstance(v, str) else str(v)
27
+
28
+
29
+ def json_default(o: Any) -> Any:
30
+ """JSON encoder fallback for the few stdlib types pydantic's ``mode='json'``
31
+ doesn't already cover when serializing raw payloads."""
32
+ if isinstance(o, (frozenset, set)):
33
+ return sorted(o)
34
+ if isinstance(o, tuple):
35
+ return list(o)
36
+ if hasattr(o, "value"): # StrEnum / Enum
37
+ return o.value
38
+ if hasattr(o, "isoformat"): # datetime / date
39
+ return o.isoformat()
40
+ raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
41
+
42
+
43
+ def event_to_feature_row(event: SessionEvent, seq: int) -> dict[str, Any]:
44
+ """Project a SessionEvent onto the stable schema columns.
45
+
46
+ Pulls from the canonical types (``Classification.to_dict``,
47
+ ``EventMetadata.model_dump(mode="json")``) instead of re-implementing field
48
+ extraction. Frozenset-backed dimensions become sorted lists; all JSON
49
+ serialization goes through pydantic's json mode so frozensets, enums, and
50
+ tuples are handled natively.
51
+ """
52
+ payload = event.payload or {}
53
+ tool_name = payload.get("tool_name") if isinstance(payload, dict) else None
54
+ duration_ms = payload.get("duration_ms") if isinstance(payload, dict) else None
55
+
56
+ # Empty defaults for every list column so pyarrow infers the right type
57
+ # even when classification is absent.
58
+ row: dict[str, Any] = {
59
+ "event_id": event.id,
60
+ "session_id": event.session_id,
61
+ "kind": event.kind,
62
+ "timestamp_ns": event.timestamp,
63
+ "seq": seq,
64
+ "tool_name": enum_value(tool_name),
65
+ "mechanism": None,
66
+ "effect": None,
67
+ "scope": [],
68
+ "role": [],
69
+ "action": [],
70
+ "capability": [],
71
+ "structure": [],
72
+ "source_labels": [],
73
+ "shell_dialect": None,
74
+ "binaries": [],
75
+ "phase_signals": [],
76
+ "motivation": None,
77
+ "payload_json": None,
78
+ "metadata_json": None,
79
+ "duration_ms": int(duration_ms) if isinstance(duration_ms, (int, float)) else None,
80
+ }
81
+
82
+ md = event.metadata
83
+ if md is not None:
84
+ cls = md.classification
85
+ if cls is not None:
86
+ cls_dict = cls.to_dict()
87
+ row["mechanism"] = cls_dict.get("mechanism")
88
+ row["effect"] = cls_dict.get("effect")
89
+ row["scope"] = list(cls_dict.get("scope") or ())
90
+ row["role"] = list(cls_dict.get("role") or ())
91
+ row["action"] = list(cls_dict.get("action") or ())
92
+ row["capability"] = list(cls_dict.get("capability") or ())
93
+ row["structure"] = list(cls_dict.get("structure") or ())
94
+ row["source_labels"] = list(cls_dict.get("source_labels") or ())
95
+ row["shell_dialect"] = cls_dict.get("shell_dialect")
96
+ row["binaries"] = list(cls_dict.get("binaries") or ())
97
+
98
+ if md.phases:
99
+ row["phase_signals"] = sorted(enum_value(p) for p in md.phases if p is not None)
100
+
101
+ row["motivation"] = md.motivation.intent if md.motivation else None
102
+ row["metadata_json"] = json.dumps(md.model_dump(mode="json", exclude_none=True))
103
+
104
+ if payload:
105
+ row["payload_json"] = json.dumps(payload, default=json_default)
106
+
107
+ return row
@@ -0,0 +1,468 @@
1
+ """Portable per-event feature extraction for the phase classifier.
2
+
3
+ Implements the feature design in ``research/docs/03-feature-design.md``. Every
4
+ feature is portable across agent frameworks by construction. Three sources only:
5
+
6
+ 1. Canonical classification fields from traceforge's enricher (symbolic dicts).
7
+ 2. A static, framework-agnostic text embedding (model2vec) of the event payload.
8
+ 3. Position / timing features derived from (1).
9
+
10
+ The functions here operate on the **feature-row dict** produced by
11
+ :func:`traceforge.phase.event_rows.event_to_feature_row` — the same projection
12
+ the labelling corpus was built from — so training and inference featurise
13
+ identically. The model2vec embedding is frozen (no fitting); the symbolic
14
+ features are emitted as ``dict[str, float]`` for a :class:`DictVectorizer`.
15
+
16
+ ``model2vec`` (text embedding) and ``scikit-learn`` (vectorisation) are core
17
+ dependencies; they are imported lazily so importing this module stays cheap.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from collections import deque
24
+ from dataclasses import dataclass, field
25
+ from functools import lru_cache
26
+ from pathlib import Path
27
+ from typing import Iterable, Sequence
28
+
29
+ import numpy as np
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Constants (logged as MLflow params by callers; model choice, not a threshold)
33
+ # ---------------------------------------------------------------------------
34
+
35
+ #: Static distilled embedder. 256-d, CPU-only, framework-agnostic. See
36
+ #: research/docs/03-feature-design.md Block 5.
37
+ MODEL2VEC_NAME = "minishlab/potion-base-8M"
38
+
39
+ #: The embedder is VENDORED into the package (``phase/data/potion-base-8M/``) and
40
+ #: loaded from disk, so the live path makes zero network calls -- a Hub fetch on
41
+ #: the serving loop would violate the CPU-only / zero-footprint contract. The
42
+ #: vendored copy was produced with ``StaticModel.from_pretrained(MODEL2VEC_NAME)``
43
+ #: then ``.save_pretrained(MODEL2VEC_DIR)``; refresh it the same way if the model
44
+ #: is ever bumped.
45
+ MODEL2VEC_DIR = Path(__file__).resolve().parent / "data" / "potion-base-8M"
46
+ MODEL2VEC_DIM = 256
47
+
48
+ #: Payload text is truncated before embedding. model2vec mean-pools tokens, so
49
+ #: this only bounds tokenisation cost; it is not a semantic cutoff.
50
+ MAX_TEXT_CHARS = 2000
51
+
52
+ #: Gated phase vocabulary. ``review`` is intentionally excluded and folded into
53
+ #: ``verification`` (surfaced separately as the ``is_review`` modifier).
54
+ PHASES: tuple[str, ...] = (
55
+ "planning",
56
+ "implementation",
57
+ "verification",
58
+ "exploration",
59
+ )
60
+
61
+ #: The raw label demoted to a modifier and remapped onto a gated phase.
62
+ REVIEW_MODIFIER = "review"
63
+ REVIEW_REMAPS_TO = "verification"
64
+
65
+ # Canonical event fields fed to the symbolic featuriser.
66
+ _SCALAR_FIELDS = ("kind", "tool_name", "mechanism", "effect", "shell_dialect")
67
+ _LIST_FIELDS = (
68
+ "scope",
69
+ "role",
70
+ "action",
71
+ "capability",
72
+ "structure",
73
+ "phase_signals",
74
+ )
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Feature-set composition (single source of truth, shared by CV + inference)
78
+ # ---------------------------------------------------------------------------
79
+
80
+ #: Feature-set identifiers understood by the matrix builders.
81
+ FEATURE_SETS = (
82
+ "symbolic",
83
+ "embedding",
84
+ "combined",
85
+ "combined-seg",
86
+ "combined-seg-nbrcos",
87
+ "combined-seg-nbrcentroid",
88
+ "combined-seg-nbr",
89
+ )
90
+
91
+ #: Which neighbor (Block 5b) feature-key prefixes each feature-set includes.
92
+ _NBR_PREFIXES: dict[str, tuple[str, ...]] = {
93
+ "combined-seg-nbrcos": ("nbr_cos_",),
94
+ "combined-seg-nbrcentroid": ("nbr_centroid_",),
95
+ "combined-seg-nbr": ("nbr_cos_", "nbr_centroid_"),
96
+ }
97
+ _SYMBOLIC_SETS = frozenset(FEATURE_SETS) - {"embedding"}
98
+ _EMBEDDING_SETS = frozenset(FEATURE_SETS) - {"symbolic"}
99
+ _SEG_SETS = frozenset(s for s in FEATURE_SETS if s.startswith("combined-seg"))
100
+
101
+
102
+ def feature_set_blocks(feature_set: str) -> tuple[bool, bool, bool, tuple[str, ...]]:
103
+ """Resolve a feature-set id into ``(use_symbolic, use_embedding, use_seg,
104
+ nbr_prefixes)`` — the single source of truth shared by cross-validation and
105
+ the fit-on-all persistence/inference path."""
106
+
107
+ return (
108
+ feature_set in _SYMBOLIC_SETS,
109
+ feature_set in _EMBEDDING_SETS,
110
+ feature_set in _SEG_SETS,
111
+ _NBR_PREFIXES.get(feature_set, ()),
112
+ )
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class NeighborParams:
117
+ """Windows for the neighbor model2vec similarity features (from YAML)."""
118
+
119
+ windows: tuple[int, ...]
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class EventExample:
124
+ """One event, featurised and ready for vectorisation."""
125
+
126
+ session_id: str
127
+ source: str
128
+ event_id: str
129
+ symbolic: dict[str, float]
130
+ numeric: dict[str, float]
131
+ text: str
132
+ phase_signals: tuple[str, ...]
133
+ phases: tuple[str, ...] = () # phase task target (gated 4-class); empty at inference
134
+ is_review: bool = False
135
+ seg: dict[str, float] = field(default_factory=dict) # Block 6 segmentation
136
+ nbr: dict[str, float] = field(default_factory=dict) # Block 5b neighbor sim
137
+ content_bearing: bool = True # phase-decision target? plumbing inherits instead
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Per-event featurisers (operate on the event-row dict)
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def _extract_text(event: dict) -> str:
146
+ """Concatenate the human-meaningful text on an event.
147
+
148
+ Uses ``motivation`` (the agent's stated intent) plus any string values in
149
+ ``payload_json`` (tool output, command, error text). Framework-agnostic.
150
+ """
151
+
152
+ parts: list[str] = []
153
+ motivation = event.get("motivation")
154
+ if motivation:
155
+ parts.append(str(motivation))
156
+ payload = event.get("payload_json")
157
+ if payload:
158
+ try:
159
+ obj = json.loads(payload)
160
+ parts.extend(_iter_strings(obj))
161
+ except (json.JSONDecodeError, TypeError):
162
+ parts.append(str(payload))
163
+ text = " ".join(p for p in parts if p).strip()
164
+ return text[:MAX_TEXT_CHARS]
165
+
166
+
167
+ def _iter_strings(obj: object) -> Iterable[str]:
168
+ if isinstance(obj, str):
169
+ yield obj
170
+ elif isinstance(obj, dict):
171
+ for v in obj.values():
172
+ yield from _iter_strings(v)
173
+ elif isinstance(obj, (list, tuple)):
174
+ for v in obj:
175
+ yield from _iter_strings(v)
176
+
177
+
178
+ #: Canonical (framework-normalised) event-kind prefixes that carry a workflow
179
+ #: phase. Everything else — session/turn/hook lifecycle markers, agent spawn
180
+ #: bookkeeping, permission/telemetry plumbing — has no intrinsic phase and is
181
+ #: made to *inherit* the prevailing phase rather than be classified. Adapters
182
+ #: normalise raw framework types onto these canonical kinds, so the predicate is
183
+ #: framework-agnostic by construction.
184
+ _CONTENT_KIND_PREFIXES = (
185
+ "message.",
186
+ "tool.call.",
187
+ "tool.result.",
188
+ "tool.output",
189
+ "reasoning.",
190
+ "planning.",
191
+ )
192
+
193
+
194
+ def is_content_bearing(event: dict) -> bool:
195
+ """Whether an event is a phase-*decision* target.
196
+
197
+ Plumbing events (lifecycle/turn/hook/permission markers) carry no workflow
198
+ phase: in the labelling corpus they were uniformly stamped ``planning`` and
199
+ they expose no separable features (empty text, framework-specific kind), so
200
+ classifying them independently only echoes the majority prior. They instead
201
+ inherit the prevailing content-bearing phase. An event is content-bearing if
202
+ its canonical kind is a message/tool/reasoning kind, or — as a fallback for
203
+ unmapped/raw events — it carries a tool action or a canonical classification.
204
+ """
205
+
206
+ kind = str(event.get("kind") or "")
207
+ if kind.startswith(_CONTENT_KIND_PREFIXES):
208
+ return True
209
+ return bool(event.get("tool_name") or event.get("action") or event.get("mechanism"))
210
+
211
+
212
+ def _symbolic_dict(event: dict) -> dict[str, float]:
213
+ """Canonical classification one-hots / multi-hots (Blocks 1-3)."""
214
+
215
+ feats: dict[str, float] = {}
216
+ for f in _SCALAR_FIELDS:
217
+ v = event.get(f)
218
+ if v:
219
+ feats[f"{f}={v}"] = 1.0
220
+ for f in _LIST_FIELDS:
221
+ for v in event.get(f) or ():
222
+ feats[f"{f}={v}"] = 1.0
223
+ # Coarse activity generalisation: the root of each canonical action
224
+ # (e.g. modify.edit -> modify, persist.commit -> persist) so the model can
225
+ # back off from a sparse fine-grained action to its activity class. Derived
226
+ # from the already-populated ``action`` set (the general, framework-agnostic
227
+ # taxonomy), not a separate column.
228
+ for a in event.get("action") or ():
229
+ feats[f"activity_root={str(a).split('.', 1)[0]}"] = 1.0
230
+ return feats
231
+
232
+
233
+ def _numeric_dict(event: dict, position: float | None = None) -> dict[str, float]:
234
+ """Timing features (Block 4), all derived from canonical fields.
235
+
236
+ ``position`` (``seq / n``, normalised by *total* session length) is
237
+ intentionally omitted from the phase path: it is the only non-causal
238
+ feature — ``n`` is unknowable mid-stream, so the streaming serve path
239
+ could never reproduce it without skew. It is still emitted for the
240
+ separate boundary system, which passes an explicit ``position``.
241
+ """
242
+
243
+ feats: dict[str, float] = {}
244
+ if position is not None:
245
+ feats["position_in_session"] = position
246
+ dur = event.get("duration_ms")
247
+ if dur:
248
+ feats["log_duration_ms"] = float(np.log1p(max(0, int(dur))))
249
+ return feats
250
+
251
+
252
+ def _l2norm(v: np.ndarray) -> np.ndarray:
253
+ n = float(np.linalg.norm(v))
254
+ return v / n if n > 0 else v
255
+
256
+
257
+ def _neighbor_features(
258
+ ordered_ids: list[str], ordered_texts: list[str], windows: tuple[int, ...]
259
+ ) -> dict[str, dict[str, float]]:
260
+ """Windowed neighbor model2vec similarity features (Block 5b, docs/03 §5).
261
+
262
+ For each event and window ``w`` we emit two scalars:
263
+
264
+ * ``nbr_cos_w{w}`` — cosine between the mean-pooled past-window centroid
265
+ and the mean-pooled future-window centroid (semantic shift). 1.0 at edges.
266
+ **Non-causal** (uses the future window).
267
+ * ``nbr_centroid_w{w}`` — 1 - cosine between the event embedding and its
268
+ trailing-window centroid (drift from recent context). **Causal**.
269
+
270
+ The production contract (``combined-seg-nbrcentroid``) uses only the causal
271
+ ``nbr_centroid_*`` family.
272
+ """
273
+
274
+ vecs = embed_texts(ordered_texts)
275
+ n = len(vecs)
276
+ out: dict[str, dict[str, float]] = {}
277
+ for i, eid in enumerate(ordered_ids):
278
+ ev_unit = _l2norm(vecs[i])
279
+ d: dict[str, float] = {}
280
+ for w in windows:
281
+ past = vecs[max(0, i - w) : i]
282
+ fut = vecs[i : min(n, i + w)]
283
+ if len(past) and len(fut):
284
+ pc = _l2norm(past.mean(axis=0))
285
+ fc = _l2norm(fut.mean(axis=0))
286
+ d[f"nbr_cos_w{w}"] = float(np.dot(pc, fc))
287
+ else:
288
+ d[f"nbr_cos_w{w}"] = 1.0
289
+ trail = vecs[max(0, i - w + 1) : i + 1]
290
+ tc = _l2norm(trail.mean(axis=0))
291
+ d[f"nbr_centroid_w{w}"] = float(1.0 - np.dot(ev_unit, tc))
292
+ out[eid] = d
293
+ return out
294
+
295
+
296
+ class IncrementalNeighbor:
297
+ """Online, exactly-equivalent form of the causal ``nbr_centroid_*`` family.
298
+
299
+ Maintains the last ``max(windows)`` event embeddings and, per :meth:`push`,
300
+ returns the same ``nbr_centroid_w{w}`` (1 - cosine to the trailing-window
301
+ centroid) that :func:`_neighbor_features` computes for that event. Only the
302
+ causal centroid family is produced — the non-causal ``nbr_cos_*`` (which
303
+ needs the future window) is not part of the production contract.
304
+ """
305
+
306
+ def __init__(self, windows: tuple[int, ...]) -> None:
307
+ self._windows = windows
308
+ self._buf: deque[np.ndarray] = deque(maxlen=max(windows, default=1))
309
+
310
+ def push(self, text: str) -> dict[str, float]:
311
+ v = embed_texts([text])[0]
312
+ self._buf.append(v)
313
+ ev_unit = _l2norm(v)
314
+ recent = list(self._buf)
315
+ d: dict[str, float] = {}
316
+ for w in self._windows:
317
+ trail = np.asarray(recent[-w:])
318
+ tc = _l2norm(trail.mean(axis=0))
319
+ d[f"nbr_centroid_w{w}"] = float(1.0 - np.dot(ev_unit, tc))
320
+ return d
321
+
322
+
323
+ class StreamingSessionFeaturizer:
324
+ """Featurise one session **event by event**, exactly as the batch path.
325
+
326
+ Drives :class:`~traceforge.phase.segmentation.IncrementalSegmentation` and
327
+ :class:`IncrementalNeighbor` so each :meth:`push` returns the
328
+ :class:`EventExample` that :func:`featurize_session_events` would emit for
329
+ that event over the full prefix — but online and in bounded state, so phases
330
+ can be stamped live as events arrive instead of at the session boundary.
331
+ """
332
+
333
+ def __init__(self, session_id: str, source: str, seg_params, neighbor_params) -> None:
334
+ from .segmentation import IncrementalSegmentation
335
+
336
+ self._session_id = session_id
337
+ self._source = source
338
+ self._seg = IncrementalSegmentation(seg_params) if seg_params is not None else None
339
+ self._nbr = (
340
+ IncrementalNeighbor(neighbor_params.windows) if neighbor_params is not None else None
341
+ )
342
+
343
+ def push(self, event_id: str, event: dict) -> EventExample:
344
+ text = _extract_text(event)
345
+ seg = self._seg.push(event.get("phase_signals")) if self._seg is not None else {}
346
+ nbr = self._nbr.push(text) if self._nbr is not None else {}
347
+ return EventExample(
348
+ session_id=self._session_id,
349
+ source=self._source,
350
+ event_id=event_id,
351
+ symbolic=_symbolic_dict(event),
352
+ numeric=_numeric_dict(event),
353
+ text=text,
354
+ phase_signals=tuple(event.get("phase_signals") or ()),
355
+ seg=seg,
356
+ nbr=nbr,
357
+ content_bearing=is_content_bearing(event),
358
+ )
359
+
360
+
361
+ @lru_cache(maxsize=1)
362
+ def _embedder():
363
+ """Load the frozen, VENDORED model2vec embedder from ``MODEL2VEC_DIR``.
364
+
365
+ Loads from the on-disk copy shipped inside the package, so serving makes no
366
+ network calls. A missing artifact is a packaging error, not a runtime
367
+ condition, so it raises loudly rather than silently degrading feature quality.
368
+ """
369
+ from model2vec import StaticModel
370
+
371
+ if not MODEL2VEC_DIR.exists():
372
+ raise FileNotFoundError(
373
+ f"vendored model2vec embedder not found at {MODEL2VEC_DIR}; the package "
374
+ "is missing its shipped artifact (regenerate with "
375
+ f"StaticModel.from_pretrained({MODEL2VEC_NAME!r}).save_pretrained(...))"
376
+ )
377
+ return StaticModel.from_pretrained(str(MODEL2VEC_DIR))
378
+
379
+
380
+ def embed_texts(texts: Sequence[str]) -> np.ndarray:
381
+ """Frozen model2vec embedding of a list of texts → (n, 256) float32."""
382
+
383
+ if not texts:
384
+ return np.zeros((0, MODEL2VEC_DIM), dtype=np.float32)
385
+ vecs = _embedder().encode(list(texts))
386
+ return np.asarray(vecs, dtype=np.float32)
387
+
388
+
389
+ def merged_symbolic(
390
+ example: EventExample, include_seg: bool = False, nbr_prefixes: tuple[str, ...] = ()
391
+ ) -> dict[str, float]:
392
+ """Symbolic + numeric (+ optional segmentation / neighbor) dict for a DictVectorizer."""
393
+
394
+ d = dict(example.symbolic)
395
+ d.update(example.numeric)
396
+ if include_seg:
397
+ d.update(getattr(example, "seg", {}) or {})
398
+ if nbr_prefixes:
399
+ for k, v in (getattr(example, "nbr", {}) or {}).items():
400
+ if k.startswith(nbr_prefixes):
401
+ d[k] = v
402
+ return d
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # Session featurisation (shared by training and runtime inference)
407
+ # ---------------------------------------------------------------------------
408
+
409
+
410
+ def featurize_session_events(
411
+ session_id: str,
412
+ source: str,
413
+ events: dict[str, dict],
414
+ seg_params=None,
415
+ neighbor_params=None,
416
+ ) -> list[EventExample]:
417
+ """Featurise every event of one session into ordered, label-free examples.
418
+
419
+ ``events`` maps ``event_id -> event-row dict`` (each from
420
+ :func:`traceforge.phase.event_rows.event_to_feature_row`). Returns
421
+ :class:`EventExample` objects in ``seq`` order with empty ``phases`` — the
422
+ training path overlays the target; inference uses them directly.
423
+
424
+ All features are **causal** when ``neighbor_params`` only drives the
425
+ trailing-only centroid family (the production contract).
426
+ """
427
+
428
+ if not events:
429
+ return []
430
+ seq = {eid: int(ev.get("seq") or 0) for eid, ev in events.items()}
431
+ ordered = sorted(events, key=lambda e: seq.get(e, 0))
432
+
433
+ seg_feats: dict[str, dict[str, float]] = {}
434
+ if seg_params is not None:
435
+ from .segmentation import session_segmentation_features
436
+
437
+ seg_feats = session_segmentation_features(
438
+ [events[e].get("phase_signals") for e in ordered],
439
+ ordered,
440
+ seg_params,
441
+ )
442
+
443
+ nbr_feats: dict[str, dict[str, float]] = {}
444
+ if neighbor_params is not None:
445
+ nbr_feats = _neighbor_features(
446
+ ordered,
447
+ [_extract_text(events[e]) for e in ordered],
448
+ neighbor_params.windows,
449
+ )
450
+
451
+ out: list[EventExample] = []
452
+ for eid in ordered:
453
+ ev = events[eid]
454
+ out.append(
455
+ EventExample(
456
+ session_id=session_id,
457
+ source=source,
458
+ event_id=eid,
459
+ symbolic=_symbolic_dict(ev),
460
+ numeric=_numeric_dict(ev),
461
+ text=_extract_text(ev),
462
+ phase_signals=tuple(ev.get("phase_signals") or ()),
463
+ seg=seg_feats.get(eid, {}),
464
+ nbr=nbr_feats.get(eid, {}),
465
+ content_bearing=is_content_bearing(ev),
466
+ )
467
+ )
468
+ return out