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,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
|
+
]
|