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,279 @@
1
+ """Persist and apply the trained per-event phase classifier.
2
+
3
+ The shipped contract is ``combined-seg-nbrcentroid``: the best *causal*
4
+ feature-set (trailing-only neighbor centroid distance, online BOCPD, trailing
5
+ majority-vote), leave-session-out F1_macro 0.931 — within 0.0004 of the
6
+ look-ahead model, so inference needs no future context and runs online.
7
+
8
+ This module owns the bundle type so the persisted joblib is loadable in
9
+ production. ``scikit-learn`` / ``scipy`` / ``joblib`` are core dependencies;
10
+ they are imported lazily only to keep the base package's import time low.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ from .features import (
22
+ MODEL2VEC_DIM,
23
+ MODEL2VEC_NAME,
24
+ PHASES,
25
+ EventExample,
26
+ NeighborParams,
27
+ embed_texts,
28
+ featurize_session_events,
29
+ feature_set_blocks,
30
+ merged_symbolic,
31
+ )
32
+ from .segmentation import SegmentationParams
33
+
34
+ #: Bumped whenever the bundle layout or feature contract changes.
35
+ SCHEMA_VERSION = 1
36
+ #: The shipped (causal) feature contract.
37
+ DEFAULT_FEATURE_SET = "combined-seg-nbrcentroid"
38
+ #: Environment override for the model location.
39
+ MODEL_PATH_ENV = "TRACEFORGE_PHASE_MODEL"
40
+ #: Packaged model location (populated at release time from the trained bundle).
41
+ PACKAGED_MODEL_PATH = Path(__file__).resolve().parent / "data" / "phase-model.joblib"
42
+
43
+
44
+ def resolve_model_path(path: str | Path | None = None) -> Path:
45
+ """Resolve the bundle location: explicit arg → ``$TRACEFORGE_PHASE_MODEL`` →
46
+ the packaged default."""
47
+
48
+ if path is not None:
49
+ return Path(path)
50
+ env = os.environ.get(MODEL_PATH_ENV)
51
+ if env:
52
+ return Path(env)
53
+ return PACKAGED_MODEL_PATH
54
+
55
+
56
+ @dataclass
57
+ class PhaseModel:
58
+ """A self-contained, fitted phase classifier ready for inference."""
59
+
60
+ feature_set: str
61
+ classes: tuple[str, ...]
62
+ vectorizer: object # sklearn DictVectorizer
63
+ scaler: object # sklearn StandardScaler
64
+ estimator: object
65
+ seg_params: SegmentationParams | None
66
+ neighbor_params: NeighborParams | None
67
+ drop_prefixes: tuple[str, ...]
68
+ decision_threshold: float
69
+ model2vec_name: str
70
+ model2vec_dim: int
71
+ n_train_examples: int
72
+ n_train_sessions: int
73
+ schema_version: int = SCHEMA_VERSION
74
+
75
+
76
+ def _filter(d: dict[str, float], drop_prefixes: tuple[str, ...]) -> dict[str, float]:
77
+ if not drop_prefixes:
78
+ return d
79
+ return {k: v for k, v in d.items() if not k.startswith(drop_prefixes)}
80
+
81
+
82
+ def _design_matrix(
83
+ feature_set: str,
84
+ vectorizer,
85
+ examples,
86
+ embeddings: np.ndarray,
87
+ drop_prefixes: tuple[str, ...],
88
+ *,
89
+ fit: bool,
90
+ ) -> np.ndarray:
91
+ """Assemble the (pre-scaling) feature matrix: symbolic block (DictVectorizer)
92
+ first, then the frozen model2vec block, horizontally stacked."""
93
+
94
+ from scipy import sparse
95
+
96
+ use_symbolic, use_embedding, use_seg, nbr_prefixes = feature_set_blocks(feature_set)
97
+ blocks: list[np.ndarray] = []
98
+ if use_symbolic:
99
+ dicts = [
100
+ _filter(merged_symbolic(ex, use_seg, nbr_prefixes), drop_prefixes) for ex in examples
101
+ ]
102
+ m = vectorizer.fit_transform(dicts) if fit else vectorizer.transform(dicts)
103
+ blocks.append(m.toarray() if sparse.issparse(m) else np.asarray(m))
104
+ if use_embedding:
105
+ blocks.append(np.asarray(embeddings))
106
+ return np.hstack(blocks).astype(np.float64)
107
+
108
+
109
+ def fit_phase_model(
110
+ examples,
111
+ feature_set: str,
112
+ estimator_factory,
113
+ seg_params: SegmentationParams | None,
114
+ neighbor_params: NeighborParams | None,
115
+ *,
116
+ drop_prefixes: tuple[str, ...] = (),
117
+ decision_threshold: float = 0.5,
118
+ ) -> PhaseModel:
119
+ """Fit a phase classifier on every labelled example and return the bundle.
120
+
121
+ ``examples`` must already carry ``seg``/``nbr`` features for ``feature_set``.
122
+ The same params are stored so inference recomputes identical features.
123
+ """
124
+
125
+ from sklearn.feature_extraction import DictVectorizer
126
+ from sklearn.preprocessing import MultiLabelBinarizer, StandardScaler
127
+
128
+ embeddings = embed_texts([e.text for e in examples])
129
+ vectorizer = DictVectorizer(sparse=True)
130
+ x = _design_matrix(feature_set, vectorizer, examples, embeddings, drop_prefixes, fit=True)
131
+ scaler = StandardScaler().fit(x)
132
+ mlb = MultiLabelBinarizer(classes=list(PHASES))
133
+ y = mlb.fit_transform([e.phases for e in examples])
134
+ estimator = estimator_factory()
135
+ estimator.fit(scaler.transform(x), y)
136
+ return PhaseModel(
137
+ feature_set=feature_set,
138
+ classes=tuple(PHASES),
139
+ vectorizer=vectorizer,
140
+ scaler=scaler,
141
+ estimator=estimator,
142
+ seg_params=seg_params,
143
+ neighbor_params=neighbor_params,
144
+ drop_prefixes=tuple(drop_prefixes),
145
+ decision_threshold=decision_threshold,
146
+ model2vec_name=MODEL2VEC_NAME,
147
+ model2vec_dim=MODEL2VEC_DIM,
148
+ n_train_examples=len(examples),
149
+ n_train_sessions=len({e.session_id for e in examples}),
150
+ )
151
+
152
+
153
+ def predict_scores(model: PhaseModel, examples) -> np.ndarray:
154
+ """Per-class probabilities for each example, aligned to ``model.classes``."""
155
+
156
+ if not examples:
157
+ return np.zeros((0, len(model.classes)), dtype=np.float64)
158
+ embeddings = embed_texts([e.text for e in examples])
159
+ x = _design_matrix(
160
+ model.feature_set, model.vectorizer, examples, embeddings, model.drop_prefixes, fit=False
161
+ )
162
+ xs = model.scaler.transform(x)
163
+ est = model.estimator
164
+ if hasattr(est, "predict_proba"):
165
+ return np.asarray(est.predict_proba(xs), dtype=np.float64)
166
+ return np.asarray(est.decision_function(xs), dtype=np.float64)
167
+
168
+
169
+ def predict_examples(model: PhaseModel, examples) -> list[dict]:
170
+ """Stamp a phase onto each example.
171
+
172
+ Returns one dict per example: ``event_id``, the single best ``phase``
173
+ (argmax — the production stamp), the multi-label ``phases`` (every class
174
+ above ``decision_threshold``; falls back to argmax when none clear it),
175
+ and the raw per-class ``scores``.
176
+ """
177
+
178
+ scores = predict_scores(model, examples)
179
+ out: list[dict] = []
180
+ for ex, row in zip(examples, scores):
181
+ top = model.classes[int(np.argmax(row))]
182
+ multi = tuple(c for c, s in zip(model.classes, row) if s >= model.decision_threshold)
183
+ if not multi:
184
+ multi = (top,)
185
+ out.append(
186
+ {
187
+ "event_id": ex.event_id,
188
+ "phase": top,
189
+ "phases": multi,
190
+ "scores": {c: float(s) for c, s in zip(model.classes, row)},
191
+ }
192
+ )
193
+ return out
194
+
195
+
196
+ def predict_session(
197
+ model: PhaseModel, session_id: str, source: str, events: dict[str, dict]
198
+ ) -> list[dict]:
199
+ """Featurise a whole session causally and predict, with plumbing inheriting.
200
+
201
+ The phase *decision* is restricted to content-bearing events (messages, tool
202
+ calls, reasoning). Plumbing events (lifecycle/turn/hook markers) carry no
203
+ intrinsic phase, so they inherit the most recent content-bearing phase
204
+ (leading plumbing back-fills the first content-bearing phase). A stamp is
205
+ still returned for **every** event so downstream sinks see a phase on each.
206
+
207
+ Featurisation runs over the full event sequence, so the trailing windows /
208
+ neighbor centroids are unchanged — only which events the model is *asked*
209
+ about differs. Inherited stamps are flagged with ``"inherited": True``.
210
+ """
211
+
212
+ examples = featurize_session_events(
213
+ session_id, source, events, model.seg_params, model.neighbor_params
214
+ )
215
+ if not examples:
216
+ return []
217
+
218
+ content = [ex for ex in examples if ex.content_bearing]
219
+ preds = predict_examples(model, content)
220
+ pred_by_id = {p["event_id"]: p for p in preds}
221
+ first_phase = preds[0]["phase"] if preds else model.classes[0]
222
+
223
+ out: list[dict] = []
224
+ last: dict | None = None
225
+ for ex in examples:
226
+ if ex.content_bearing and ex.event_id in pred_by_id:
227
+ last = pred_by_id[ex.event_id]
228
+ out.append(last)
229
+ else:
230
+ inherited = last["phase"] if last is not None else first_phase
231
+ out.append(
232
+ {
233
+ "event_id": ex.event_id,
234
+ "phase": inherited,
235
+ "phases": (inherited,),
236
+ "scores": {},
237
+ "inherited": True,
238
+ }
239
+ )
240
+ return out
241
+
242
+
243
+ def save(model: PhaseModel, path: str | Path) -> Path:
244
+ import joblib
245
+
246
+ p = Path(path)
247
+ p.parent.mkdir(parents=True, exist_ok=True)
248
+ joblib.dump(model, p)
249
+ return p
250
+
251
+
252
+ def load(path: str | Path | None = None) -> PhaseModel:
253
+ import joblib
254
+
255
+ p = resolve_model_path(path)
256
+ if not p.exists():
257
+ raise FileNotFoundError(
258
+ f"phase model not found at {p}. Set ${MODEL_PATH_ENV} or place the "
259
+ "trained bundle at the packaged path. Train it with "
260
+ "`python -m scripts.persist_phase_model` in research/."
261
+ )
262
+ return joblib.load(p)
263
+
264
+
265
+ __all__ = [
266
+ "DEFAULT_FEATURE_SET",
267
+ "MODEL_PATH_ENV",
268
+ "PACKAGED_MODEL_PATH",
269
+ "SCHEMA_VERSION",
270
+ "EventExample",
271
+ "PhaseModel",
272
+ "fit_phase_model",
273
+ "load",
274
+ "predict_examples",
275
+ "predict_scores",
276
+ "predict_session",
277
+ "resolve_model_path",
278
+ "save",
279
+ ]
@@ -0,0 +1,171 @@
1
+ """Runtime phase inference over live :class:`SessionEvent` objects.
2
+
3
+ Bridges the production event type to the trained classifier: projects each
4
+ event through the same :func:`event_to_feature_row` contract the corpus was
5
+ built from, runs the causal session featuriser + bundle, and stamps the
6
+ predicted workflow stage onto ``metadata.phase``.
7
+
8
+ The trained model is the *only* phase producer — there is no deterministic
9
+ fallback. If the model bundle is missing, inference raises rather than
10
+ silently degrading.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Sequence
16
+
17
+ from traceforge.classify.workflow import Phase
18
+ from traceforge.types import SessionEvent
19
+
20
+ from .event_rows import event_to_feature_row
21
+
22
+
23
+ class PhaseInferencer:
24
+ """Loads a trained bundle once and applies it to whole sessions.
25
+
26
+ Construct with an explicit ``model``/``model_path`` or rely on
27
+ :func:`traceforge.phase.inference.resolve_model_path` (env var / packaged
28
+ default). The model loads lazily on first use.
29
+ """
30
+
31
+ def __init__(self, model=None, model_path=None) -> None:
32
+ self._model = model
33
+ self._model_path = model_path
34
+
35
+ @property
36
+ def model(self):
37
+ if self._model is None:
38
+ from .inference import load
39
+
40
+ self._model = load(self._model_path)
41
+ return self._model
42
+
43
+ def predict(self, events: Sequence[SessionEvent]) -> list[dict]:
44
+ """Predict a phase for every event of one session (in given order).
45
+
46
+ Events must belong to a single session and be in session sequence
47
+ order. Returns the per-event prediction dicts from
48
+ :func:`traceforge.phase.inference.predict_examples`.
49
+ """
50
+
51
+ from .inference import predict_session
52
+
53
+ ordered = list(events)
54
+ if not ordered:
55
+ return []
56
+ rows: dict[str, dict] = {}
57
+ for seq, ev in enumerate(ordered):
58
+ row = event_to_feature_row(ev, seq)
59
+ rows[row["event_id"]] = row
60
+ first_md = ordered[0].metadata
61
+ source = (first_md.source_framework if first_md else None) or ""
62
+ return predict_session(self.model, ordered[0].session_id, source, rows)
63
+
64
+ def stamp(self, events: Sequence[SessionEvent]) -> list[SessionEvent]:
65
+ """Return copies of ``events`` with ``metadata.phase`` set by the model."""
66
+
67
+ preds = {p["event_id"]: p for p in self.predict(events)}
68
+ out: list[SessionEvent] = []
69
+ for ev in events:
70
+ p = preds.get(ev.id)
71
+ out.append(self.apply(ev, p["phase"] if p else None))
72
+ return out
73
+
74
+ def apply(self, event: SessionEvent, phase: str | None) -> SessionEvent:
75
+ """Return a copy of ``event`` with ``metadata.phase`` set to ``phase``.
76
+
77
+ Returns the event unchanged when ``phase`` is ``None`` or the event has
78
+ no metadata. Used by the streaming pipeline to stamp one event at a time.
79
+ """
80
+
81
+ if phase is None or event.metadata is None:
82
+ return event
83
+ new_md = event.metadata.model_copy(update={"phase": Phase(phase)})
84
+ return event.model_copy(update={"metadata": new_md})
85
+
86
+ def is_content_bearing(self, event: SessionEvent) -> bool:
87
+ """Whether ``event`` carries an intrinsic phase the model predicts.
88
+
89
+ Plumbing events (lifecycle/turn/hook markers) are *not* content-bearing;
90
+ in the live stream they inherit the prevailing content phase instead of
91
+ being classified.
92
+ """
93
+
94
+ from .features import is_content_bearing as _is_content_bearing
95
+
96
+ return _is_content_bearing(event_to_feature_row(event, 0))
97
+
98
+ def new_stream(self, session_id: str, source: str = "") -> "SessionPhaseStream":
99
+ """Open a live per-event phase stream for one session.
100
+
101
+ Feed events through :meth:`SessionPhaseStream.push` in arrival order;
102
+ each call returns the events ready to emit, stamped with the phase the
103
+ batch path would assign — computed online from bounded incremental state
104
+ (no waiting for the session to end, no moving-window resets).
105
+ """
106
+
107
+ return SessionPhaseStream(self, session_id, source)
108
+
109
+
110
+ class SessionPhaseStream:
111
+ """Live per-event phase stamper for a single session.
112
+
113
+ Drives :class:`~traceforge.phase.features.StreamingSessionFeaturizer` so each
114
+ event's causal features (segmentation BOCPD posterior, trailing centroids,
115
+ windowed majority/entropy) are carried forward exactly, then classifies and
116
+ stamps content-bearing events the instant they arrive. Plumbing events
117
+ inherit the prevailing content phase. Only contiguous *leading* plumbing is
118
+ held (so it can inherit the first content phase, matching the batch path);
119
+ everything after the first content event is emitted one event at a time.
120
+ """
121
+
122
+ def __init__(self, inferencer: "PhaseInferencer", session_id: str, source: str) -> None:
123
+ from .features import StreamingSessionFeaturizer
124
+
125
+ self._inf = inferencer
126
+ model = inferencer.model
127
+ self._classes = model.classes
128
+ self._feat = StreamingSessionFeaturizer(
129
+ session_id, source, model.seg_params, model.neighbor_params
130
+ )
131
+ self._seq = 0
132
+ self._seen_content = False
133
+ self._leading: list[SessionEvent] = []
134
+ self._last_phase: str | None = None
135
+
136
+ def _predict(self, example) -> str:
137
+ from .inference import predict_examples
138
+
139
+ return predict_examples(self._inf.model, [example])[0]["phase"]
140
+
141
+ def push(self, event: SessionEvent) -> list[SessionEvent]:
142
+ """Ingest one event; return the events now ready to emit (stamped)."""
143
+
144
+ row = event_to_feature_row(event, self._seq)
145
+ self._seq += 1
146
+ example = self._feat.push(row["event_id"], row)
147
+
148
+ if not self._seen_content:
149
+ self._leading.append(event)
150
+ if example.content_bearing:
151
+ self._last_phase = self._predict(example)
152
+ out = [self._inf.apply(ev, self._last_phase) for ev in self._leading]
153
+ self._leading = []
154
+ self._seen_content = True
155
+ return out
156
+ return []
157
+
158
+ if example.content_bearing:
159
+ self._last_phase = self._predict(example)
160
+ return [self._inf.apply(event, self._last_phase)]
161
+
162
+ def flush(self) -> list[SessionEvent]:
163
+ """Emit any held leading plumbing for a session that never produced a
164
+ content event (matches the batch fallback: inherit the first class)."""
165
+
166
+ if self._seen_content or not self._leading:
167
+ return []
168
+ phase = self._classes[0]
169
+ out = [self._inf.apply(ev, phase) for ev in self._leading]
170
+ self._leading = []
171
+ return out