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