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,137 @@
1
+ """Per-gap featurisation for the boundary classifier (shared train/serve).
2
+
3
+ A *gap* is the transition point after event ``t``; it is featurised from the
4
+ canonical fields of ``t`` and its successor ``t+1`` (so the classifier sees the
5
+ transition), plus the causal segmentation-detector outputs at ``t``. This is the
6
+ single featuriser used by both leave-session-out training (via the research
7
+ label loader) and runtime/e2e inference, so there is no train/serve skew.
8
+
9
+ All features are **causal**: ``t+1`` has arrived by the time the gap after ``t``
10
+ is scored, and the segmentation features come from the online BOCPD / trailing
11
+ majority-vote state. The acausal ``position = seq / n`` feature used by the
12
+ earliest boundary baselines is intentionally **not** emitted here — ``n`` (total
13
+ session length) is unknowable mid-stream.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from traceforge.phase.features import (
21
+ MAX_TEXT_CHARS,
22
+ _extract_text,
23
+ _numeric_dict,
24
+ _symbolic_dict,
25
+ )
26
+ from traceforge.phase.segmentation import SegmentationParams, session_segmentation_features
27
+
28
+ #: Canonical fields whose change across the gap is an explicit transition signal.
29
+ _CHANGE_FIELDS = ("mechanism", "effect", "tool_name")
30
+
31
+
32
+ @dataclass
33
+ class GapExample:
34
+ """One gap (the transition after ``after_event_id``), ready for vectorisation.
35
+
36
+ Field names mirror :class:`traceforge.phase.features.EventExample` so the
37
+ shared ``merged_symbolic`` / design-matrix machinery applies unchanged.
38
+ """
39
+
40
+ session_id: str
41
+ source: str
42
+ after_event_id: str
43
+ symbolic: dict[str, float]
44
+ numeric: dict[str, float]
45
+ text: str
46
+ seg: dict[str, float] = field(default_factory=dict)
47
+ nbr: dict[str, float] = field(default_factory=dict)
48
+ label: str = ""
49
+
50
+ @property
51
+ def event_id(self) -> str:
52
+ """Alias so predictions can be keyed identically to the phase path."""
53
+ return self.after_event_id
54
+
55
+
56
+ def _gap_symbolic(cur: dict, nxt: dict | None) -> dict[str, float]:
57
+ """Symbolic features of the current event plus ``next_``-prefixed successor
58
+ features and explicit change indicators across the gap."""
59
+
60
+ sym = _symbolic_dict(cur)
61
+ if nxt is not None:
62
+ for k, v in _symbolic_dict(nxt).items():
63
+ sym[f"next_{k}"] = v
64
+ for fld in _CHANGE_FIELDS:
65
+ if cur.get(fld) != nxt.get(fld):
66
+ sym[f"changed_{fld}"] = 1.0
67
+ return sym
68
+
69
+
70
+ def _gap_text(cur: dict, nxt: dict | None) -> str:
71
+ text = _extract_text(cur)
72
+ if nxt is not None:
73
+ text = (text + " \n " + _extract_text(nxt))[: MAX_TEXT_CHARS * 2]
74
+ return text
75
+
76
+
77
+ def build_gap_example(
78
+ session_id: str,
79
+ source: str,
80
+ cur: dict,
81
+ nxt: dict | None,
82
+ seg: dict[str, float] | None = None,
83
+ ) -> GapExample:
84
+ """Build one :class:`GapExample` from a current feature row, its successor
85
+ (or ``None`` for the final gap), and the causal seg features computed at
86
+ ``cur``. Shared by the batch featuriser and the live streaming inferencer so
87
+ there is no train/serve skew."""
88
+
89
+ return GapExample(
90
+ session_id=session_id,
91
+ source=source,
92
+ after_event_id=cur["event_id"],
93
+ symbolic=_gap_symbolic(cur, nxt),
94
+ numeric=_numeric_dict(cur), # causal: no acausal position=seq/n
95
+ text=_gap_text(cur, nxt),
96
+ seg=seg or {},
97
+ )
98
+
99
+
100
+ def featurize_session_gaps(
101
+ session_id: str,
102
+ source: str,
103
+ events: dict[str, dict],
104
+ seg_params: SegmentationParams | None = None,
105
+ ) -> list[GapExample]:
106
+ """Featurise every gap of one session causally, in ``seq`` order.
107
+
108
+ ``events`` maps ``event_id -> feature-row dict`` (each from
109
+ :func:`traceforge.phase.event_rows.event_to_feature_row`). Returns one
110
+ :class:`GapExample` per event (the gap *after* it); the final event's gap has
111
+ no successor. Labels are left empty — the research loader overlays them; the
112
+ inference path uses the examples directly.
113
+ """
114
+
115
+ if not events:
116
+ return []
117
+ seq = {eid: int(ev.get("seq") or 0) for eid, ev in events.items()}
118
+ ordered = sorted(events, key=lambda e: seq.get(e, 0))
119
+ next_of = {ordered[i]: ordered[i + 1] for i in range(len(ordered) - 1)}
120
+
121
+ seg_feats: dict[str, dict[str, float]] = {}
122
+ if seg_params is not None:
123
+ seg_feats = session_segmentation_features(
124
+ [events[e].get("phase_signals") for e in ordered],
125
+ ordered,
126
+ seg_params,
127
+ )
128
+
129
+ out: list[GapExample] = []
130
+ for aid in ordered:
131
+ cur = events[aid]
132
+ nxt = events.get(next_of.get(aid)) if next_of.get(aid) else None
133
+ out.append(build_gap_example(session_id, source, cur, nxt, seg_feats.get(aid, {})))
134
+ return out
135
+
136
+
137
+ __all__ = ["GapExample", "build_gap_example", "featurize_session_gaps"]
@@ -0,0 +1,267 @@
1
+ """Persist and apply the trained per-gap boundary classifier.
2
+
3
+ The shipped contract is ``combined-seg``: canonical symbolic features of the gap
4
+ (current + successor event, with change indicators), a frozen model2vec text
5
+ embedding of both events, and the causal classical-segmentation detector outputs
6
+ (online BOCPD posterior, trailing multi-scale majority vote). It is a 3-class
7
+ single-label problem (``noise`` / ``activity-boundary`` / ``step-boundary``).
8
+
9
+ This module owns the bundle type so the persisted joblib loads in production
10
+ (``scikit-learn`` / ``scipy`` / ``joblib`` are core deps, imported lazily). It
11
+ mirrors :mod:`traceforge.phase.inference`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ import numpy as np
22
+
23
+ if TYPE_CHECKING:
24
+ from .decode import DecodeParams
25
+
26
+ from traceforge.phase.features import (
27
+ MODEL2VEC_DIM,
28
+ MODEL2VEC_NAME,
29
+ embed_texts,
30
+ feature_set_blocks,
31
+ merged_symbolic,
32
+ )
33
+ from traceforge.phase.segmentation import SegmentationParams
34
+
35
+ from .features import GapExample, featurize_session_gaps
36
+
37
+ #: Bumped whenever the bundle layout or feature contract changes.
38
+ SCHEMA_VERSION = 1
39
+ #: Single-label class vocabulary (order is the model's class order).
40
+ BOUNDARY_CLASSES: tuple[str, ...] = ("noise", "activity-boundary", "step-boundary")
41
+ #: The shipped (causal) feature contract.
42
+ DEFAULT_FEATURE_SET = "combined-seg"
43
+ #: Environment override for the model location.
44
+ MODEL_PATH_ENV = "TRACEFORGE_BOUNDARY_MODEL"
45
+ #: Packaged model location (populated at release time from the trained bundle).
46
+ PACKAGED_MODEL_PATH = Path(__file__).resolve().parent / "data" / "boundary-model.joblib"
47
+
48
+
49
+ def resolve_model_path(path: str | Path | None = None) -> Path:
50
+ """Resolve the bundle location: explicit arg → ``$TRACEFORGE_BOUNDARY_MODEL``
51
+ → the packaged default."""
52
+
53
+ if path is not None:
54
+ return Path(path)
55
+ env = os.environ.get(MODEL_PATH_ENV)
56
+ if env:
57
+ return Path(env)
58
+ return PACKAGED_MODEL_PATH
59
+
60
+
61
+ @dataclass
62
+ class BoundaryModel:
63
+ """A self-contained, fitted boundary classifier ready for inference."""
64
+
65
+ feature_set: str
66
+ classes: tuple[str, ...]
67
+ vectorizer: object # sklearn DictVectorizer
68
+ scaler: object # sklearn StandardScaler
69
+ estimator: object
70
+ seg_params: SegmentationParams | None
71
+ drop_prefixes: tuple[str, ...]
72
+ model2vec_name: str
73
+ model2vec_dim: int
74
+ n_train_examples: int
75
+ n_train_sessions: int
76
+ schema_version: int = SCHEMA_VERSION
77
+ #: Learned causal decode levers (per-class threshold + refractory min-gap).
78
+ #: ``None`` => legacy bundle; callers fall back to argmax.
79
+ decode_params: "DecodeParams | None" = None
80
+
81
+
82
+ def _filter(d: dict[str, float], drop_prefixes: tuple[str, ...]) -> dict[str, float]:
83
+ if not drop_prefixes:
84
+ return d
85
+ return {k: v for k, v in d.items() if not k.startswith(drop_prefixes)}
86
+
87
+
88
+ def _design_matrix(
89
+ feature_set: str,
90
+ vectorizer,
91
+ examples,
92
+ embeddings: np.ndarray,
93
+ drop_prefixes: tuple[str, ...],
94
+ *,
95
+ fit: bool,
96
+ ) -> np.ndarray:
97
+ """Assemble the (pre-scaling) feature matrix: symbolic block (DictVectorizer)
98
+ then the frozen model2vec block, horizontally stacked."""
99
+
100
+ from scipy import sparse
101
+
102
+ use_symbolic, use_embedding, use_seg, nbr_prefixes = feature_set_blocks(feature_set)
103
+ blocks: list[np.ndarray] = []
104
+ if use_symbolic:
105
+ dicts = [
106
+ _filter(merged_symbolic(ex, use_seg, nbr_prefixes), drop_prefixes) for ex in examples
107
+ ]
108
+ m = vectorizer.fit_transform(dicts) if fit else vectorizer.transform(dicts)
109
+ blocks.append(m.toarray() if sparse.issparse(m) else np.asarray(m))
110
+ if use_embedding:
111
+ blocks.append(np.asarray(embeddings))
112
+ return np.hstack(blocks).astype(np.float64)
113
+
114
+
115
+ def fit_boundary_model(
116
+ examples,
117
+ feature_set: str,
118
+ estimator_factory,
119
+ seg_params: SegmentationParams | None,
120
+ *,
121
+ drop_prefixes: tuple[str, ...] = (),
122
+ ) -> BoundaryModel:
123
+ """Fit a boundary classifier on every labelled gap and return the bundle.
124
+
125
+ ``examples`` must already carry ``seg`` features for ``feature_set`` and a
126
+ non-empty ``label`` in :data:`BOUNDARY_CLASSES`.
127
+ """
128
+
129
+ from sklearn.feature_extraction import DictVectorizer
130
+ from sklearn.preprocessing import StandardScaler
131
+
132
+ embeddings = embed_texts([e.text for e in examples])
133
+ vectorizer = DictVectorizer(sparse=True)
134
+ x = _design_matrix(feature_set, vectorizer, examples, embeddings, drop_prefixes, fit=True)
135
+ scaler = StandardScaler().fit(x)
136
+ y = np.array([e.label for e in examples], dtype=object)
137
+ estimator = estimator_factory()
138
+ estimator.fit(scaler.transform(x), y)
139
+ classes = tuple(str(c) for c in estimator.classes_)
140
+ return BoundaryModel(
141
+ feature_set=feature_set,
142
+ classes=classes,
143
+ vectorizer=vectorizer,
144
+ scaler=scaler,
145
+ estimator=estimator,
146
+ seg_params=seg_params,
147
+ drop_prefixes=tuple(drop_prefixes),
148
+ model2vec_name=MODEL2VEC_NAME,
149
+ model2vec_dim=MODEL2VEC_DIM,
150
+ n_train_examples=len(examples),
151
+ n_train_sessions=len({e.session_id for e in examples}),
152
+ )
153
+
154
+
155
+ def predict_scores(model: BoundaryModel, examples) -> np.ndarray:
156
+ """Per-class probabilities for each gap, aligned to ``model.classes``."""
157
+
158
+ if not examples:
159
+ return np.zeros((0, len(model.classes)), dtype=np.float64)
160
+ embeddings = embed_texts([e.text for e in examples])
161
+ x = _design_matrix(
162
+ model.feature_set, model.vectorizer, examples, embeddings, model.drop_prefixes, fit=False
163
+ )
164
+ xs = model.scaler.transform(x)
165
+ est = model.estimator
166
+ if hasattr(est, "predict_proba"):
167
+ return np.asarray(est.predict_proba(xs), dtype=np.float64)
168
+ return np.asarray(est.decision_function(xs), dtype=np.float64)
169
+
170
+
171
+ def predict_examples(model: BoundaryModel, examples) -> list[dict]:
172
+ """Label each gap. Returns one dict per example: ``event_id`` (the gap's
173
+ ``after_event_id``), the single best ``label`` (argmax) and raw ``scores``."""
174
+
175
+ scores = predict_scores(model, examples)
176
+ out: list[dict] = []
177
+ for ex, row in zip(examples, scores):
178
+ top = model.classes[int(np.argmax(row))]
179
+ out.append(
180
+ {
181
+ "event_id": ex.event_id,
182
+ "label": top,
183
+ "scores": {c: float(s) for c, s in zip(model.classes, row)},
184
+ }
185
+ )
186
+ return out
187
+
188
+
189
+ def decode_examples(model: BoundaryModel, examples) -> list[dict]:
190
+ """Label each gap with the learned causal decoder (per-class threshold +
191
+ refractory min-gap) instead of ``argmax``.
192
+
193
+ ``examples`` must be in session ``seq`` order (as produced by
194
+ :func:`featurize_session_gaps`) — the refractory is positional. Falls back to
195
+ :func:`predict_examples` (argmax) when the bundle carries no decode params.
196
+ """
197
+
198
+ if model.decode_params is None:
199
+ return predict_examples(model, examples)
200
+ from .decode import decode_scores
201
+
202
+ scores = predict_scores(model, examples)
203
+ labels = decode_scores(model.decode_params, model.classes, scores)
204
+ return [
205
+ {
206
+ "event_id": ex.event_id,
207
+ "label": lab,
208
+ "scores": {c: float(s) for c, s in zip(model.classes, row)},
209
+ }
210
+ for ex, row, lab in zip(examples, scores, labels)
211
+ ]
212
+
213
+
214
+ def predict_session(
215
+ model: BoundaryModel, session_id: str, source: str, events: dict[str, dict]
216
+ ) -> list[dict]:
217
+ """Featurise a whole session's gaps causally and label each one. Uses the
218
+ learned decoder when the bundle carries decode params, else argmax."""
219
+
220
+ examples = featurize_session_gaps(session_id, source, events, model.seg_params)
221
+ if not examples:
222
+ return []
223
+ if model.decode_params is not None:
224
+ return decode_examples(model, examples)
225
+ return predict_examples(model, examples)
226
+
227
+
228
+ def save(model: BoundaryModel, path: str | Path) -> Path:
229
+ import joblib
230
+
231
+ p = Path(path)
232
+ p.parent.mkdir(parents=True, exist_ok=True)
233
+ joblib.dump(model, p)
234
+ return p
235
+
236
+
237
+ def load(path: str | Path | None = None) -> BoundaryModel:
238
+ import joblib
239
+
240
+ p = resolve_model_path(path)
241
+ if not p.exists():
242
+ raise FileNotFoundError(
243
+ f"boundary model not found at {p}. Set ${MODEL_PATH_ENV} or place the "
244
+ "trained bundle at the packaged path. Train it with "
245
+ "`python -m scripts.persist_boundary_model` in research/."
246
+ )
247
+ return joblib.load(p)
248
+
249
+
250
+ __all__ = [
251
+ "BOUNDARY_CLASSES",
252
+ "DEFAULT_FEATURE_SET",
253
+ "MODEL_PATH_ENV",
254
+ "PACKAGED_MODEL_PATH",
255
+ "SCHEMA_VERSION",
256
+ "BoundaryModel",
257
+ "GapExample",
258
+ "featurize_session_gaps",
259
+ "fit_boundary_model",
260
+ "decode_examples",
261
+ "load",
262
+ "predict_examples",
263
+ "predict_scores",
264
+ "predict_session",
265
+ "resolve_model_path",
266
+ "save",
267
+ ]
@@ -0,0 +1,146 @@
1
+ """Runtime boundary inference over live :class:`SessionEvent` objects.
2
+
3
+ Bridges the production event type to the trained per-gap boundary classifier and
4
+ runs it **live, as events stream** — no waiting for the session to end. A *gap*
5
+ is the transition after event ``t``; it is featurised from ``t`` and its
6
+ successor ``t+1`` plus the causal segmentation state at ``t``. Because the gap
7
+ needs the successor, the boundary it decodes is stamped on that successor — the
8
+ event that *opens* the new activity/step — via ``metadata.boundary``. So the
9
+ moment event ``t+1`` arrives, the gap after ``t`` is fully known and stamped with
10
+ zero look-ahead and zero buffering.
11
+
12
+ The causal decoder (per-class threshold + refractory min-gap) runs incrementally
13
+ through :class:`traceforge.boundary.decode.StreamingBoundaryDecoder`, and the
14
+ segmentation features come from :class:`IncrementalSegmentation`, so the streamed
15
+ labels are identical to the batch :func:`predict_session` path (guarded by tests)
16
+ while holding only O(1) state per session.
17
+
18
+ Mirrors :mod:`traceforge.phase.inferencer`. The model is the only boundary
19
+ producer; a missing bundle raises rather than silently degrading.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from traceforge.phase.event_rows import event_to_feature_row
25
+ from traceforge.types import SessionEvent
26
+
27
+ from .features import build_gap_example
28
+
29
+
30
+ class BoundaryInferencer:
31
+ """Loads a trained boundary bundle once and applies it to live events.
32
+
33
+ Construct with an explicit ``model``/``model_path`` or rely on
34
+ :func:`traceforge.boundary.inference.resolve_model_path` (env var / packaged
35
+ default). The model loads lazily on first use.
36
+ """
37
+
38
+ def __init__(self, model=None, model_path=None) -> None:
39
+ self._model = model
40
+ self._model_path = model_path
41
+
42
+ @property
43
+ def model(self):
44
+ if self._model is None:
45
+ from .inference import load
46
+
47
+ self._model = load(self._model_path)
48
+ return self._model
49
+
50
+ def apply(self, event: SessionEvent, boundary: str | None) -> SessionEvent:
51
+ """Return a copy of ``event`` with ``metadata.boundary`` set.
52
+
53
+ ``boundary`` is an opening label (``"activity-boundary"`` /
54
+ ``"step-boundary"``) or ``None`` to leave the event continuing the
55
+ current segment. Returns the event unchanged when ``boundary`` is
56
+ ``None`` or the event carries no metadata.
57
+ """
58
+
59
+ if boundary is None or event.metadata is None:
60
+ return event
61
+ new_md = event.metadata.model_copy(update={"boundary": boundary})
62
+ return event.model_copy(update={"metadata": new_md})
63
+
64
+ def new_stream(self, session_id: str, source: str = "") -> "SessionBoundaryStream":
65
+ """Open a live per-event boundary stream for one session.
66
+
67
+ Feed events through :meth:`SessionBoundaryStream.push` in arrival order;
68
+ each call returns the event stamped with the opening boundary the batch
69
+ decoder would assign to the gap *into* it — computed online from bounded
70
+ incremental state.
71
+ """
72
+
73
+ return SessionBoundaryStream(self, session_id, source)
74
+
75
+
76
+ class SessionBoundaryStream:
77
+ """Live per-event boundary stamper for a single session.
78
+
79
+ Drives :class:`~traceforge.phase.segmentation.IncrementalSegmentation` so each
80
+ gap's causal seg features match the batch path exactly, scores the gap after
81
+ the previous event against the just-arrived successor, decodes it with the
82
+ refractory :class:`~traceforge.boundary.decode.StreamingBoundaryDecoder`, and
83
+ stamps the resulting opening label on the successor. The very first event has
84
+ no incoming gap, so it is emitted unstamped (it opens the root segment).
85
+ """
86
+
87
+ def __init__(self, inferencer: "BoundaryInferencer", session_id: str, source: str) -> None:
88
+ from traceforge.phase.segmentation import IncrementalSegmentation
89
+
90
+ from .decode import StreamingBoundaryDecoder
91
+
92
+ self._inf = inferencer
93
+ self._session_id = session_id
94
+ self._source = source
95
+ model = inferencer.model
96
+ self._seg = (
97
+ IncrementalSegmentation(model.seg_params) if model.seg_params is not None else None
98
+ )
99
+ self._decoder = (
100
+ StreamingBoundaryDecoder(model.decode_params)
101
+ if model.decode_params is not None
102
+ else None
103
+ )
104
+ self._priority = model.decode_params.priority if model.decode_params is not None else ()
105
+ self._seq = 0
106
+ self._prev_row: dict | None = None
107
+ self._prev_seg: dict[str, float] = {}
108
+
109
+ def push(self, event: SessionEvent) -> SessionEvent:
110
+ """Ingest one event; return it stamped with its opening boundary (if any)."""
111
+
112
+ row = event_to_feature_row(event, self._seq)
113
+ self._seq += 1
114
+ seg_cur = self._seg.push(row.get("phase_signals")) if self._seg is not None else {}
115
+
116
+ boundary: str | None = None
117
+ if self._prev_row is not None:
118
+ boundary = self._decode_gap(self._prev_row, row, self._prev_seg)
119
+
120
+ self._prev_row = row
121
+ self._prev_seg = seg_cur
122
+ return self._inf.apply(event, boundary)
123
+
124
+ def _decode_gap(self, cur: dict, nxt: dict, seg: dict[str, float]) -> str | None:
125
+ """Score the gap (cur -> nxt) and advance the refractory decoder.
126
+
127
+ Falls back to ``argmax`` (no refractory) when the bundle carries no
128
+ decode params, mirroring the batch path. Returns an opening label or
129
+ ``None`` (noise / suppressed)."""
130
+
131
+ from .inference import predict_scores
132
+
133
+ model = self._inf.model
134
+ gap = build_gap_example(self._session_id, self._source, cur, nxt, seg)
135
+ scores = predict_scores(model, [gap])[0]
136
+ score_by_class = {c: float(s) for c, s in zip(model.classes, scores)}
137
+
138
+ if self._decoder is None:
139
+ top = max(model.classes, key=lambda c: score_by_class[c])
140
+ return top if top != "noise" else None
141
+
142
+ label = self._decoder.push({c: score_by_class.get(c, 0.0) for c in self._priority})
143
+ return label if label != "noise" else None
144
+
145
+
146
+ __all__ = ["BoundaryInferencer", "SessionBoundaryStream"]
@@ -0,0 +1,95 @@
1
+ """Classification package — multi-dimensional tool and shell analysis."""
2
+
3
+ from traceforge.classify.cmd import classify_cmd_command
4
+ from traceforge.classify.config import (
5
+ ClassificationEngine,
6
+ ClassifyConfig,
7
+ get_default_engine,
8
+ load_config,
9
+ reset_default_engine,
10
+ set_default_engine,
11
+ )
12
+ from traceforge.classify.core import (
13
+ Action,
14
+ Capability,
15
+ Classification,
16
+ Effect,
17
+ Mechanism,
18
+ PhaseSegment,
19
+ Role,
20
+ Scope,
21
+ Structure,
22
+ aggregate_effect,
23
+ )
24
+ from traceforge.classify.coding import (
25
+ CodingAction,
26
+ CodingMechanism,
27
+ CodingRole,
28
+ CodingScope,
29
+ ShellDialect,
30
+ ShellStructure,
31
+ )
32
+ from traceforge.classify.mcp import (
33
+ McpServerProfile,
34
+ McpToolOverride,
35
+ classify_mcp_tool,
36
+ extract_mcp_namespace,
37
+ )
38
+ from traceforge.classify.powershell import classify_powershell_command
39
+ from traceforge.classify.registry import DimensionRegistry, get_default_registry
40
+ from traceforge.classify.risk import Confidence, RiskAssessment, assess_risk, assess_tool_risk
41
+ from traceforge.classify.shell import (
42
+ _extract_commands_from_ast,
43
+ classify_shell,
44
+ )
45
+ from traceforge.classify.tools import classify_tool, normalize_tool_name
46
+ from traceforge.classify.workflow import Phase, Visibility
47
+
48
+ __all__ = [
49
+ # Core types
50
+ "Action",
51
+ "Capability",
52
+ "Classification",
53
+ "Effect",
54
+ "Mechanism",
55
+ "Phase",
56
+ "PhaseSegment",
57
+ "Role",
58
+ "Scope",
59
+ "ShellDialect",
60
+ "ShellStructure",
61
+ "Structure",
62
+ "Visibility",
63
+ "aggregate_effect",
64
+ # Coding domain
65
+ "CodingAction",
66
+ "CodingMechanism",
67
+ "CodingRole",
68
+ "CodingScope",
69
+ # Config
70
+ "ClassificationEngine",
71
+ "ClassifyConfig",
72
+ "get_default_engine",
73
+ "load_config",
74
+ "reset_default_engine",
75
+ "set_default_engine",
76
+ # Registry
77
+ "DimensionRegistry",
78
+ "get_default_registry",
79
+ # Classifiers
80
+ "_extract_commands_from_ast",
81
+ "classify_shell",
82
+ "classify_cmd_command",
83
+ "classify_powershell_command",
84
+ "classify_tool",
85
+ "classify_mcp_tool",
86
+ "extract_mcp_namespace",
87
+ "McpServerProfile",
88
+ "McpToolOverride",
89
+ "normalize_tool_name",
90
+ # Risk scoring
91
+ "Confidence",
92
+ "RiskAssessment",
93
+ "assess_risk",
94
+ "assess_tool_risk",
95
+ ]