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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|