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,30 @@
|
|
|
1
|
+
"""Phase tracker: streaming session-level phase segmentation.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
* :class:`PhaseTracker` — the streaming segmenter.
|
|
6
|
+
* :class:`PhaseBlock`, :class:`PhaseTransition`, :class:`PhaseStats`,
|
|
7
|
+
:class:`PhaseTimeline`, :class:`PhaseSummary` — frozen output types.
|
|
8
|
+
* :func:`resolve_phase_root` — phase dot-path -> boundary-comparison root.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .models import (
|
|
14
|
+
PhaseBlock,
|
|
15
|
+
PhaseStats,
|
|
16
|
+
PhaseSummary,
|
|
17
|
+
PhaseTimeline,
|
|
18
|
+
PhaseTransition,
|
|
19
|
+
)
|
|
20
|
+
from .phase_tracker import PhaseTracker, resolve_phase_root
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"PhaseTracker",
|
|
24
|
+
"resolve_phase_root",
|
|
25
|
+
"PhaseBlock",
|
|
26
|
+
"PhaseTransition",
|
|
27
|
+
"PhaseStats",
|
|
28
|
+
"PhaseTimeline",
|
|
29
|
+
"PhaseSummary",
|
|
30
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Output data types for the phase tracker.
|
|
2
|
+
|
|
3
|
+
All types are frozen, following project convention: once a block or summary is
|
|
4
|
+
emitted it is immutable. See docs/design-phase-tracker.md for the full design.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PhaseBlock:
|
|
15
|
+
"""A contiguous run of events dominated by a single phase.
|
|
16
|
+
|
|
17
|
+
Boundaries are determined by the debounced majority-vote algorithm. Phase
|
|
18
|
+
blocks are first-class pipeline data: emitted to sinks and the system DB on
|
|
19
|
+
each boundary commit.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
session_id: str
|
|
23
|
+
phase: str
|
|
24
|
+
"""Dominant phase, dot-path string (e.g. 'verification.lint'). Derived from
|
|
25
|
+
the per-event phase signals in this block."""
|
|
26
|
+
|
|
27
|
+
phase_root: str
|
|
28
|
+
"""Root phase used for boundary detection (e.g. 'verification'). What the
|
|
29
|
+
majority-vote window compares."""
|
|
30
|
+
|
|
31
|
+
start_time: datetime
|
|
32
|
+
end_time: datetime
|
|
33
|
+
event_count: int
|
|
34
|
+
|
|
35
|
+
tool_names: tuple[str, ...] = ()
|
|
36
|
+
"""Ordered tool names invoked during this block."""
|
|
37
|
+
|
|
38
|
+
dominant_motivation: str | None = None
|
|
39
|
+
"""Most common motivation intent across events in this block, or None."""
|
|
40
|
+
|
|
41
|
+
minority_phases: tuple[tuple[str, int], ...] = ()
|
|
42
|
+
"""Phase signals suggesting a different phase during this block, sorted by
|
|
43
|
+
count desc. E.g. (('exploration', 3),) means 3 exploration-signal events
|
|
44
|
+
appeared inside an implementation block."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def duration_seconds(self) -> float:
|
|
48
|
+
return (self.end_time - self.start_time).total_seconds()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PhaseTransition:
|
|
53
|
+
"""A boundary between two adjacent phase blocks. Derivable from consecutive
|
|
54
|
+
blocks; surfaced directly for downstream consumers."""
|
|
55
|
+
|
|
56
|
+
session_id: str
|
|
57
|
+
from_phase: str
|
|
58
|
+
to_phase: str
|
|
59
|
+
timestamp: datetime
|
|
60
|
+
"""Timestamp of the first event in the new block."""
|
|
61
|
+
|
|
62
|
+
trigger_event_id: str
|
|
63
|
+
"""ID of the event that caused the boundary to commit."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class PhaseStats:
|
|
68
|
+
"""Aggregate stats for a single phase across the session."""
|
|
69
|
+
|
|
70
|
+
phase: str
|
|
71
|
+
event_count: int
|
|
72
|
+
block_count: int
|
|
73
|
+
total_duration_seconds: float
|
|
74
|
+
fraction_of_events: float
|
|
75
|
+
fraction_of_duration: float
|
|
76
|
+
avg_block_duration_seconds: float
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class PhaseTimeline:
|
|
81
|
+
"""Complete phase segmentation of a session.
|
|
82
|
+
|
|
83
|
+
Built incrementally — blocks are appended as they close. The last block may
|
|
84
|
+
be open (still accumulating events) when produced via ``snapshot()``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
session_id: str
|
|
88
|
+
blocks: tuple[PhaseBlock, ...] = ()
|
|
89
|
+
transitions: tuple[PhaseTransition, ...] = ()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def total_events(self) -> int:
|
|
93
|
+
return sum(b.event_count for b in self.blocks)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def total_duration_seconds(self) -> float:
|
|
97
|
+
if not self.blocks:
|
|
98
|
+
return 0.0
|
|
99
|
+
return (self.blocks[-1].end_time - self.blocks[0].start_time).total_seconds()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class PhaseSummary:
|
|
104
|
+
"""Aggregate statistics derived from a finalized PhaseTimeline.
|
|
105
|
+
|
|
106
|
+
Provides the '60% implementation, 25% exploration' view.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
session_id: str
|
|
110
|
+
total_events: int
|
|
111
|
+
total_duration_seconds: float
|
|
112
|
+
by_phase: tuple[PhaseStats, ...] = ()
|
|
113
|
+
transition_count: int = 0
|
|
114
|
+
most_common_transitions: tuple[tuple[str, str, int], ...] = ()
|
|
115
|
+
"""Top transition pairs (from, to, count), sorted desc."""
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Streaming phase segmentation: the debounced majority-vote PhaseTracker.
|
|
2
|
+
|
|
3
|
+
Consumes per-event phase signals one at a time and maintains an
|
|
4
|
+
incrementally-built phase timeline. The algorithm is a debounced majority vote
|
|
5
|
+
over a sliding window of per-event phase signals: the current block's phase is
|
|
6
|
+
the window mode, and a new block opens only after the mode changes for
|
|
7
|
+
``debounce`` consecutive events. O(1) per event.
|
|
8
|
+
|
|
9
|
+
The per-event phase signal is the existing ``metadata.phases`` estimate the
|
|
10
|
+
enricher already produces, collapsed to a single dominant phase. This module is
|
|
11
|
+
the *phase* system only; it is independent of the activity/step segmentation
|
|
12
|
+
system and does not consume or emit activity labels.
|
|
13
|
+
|
|
14
|
+
See docs/design-phase-tracker.md for the design and rationale (why not
|
|
15
|
+
BOCPD/HMM: short sessions, crisp categorical phase labels, no posterior
|
|
16
|
+
needed). All numeric knobs come from :class:`PhaseTrackerConfig`; nothing is
|
|
17
|
+
hardcoded here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import Counter, deque
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
from traceforge.config.models import PhaseTrackerConfig
|
|
26
|
+
|
|
27
|
+
from .models import (
|
|
28
|
+
PhaseBlock,
|
|
29
|
+
PhaseStats,
|
|
30
|
+
PhaseSummary,
|
|
31
|
+
PhaseTimeline,
|
|
32
|
+
PhaseTransition,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_phase_root(phase: str, depth: int) -> str:
|
|
37
|
+
"""Group a phase dot-path to the root used for boundary comparison.
|
|
38
|
+
|
|
39
|
+
``resolve_phase_root('verification.lint', 1) == 'verification'``. The
|
|
40
|
+
tracker compares roots when deciding boundaries, so that sub-phase churn
|
|
41
|
+
(``verification.lint`` -> ``verification.test``) does not open a new block.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
if depth <= 0:
|
|
45
|
+
return phase
|
|
46
|
+
return ".".join(phase.split(".")[:depth])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _mode(window: deque[str]) -> str:
|
|
50
|
+
"""Most common element in the window, ties broken by most-recent occurrence.
|
|
51
|
+
|
|
52
|
+
Recency tie-breaking keeps the tracker responsive: when two signals are
|
|
53
|
+
equally frequent, the fresher one wins.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
counts = Counter(window)
|
|
57
|
+
top = max(counts.values())
|
|
58
|
+
for item in reversed(window):
|
|
59
|
+
if counts[item] == top:
|
|
60
|
+
return item
|
|
61
|
+
return window[-1] # unreachable for non-empty window
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _OpenBlock:
|
|
65
|
+
"""Mutable accumulator for the block currently being built."""
|
|
66
|
+
|
|
67
|
+
__slots__ = (
|
|
68
|
+
"session_id",
|
|
69
|
+
"phase_root",
|
|
70
|
+
"start_time",
|
|
71
|
+
"end_time",
|
|
72
|
+
"event_count",
|
|
73
|
+
"tool_names",
|
|
74
|
+
"_motivations",
|
|
75
|
+
"_phase_roots",
|
|
76
|
+
"_full_phases",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def __init__(self, session_id: str, phase_root: str, timestamp: datetime) -> None:
|
|
80
|
+
self.session_id = session_id
|
|
81
|
+
self.phase_root = phase_root
|
|
82
|
+
self.start_time = timestamp
|
|
83
|
+
self.end_time = timestamp
|
|
84
|
+
self.event_count = 0
|
|
85
|
+
self.tool_names: list[str] = []
|
|
86
|
+
self._motivations: list[str] = []
|
|
87
|
+
self._phase_roots: list[str] = []
|
|
88
|
+
self._full_phases: list[str] = []
|
|
89
|
+
|
|
90
|
+
def add(
|
|
91
|
+
self,
|
|
92
|
+
phase: str,
|
|
93
|
+
phase_root: str,
|
|
94
|
+
timestamp: datetime,
|
|
95
|
+
tool_name: str | None,
|
|
96
|
+
motivation: str | None,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.event_count += 1
|
|
99
|
+
self.end_time = timestamp
|
|
100
|
+
self._full_phases.append(phase)
|
|
101
|
+
self._phase_roots.append(phase_root)
|
|
102
|
+
if tool_name:
|
|
103
|
+
self.tool_names.append(tool_name)
|
|
104
|
+
if motivation:
|
|
105
|
+
self._motivations.append(motivation)
|
|
106
|
+
|
|
107
|
+
def _dominant_phase(self) -> str:
|
|
108
|
+
"""Most common full phase dot-path in the block (falls back to root)."""
|
|
109
|
+
|
|
110
|
+
if not self._full_phases:
|
|
111
|
+
return self.phase_root
|
|
112
|
+
return Counter(self._full_phases).most_common(1)[0][0]
|
|
113
|
+
|
|
114
|
+
def _dominant_motivation(self) -> str | None:
|
|
115
|
+
if not self._motivations:
|
|
116
|
+
return None
|
|
117
|
+
return Counter(self._motivations).most_common(1)[0][0]
|
|
118
|
+
|
|
119
|
+
def _minority_phases(self) -> tuple[tuple[str, int], ...]:
|
|
120
|
+
minority = Counter(r for r in self._phase_roots if r != self.phase_root)
|
|
121
|
+
return tuple(sorted(minority.items(), key=lambda kv: (-kv[1], kv[0])))
|
|
122
|
+
|
|
123
|
+
def close(self) -> PhaseBlock:
|
|
124
|
+
return PhaseBlock(
|
|
125
|
+
session_id=self.session_id,
|
|
126
|
+
phase=self._dominant_phase(),
|
|
127
|
+
phase_root=self.phase_root,
|
|
128
|
+
start_time=self.start_time,
|
|
129
|
+
end_time=self.end_time,
|
|
130
|
+
event_count=self.event_count,
|
|
131
|
+
tool_names=tuple(self.tool_names),
|
|
132
|
+
dominant_motivation=self._dominant_motivation(),
|
|
133
|
+
minority_phases=self._minority_phases(),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PhaseTracker:
|
|
138
|
+
"""Streaming phase segmentation for a single session.
|
|
139
|
+
|
|
140
|
+
Single-writer; no locking. Feed events in session order via
|
|
141
|
+
:meth:`observe`; query the live phase via :attr:`phase`; produce a frozen
|
|
142
|
+
:class:`PhaseTimeline` via :meth:`snapshot`/:meth:`finalize`.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, session_id: str, config: PhaseTrackerConfig | None = None) -> None:
|
|
146
|
+
self.session_id = session_id
|
|
147
|
+
self._config = config or PhaseTrackerConfig()
|
|
148
|
+
self._window: deque[str] = deque(maxlen=max(1, self._config.window_size))
|
|
149
|
+
self._open: _OpenBlock | None = None
|
|
150
|
+
self._closed_blocks: list[PhaseBlock] = []
|
|
151
|
+
self._transitions: list[PhaseTransition] = []
|
|
152
|
+
self._candidate_streak = 0
|
|
153
|
+
self._finalized = False
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def phase(self) -> str | None:
|
|
157
|
+
"""Phase of the currently-open block (real-time query)."""
|
|
158
|
+
|
|
159
|
+
return self._open.phase_root if self._open is not None else None
|
|
160
|
+
|
|
161
|
+
def observe(
|
|
162
|
+
self,
|
|
163
|
+
phase: str,
|
|
164
|
+
timestamp: datetime,
|
|
165
|
+
event_id: str,
|
|
166
|
+
*,
|
|
167
|
+
tool_name: str | None = None,
|
|
168
|
+
motivation: str | None = None,
|
|
169
|
+
) -> tuple[str, PhaseTransition | None]:
|
|
170
|
+
"""Process one event.
|
|
171
|
+
|
|
172
|
+
``phase`` is the per-event phase signal (the enricher's collapsed
|
|
173
|
+
``metadata.phases`` estimate). Returns ``(current_phase, transition_or_None)``;
|
|
174
|
+
``current_phase`` is the debounced phase this tracker intends its consumer
|
|
175
|
+
to stamp onto ``event.metadata.phase``. (This v1 tracker is not wired into
|
|
176
|
+
the live pipeline; the trained ``PhaseInferencer`` is the current producer
|
|
177
|
+
of ``metadata.phase``.)
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
if self._finalized:
|
|
181
|
+
raise RuntimeError("PhaseTracker.observe called after finalize()")
|
|
182
|
+
|
|
183
|
+
root = resolve_phase_root(phase, self._config.phase_root_depth)
|
|
184
|
+
self._window.append(root)
|
|
185
|
+
transition: PhaseTransition | None = None
|
|
186
|
+
|
|
187
|
+
if self._open is None:
|
|
188
|
+
# First event seeds the first block; no transition.
|
|
189
|
+
self._open = _OpenBlock(self.session_id, root, timestamp)
|
|
190
|
+
self._candidate_streak = 0
|
|
191
|
+
else:
|
|
192
|
+
new_mode = _mode(self._window)
|
|
193
|
+
if new_mode == self._open.phase_root:
|
|
194
|
+
self._candidate_streak = 0
|
|
195
|
+
else:
|
|
196
|
+
self._candidate_streak += 1
|
|
197
|
+
if self._candidate_streak >= self._config.debounce:
|
|
198
|
+
self._closed_blocks.append(self._open.close())
|
|
199
|
+
from_phase = self._open.phase_root
|
|
200
|
+
self._open = _OpenBlock(self.session_id, new_mode, timestamp)
|
|
201
|
+
transition = PhaseTransition(
|
|
202
|
+
session_id=self.session_id,
|
|
203
|
+
from_phase=from_phase,
|
|
204
|
+
to_phase=new_mode,
|
|
205
|
+
timestamp=timestamp,
|
|
206
|
+
trigger_event_id=event_id,
|
|
207
|
+
)
|
|
208
|
+
self._transitions.append(transition)
|
|
209
|
+
self._candidate_streak = 0
|
|
210
|
+
|
|
211
|
+
self._open.add(phase, root, timestamp, tool_name, motivation)
|
|
212
|
+
return self._open.phase_root, transition
|
|
213
|
+
|
|
214
|
+
def snapshot(self) -> PhaseTimeline:
|
|
215
|
+
"""Immutable snapshot of the timeline so far, including the open block."""
|
|
216
|
+
|
|
217
|
+
blocks = tuple(self._closed_blocks)
|
|
218
|
+
if self._open is not None:
|
|
219
|
+
blocks += (self._open.close(),)
|
|
220
|
+
return PhaseTimeline(
|
|
221
|
+
session_id=self.session_id,
|
|
222
|
+
blocks=blocks,
|
|
223
|
+
transitions=tuple(self._transitions),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def finalize(self) -> PhaseTimeline:
|
|
227
|
+
"""Close the session and return the final timeline. Idempotent."""
|
|
228
|
+
|
|
229
|
+
if not self._finalized:
|
|
230
|
+
if self._open is not None:
|
|
231
|
+
self._closed_blocks.append(self._open.close())
|
|
232
|
+
self._open = None
|
|
233
|
+
self._finalized = True
|
|
234
|
+
return PhaseTimeline(
|
|
235
|
+
session_id=self.session_id,
|
|
236
|
+
blocks=tuple(self._closed_blocks),
|
|
237
|
+
transitions=tuple(self._transitions),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def summarize(self) -> PhaseSummary:
|
|
241
|
+
"""Aggregate statistics from the current (or finalized) timeline."""
|
|
242
|
+
|
|
243
|
+
timeline = self.snapshot() if not self._finalized else self.finalize()
|
|
244
|
+
blocks = timeline.blocks
|
|
245
|
+
total_events = sum(b.event_count for b in blocks)
|
|
246
|
+
total_duration = timeline.total_duration_seconds
|
|
247
|
+
|
|
248
|
+
by_phase: dict[str, dict[str, float]] = {}
|
|
249
|
+
for b in blocks:
|
|
250
|
+
agg = by_phase.setdefault(
|
|
251
|
+
b.phase_root,
|
|
252
|
+
{"event_count": 0, "block_count": 0, "duration": 0.0},
|
|
253
|
+
)
|
|
254
|
+
agg["event_count"] += b.event_count
|
|
255
|
+
agg["block_count"] += 1
|
|
256
|
+
agg["duration"] += b.duration_seconds
|
|
257
|
+
|
|
258
|
+
stats = tuple(
|
|
259
|
+
PhaseStats(
|
|
260
|
+
phase=phase,
|
|
261
|
+
event_count=int(agg["event_count"]),
|
|
262
|
+
block_count=int(agg["block_count"]),
|
|
263
|
+
total_duration_seconds=agg["duration"],
|
|
264
|
+
fraction_of_events=(agg["event_count"] / total_events) if total_events else 0.0,
|
|
265
|
+
fraction_of_duration=(agg["duration"] / total_duration) if total_duration else 0.0,
|
|
266
|
+
avg_block_duration_seconds=(agg["duration"] / agg["block_count"])
|
|
267
|
+
if agg["block_count"]
|
|
268
|
+
else 0.0,
|
|
269
|
+
)
|
|
270
|
+
for phase, agg in sorted(
|
|
271
|
+
by_phase.items(), key=lambda kv: (-kv[1]["event_count"], kv[0])
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
transition_pairs = Counter((t.from_phase, t.to_phase) for t in timeline.transitions)
|
|
276
|
+
most_common = tuple(
|
|
277
|
+
(frm, to, count)
|
|
278
|
+
for (frm, to), count in sorted(transition_pairs.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return PhaseSummary(
|
|
282
|
+
session_id=self.session_id,
|
|
283
|
+
total_events=total_events,
|
|
284
|
+
total_duration_seconds=total_duration,
|
|
285
|
+
by_phase=stats,
|
|
286
|
+
transition_count=len(timeline.transitions),
|
|
287
|
+
most_common_transitions=most_common,
|
|
288
|
+
)
|