File size: 15,445 Bytes
ce11d27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
"""
Result models returned by the analysis pipeline.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, List, Optional

import numpy as np

from tracescope.models.trace import TraceSession


@dataclass
class ClusterResult:
    """Clustering output.

    Attributes:
        n_clusters: Number of clusters found.
        labels: Cluster label per entry (length N).
        clusters: Per-cluster info dicts with keys:
                  cluster_id, indices, centroid.
    """

    n_clusters: int
    labels: List[int]
    clusters: List[dict]


@dataclass
class AxisInfo:
    """PCA axis information for the 3D projection.

    Attributes:
        axes: (3, D) array – principal directions in high-dim space.
        lengths: Variance explained per axis (length 3).
        labels: LLM-generated semantic labels (length 3).
        min_point_idx: Index of the point at the negative extreme per axis.
        max_point_idx: Index of the point at the positive extreme per axis.
        min_cluster_idx: Cluster at negative extreme per axis.
        max_cluster_idx: Cluster at positive extreme per axis.
    """

    axes: np.ndarray
    lengths: List[float]
    labels: List[str] = field(default_factory=lambda: ["Axis 1", "Axis 2", "Axis 3"])
    min_point_idx: List[int] = field(default_factory=list)
    max_point_idx: List[int] = field(default_factory=list)
    min_cluster_idx: List[int] = field(default_factory=list)
    max_cluster_idx: List[int] = field(default_factory=list)


@dataclass
class AnalysisResult:
    """Full result of the analysis pipeline.

    Attributes:
        session: The analyzed TraceSession.
        embedding_model: Name of the embedding model used.
        embeddings: (N, D) high-dim embedding matrix.
        clusters: Clustering result.
        projected_3d: (N, 3) projected coordinates.
        axis_info: PCA axis information with semantic labels.
        cluster_labels: LLM-generated description per cluster.
        flow_model_trained: Whether the flow model was successfully trained.
        segments: Optional segmentation result.
    """

    session: TraceSession
    embedding_model: str
    embeddings: np.ndarray
    clusters: ClusterResult
    projected_3d: np.ndarray
    axis_info: AxisInfo
    cluster_labels: List[str] = field(default_factory=list)
    flow_model_trained: bool = False
    segments: Optional[List[dict]] = None
    # Flow field data (from MDN model)
    velocity_grid: Optional[np.ndarray] = None       # (40,40,40,3) velocity field
    confidence_grid: Optional[np.ndarray] = None     # (40,40,40) MDN confidence [0,1]
    axis_min: Optional[np.ndarray] = None             # bounding box min [3]
    axis_max: Optional[np.ndarray] = None             # bounding box max [3]
    mdn_simulate: Optional[Any] = None                # MDN simulate() callable
    fitted_reducer: Optional[Any] = None               # fitted UMAP/tSNE for .transform()
    # Cluster geometry in 3D projected space
    cluster_centroids_3d: Optional[np.ndarray] = None # (K,3) cluster centers
    max_cluster_distance: float = 1.0                 # max pairwise dist between centroids
    cache_path: Optional[str] = None                  # base cache path for attractor caching
    flow_mode: str = "mdn"                             # flow model type used ("mdn" or "rbf")
    seed: int = 42                                     # global seed used by every randomized stage
    deterministic: bool = True                         # whether RNGs were seeded this run

    @property
    def n_entries(self) -> int:
        return len(self.session)

    @property
    def score_channels(self) -> List[str]:
        """Return sorted list of all score channel names available in the data."""
        return self.session.score_channels

    def get_entry_scores(self, channel: str) -> List[Optional[float]]:
        """Return score values for a channel across all entries (None if missing)."""
        return [
            e.scores.get(channel) for e in self.session.entries
        ]

    def get_path_scores(self, channel: str) -> dict:
        """Return {path_id: score} for a channel from path_scores."""
        return {
            pid: scores[channel]
            for pid, scores in self.session.path_scores.items()
            if channel in scores
        }

    def find_attractors(self, score_channel: Optional[str] = None) -> list:
        """Find flow attractors in the velocity field.

        Detects regions where flow converges and particles accumulate,
        distinguishing real attractors from boundary effects and dead zones.
        Computed on demand from the velocity grid (fast, ~0.1s).

        Args:
            score_channel: Optional score channel name to compute mean
                score within each attractor's basin (e.g. "solved" to see
                if the attractor is positive or negative).

        Returns:
            List of attractor dicts with keys:
                position, strength, divergence, basin_mask,
                basin_size, basin_fraction, mean_score
        """
        if self.velocity_grid is None:
            return []
        from tracescope.visualization.flow_field import FlowFieldSystem
        flow = FlowFieldSystem(
            self.velocity_grid, self.axis_min, self.axis_max,
            particle_grid=2,  # minimal β€” we only need the grid, not particles
            confidence_grid=self.confidence_grid,
        )
        # Build score grid if requested
        score_grid = None
        if score_channel and self.score_channels:
            if score_channel in self.score_channels:
                from tracescope.visualization.gl_renderer import FlowRenderer
                # Get normalized scores per entry
                raw = self.get_entry_scores(score_channel)
                path_scores = self.get_path_scores(score_channel)
                vals = []
                for i, entry in enumerate(self.session.entries):
                    v = raw[i]
                    if v is None and entry.path_id is not None:
                        v = path_scores.get(entry.path_id)
                    vals.append(float(v) if v is not None else float('nan'))
                arr = np.array(vals, dtype=np.float32)
                valid = ~np.isnan(arr)
                if valid.any():
                    lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr))
                    if hi - lo > 1e-8:
                        arr[valid] = (arr[valid] - lo) / (hi - lo)
                    else:
                        arr[valid] = 0.5
                    arr[~valid] = 0.5
                    flow.build_score_grid(self.projected_3d, arr)
                    score_grid = flow._score_grid
        return flow.find_attractors(score_grid=score_grid)

    def get_cluster_texts(self, cluster_id: int) -> List[str]:
        """Return texts belonging to a specific cluster."""
        return [
            self.session.entries[i].text
            for i, lbl in enumerate(self.clusters.labels)
            if lbl == cluster_id
        ]

    def get_entry_at_3d(self, idx: int) -> dict:
        """Return entry info with its 3D coordinates."""
        entry = self.session.entries[idx]
        coords = self.projected_3d[idx]
        return {
            "text": entry.text,
            "role": entry.role,
            "x": float(coords[0]),
            "y": float(coords[1]),
            "z": float(coords[2]),
            "cluster": self.clusters.labels[idx],
            "step_index": entry.step_index,
        }

    def fingerprint(self) -> str:
        """Compute a fingerprint from (texts + embedding_model + flow_mode
        + score channel names [+ seed]).

        Score channels are included so that changing the channel set
        (e.g. dropping a score or adding a new one) invalidates the
        cache β€” otherwise a cached result would be loaded with a stale
        score layout.

        Backward-compat: when run with the historical defaults
        (deterministic=True AND seed=42) the seed is *omitted* from
        the fingerprint, producing the exact same hash as pre-seed
        versions of TraceScope.  This keeps existing cached results
        valid bit-for-bit on disk, so users who never touch the seed
        don't need to recompute anything.  Non-default seeds and
        non-deterministic runs append a seed suffix, giving each
        alternate run its own distinct cached result.
        """
        texts = sorted(e.text for e in self.session.entries)
        score_channels = sorted(self.session.score_channels)
        is_default_seed = bool(self.deterministic) and int(self.seed) == 42
        if is_default_seed:
            blob = (
                json.dumps(texts, sort_keys=True) + "|"
                + self.embedding_model + "|"
                + self.flow_mode + "|"
                + json.dumps(score_channels, sort_keys=True)
            )
        else:
            seed_key = (
                f"{int(self.seed)}" if self.deterministic else "nondet"
            )
            blob = (
                json.dumps(texts, sort_keys=True) + "|"
                + self.embedding_model + "|"
                + self.flow_mode + "|"
                + json.dumps(score_channels, sort_keys=True) + "|"
                + seed_key
            )
        return hashlib.sha256(blob.encode("utf-8")).hexdigest()

    def save_result(self, path: str):
        """Save the full AnalysisResult to disk.

        Creates two files:
          - {path}.npz  β€” numpy arrays (embeddings, projected_3d, velocity_grid, etc.)
          - {path}.json β€” metadata (session, cluster info, axis labels, etc.)

        Args:
            path: Base path (without extension). E.g. "my_result" creates
                  my_result.npz and my_result.json.
        """
        base = Path(path)

        # ── Numpy arrays ───────────────────────────────
        arrays = {
            "embeddings": self.embeddings,
            "projected_3d": self.projected_3d,
        }
        if self.velocity_grid is not None:
            arrays["velocity_grid"] = self.velocity_grid
        if self.confidence_grid is not None:
            arrays["confidence_grid"] = self.confidence_grid
        if self.axis_min is not None:
            arrays["axis_min"] = self.axis_min
        if self.axis_max is not None:
            arrays["axis_max"] = self.axis_max
        if self.axis_info.axes is not None:
            arrays["axis_info_axes"] = self.axis_info.axes
        if self.cluster_centroids_3d is not None:
            arrays["cluster_centroids_3d"] = self.cluster_centroids_3d

        base.parent.mkdir(parents=True, exist_ok=True)
        np.savez_compressed(str(base) + ".npz", **arrays)

        # ── JSON sidecar ───────────────────────────────
        meta = {
            "fingerprint": self.fingerprint(),
            "embedding_model": self.embedding_model,
            "session": self.session.to_dict(),
            "clusters": {
                "n_clusters": self.clusters.n_clusters,
                "labels": self.clusters.labels,
                "clusters": self.clusters.clusters,
            },
            "axis_info": {
                "lengths": self.axis_info.lengths,
                "labels": self.axis_info.labels,
                "min_point_idx": self.axis_info.min_point_idx,
                "max_point_idx": self.axis_info.max_point_idx,
                "min_cluster_idx": self.axis_info.min_cluster_idx,
                "max_cluster_idx": self.axis_info.max_cluster_idx,
            },
            "cluster_labels": self.cluster_labels,
            "flow_model_trained": self.flow_model_trained,
            "max_cluster_distance": self.max_cluster_distance,
            "flow_mode": self.flow_mode,
            "seed": int(self.seed),
            "deterministic": bool(self.deterministic),
        }

        with open(str(base) + ".json", "w", encoding="utf-8") as f:
            json.dump(meta, f, indent=2, ensure_ascii=False)

    @classmethod
    def load_result(cls, path: str) -> "AnalysisResult":
        """Load an AnalysisResult from disk.

        Args:
            path: Base path (without extension) matching a previous save_result() call.

        Returns:
            Reconstructed AnalysisResult (without mdn_simulate or fitted_reducer).
        """
        base = Path(path)

        # ── JSON sidecar ───────────────────────────────
        with open(str(base) + ".json", "r", encoding="utf-8") as f:
            meta = json.load(f)

        # ── Numpy arrays ───────────────────────────────
        data = np.load(str(base) + ".npz", allow_pickle=False)

        session = TraceSession.from_dict(meta["session"])

        # Restore embeddings on entries
        embeddings = data["embeddings"]
        for i, entry in enumerate(session.entries):
            entry.embedding = embeddings[i]
            entry.model_name = meta["embedding_model"]

        clusters_meta = meta["clusters"]
        clusters = ClusterResult(
            n_clusters=clusters_meta["n_clusters"],
            labels=clusters_meta["labels"],
            clusters=clusters_meta["clusters"],
        )

        ai = meta["axis_info"]
        axis_info = AxisInfo(
            axes=data["axis_info_axes"] if "axis_info_axes" in data else np.eye(3),
            lengths=ai["lengths"],
            labels=ai["labels"],
            min_point_idx=ai.get("min_point_idx", []),
            max_point_idx=ai.get("max_point_idx", []),
            min_cluster_idx=ai.get("min_cluster_idx", []),
            max_cluster_idx=ai.get("max_cluster_idx", []),
        )

        import warnings
        warnings.warn(
            "AnalysisResult.load_result() does not restore the fitted UMAP/t-SNE "
            "reducer. TraceQuery projections (query_flow_at, explain_path, etc.) "
            "will use k-NN interpolation instead of the original transform. "
            "For highest accuracy, use pipeline.analyze() with cache_path.",
            UserWarning, stacklevel=2,
        )

        return cls(
            session=session,
            embedding_model=meta["embedding_model"],
            embeddings=embeddings,
            clusters=clusters,
            projected_3d=data["projected_3d"],
            axis_info=axis_info,
            cluster_labels=meta.get("cluster_labels", []),
            flow_model_trained=meta.get("flow_model_trained", False),
            velocity_grid=data["velocity_grid"] if "velocity_grid" in data else None,
            confidence_grid=data["confidence_grid"] if "confidence_grid" in data else None,
            axis_min=data["axis_min"] if "axis_min" in data else None,
            axis_max=data["axis_max"] if "axis_max" in data else None,
            cluster_centroids_3d=data["cluster_centroids_3d"] if "cluster_centroids_3d" in data else None,
            max_cluster_distance=meta.get("max_cluster_distance", 1.0),
            flow_mode=meta.get("flow_mode", "mdn"),
            seed=int(meta.get("seed", 42)),
            deterministic=bool(meta.get("deterministic", True)),
        )