Spaces:
Sleeping
Sleeping
| """ | |
| AnalysisPipeline β orchestrates the full analysis flow: | |
| embed β cluster β reduce to 3D β compute axes β label axes β | |
| label clusters β train MDN flow model β build velocity grid β return AnalysisResult | |
| Faithful port of the Android EmotionApp pipeline with: | |
| - Multi-part axis labeling (TF-IDF keyword evolution, avoid mechanism) | |
| - Cluster labeling with avoidDesc + keyword differentiation | |
| - MDN flow model training + 40Β³ velocity grid building | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from typing import Callable, Optional, List | |
| import numpy as np | |
| from tracescope.config import TraceScopeConfig | |
| from tracescope.models.trace import TraceSession | |
| from tracescope.models.analysis import AnalysisResult, ClusterResult, AxisInfo | |
| from tracescope.providers.embedding import EmbeddingProvider | |
| from tracescope.providers.llm import LLMProvider | |
| from tracescope.storage.vector_store import VectorStore | |
| from tracescope.storage.cache import LLMResponseCache, ResultCache | |
| from tracescope.analysis.explainer import SemanticExplainer | |
| from tracescope.utils.hashing import embeddings_fingerprint | |
| logger = logging.getLogger(__name__) | |
| def _build_density_grid(grid_pts, data_pts, grid_size, path_ids=None): | |
| """Compute a data-density confidence grid from path coverage. | |
| For each grid cell, measures how close it is to real training paths. | |
| Grid cells near data get confidence ~1.0; empty regions far from any | |
| path get confidence ~0.0. | |
| The bandwidth is set to 3Γ the grid cell diagonal so that any cell | |
| within a few cells of real data stays bright, and only truly empty | |
| regions fade out. | |
| """ | |
| from scipy.spatial import cKDTree | |
| pts = np.asarray(data_pts, dtype=np.float32) | |
| # Build dense samples along path segments (not just scatter points) | |
| if path_ids is not None: | |
| pid = np.asarray(path_ids) | |
| same_path = pid[:-1] == pid[1:] | |
| else: | |
| same_path = np.ones(len(pts) - 1, dtype=bool) | |
| samples = [pts] | |
| for i in np.where(same_path)[0]: | |
| seg_len = float(np.linalg.norm(pts[i + 1] - pts[i])) | |
| if seg_len < 1e-8: | |
| continue | |
| median_step = float(np.median(np.linalg.norm( | |
| np.diff(pts, axis=0), axis=1)) + 1e-8) | |
| n_interp = max(2, int(np.ceil(seg_len * 4 / median_step))) | |
| n_interp = min(n_interp, 10) | |
| ts = np.linspace(0, 1, n_interp + 2)[1:-1] | |
| for t in ts: | |
| samples.append((pts[i] * (1 - t) + pts[i + 1] * t).reshape(1, 3)) | |
| all_samples = np.concatenate(samples, axis=0) | |
| tree = cKDTree(all_samples) | |
| dist_to_nearest, _ = tree.query(grid_pts, k=1) | |
| # Adaptive bandwidth: set so that the median-distance grid cell | |
| # gets confidence β 0.5. This guarantees meaningful contrast β | |
| # cells closer than median β conf > 0.5 (bright), | |
| # cells farther than median β conf < 0.5 (fading). | |
| bandwidth = float(np.median(dist_to_nearest)) | |
| bandwidth = max(bandwidth, 1e-6) | |
| confidence = np.exp(-(dist_to_nearest / bandwidth) ** 2).astype(np.float32) | |
| return confidence.reshape(grid_size, grid_size, grid_size) | |
| def _build_velocity_grid(axis_min, axis_max, grid_size=40, | |
| data_points=None, path_ids=None): | |
| """Build velocity grid from a trained flow model (MDN or RBF). | |
| Samples the model's velocity at each point on a grid_sizeΒ³ lattice. | |
| Computes a data-density confidence grid so that regions far from any | |
| real training data (where the model is extrapolating / "hallucinating") | |
| can be faded out. | |
| Args: | |
| data_points: (N, 3) array of projected_3d training points, used | |
| to compute the density-based confidence grid. | |
| path_ids: list of path IDs per point, so density accounts for | |
| interpolated path segments (not just scatter points). | |
| Returns: | |
| (velocity_grid, confidence_grid) tuple where: | |
| - velocity_grid: (G, G, G, 3) numpy array or None | |
| - confidence_grid: (G, G, G) numpy array in [0, 1] or None | |
| """ | |
| from tracescope.analysis.axes_nn import _state | |
| a_min = np.asarray(axis_min, dtype=np.float32) | |
| a_max = np.asarray(axis_max, dtype=np.float32) | |
| kind = _state.get("kind") | |
| # Build the grid point coordinates (shared by all model types) | |
| lin = [np.linspace(a_min[d], a_max[d], grid_size) for d in range(3)] | |
| gx, gy, gz = np.meshgrid(lin[0], lin[1], lin[2], indexing="ij") | |
| grid_pts = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1).astype(np.float32) | |
| # ββ RBF path: batch-evaluate predict_velocity ββββββββββββββββββ | |
| if kind == "rbf" and "predict_velocity" in _state: | |
| predict_fn = _state["predict_velocity"] | |
| vel = predict_fn(grid_pts) # (grid_sizeΒ³, 3) | |
| grid = vel.reshape(grid_size, grid_size, grid_size, 3).astype(np.float32) | |
| conf_grid = None | |
| if data_points is not None and len(data_points) >= 5: | |
| conf_grid = _build_density_grid(grid_pts, data_points, grid_size, path_ids) | |
| logger.info(f"Built {grid_size}Β³ velocity grid from RBF" | |
| f"{' + density confidence' if conf_grid is not None else ''}") | |
| return grid, conf_grid | |
| # ββ MDN path: requires PyTorch βββββββββββββββββββββββββββββββββ | |
| if kind != "mdn" or "net" not in _state: | |
| logger.warning("No compatible flow model in _state, cannot build velocity grid") | |
| return None, None | |
| try: | |
| import torch | |
| except ImportError: | |
| logger.warning("PyTorch not available, cannot build velocity grid") | |
| return None, None | |
| net = _state["net"] | |
| net.eval() | |
| with torch.no_grad(): | |
| t = torch.from_numpy(grid_pts) | |
| mu1, mu2, sigma1, sigma2, logit_pi = net(t) | |
| pi = torch.sigmoid(logit_pi).unsqueeze(-1) | |
| v = (pi * mu1 + (1 - pi) * mu2).numpy() | |
| grid = v.reshape(grid_size, grid_size, grid_size, 3).astype(np.float32) | |
| # Data-density confidence (works for both MDN and RBF) | |
| conf_grid = None | |
| if data_points is not None and len(data_points) >= 5: | |
| conf_grid = _build_density_grid(grid_pts, data_points, grid_size, path_ids) | |
| logger.info(f"Built {grid_size}Β³ velocity + density-confidence grid from MDN") | |
| return grid, conf_grid | |
| class AnalysisPipeline: | |
| """Main analysis pipeline. | |
| Args: | |
| config: TraceScopeConfig with API keys and settings. | |
| """ | |
| def __init__(self, config: TraceScopeConfig): | |
| self.config = config | |
| self._embedding_provider: EmbeddingProvider = config.create_embedding_provider() | |
| self._llm_provider: LLMProvider = config.create_llm_provider() | |
| self._llm_provider_complex: LLMProvider = config.create_llm_provider_complex() | |
| self._vector_store = VectorStore(config.chromadb_dir) | |
| self._cache = LLMResponseCache(config.cache_db_path, config.cache_enabled) | |
| self._result_cache = ResultCache(config.cache_db_path, config.cache_enabled) | |
| # Simple model for labeling, complex model for explanations | |
| self._explainer = SemanticExplainer(self._llm_provider, self._cache) | |
| self._explainer_complex = SemanticExplainer(self._llm_provider_complex, self._cache) | |
| def embedding_provider(self) -> EmbeddingProvider: | |
| return self._embedding_provider | |
| def embedding_provider(self, provider: EmbeddingProvider): | |
| self._embedding_provider = provider | |
| def llm_provider(self) -> LLMProvider: | |
| return self._llm_provider | |
| def llm_provider(self, provider: LLMProvider): | |
| self._llm_provider = provider | |
| self._explainer = SemanticExplainer(provider, self._cache) | |
| def explainer(self) -> SemanticExplainer: | |
| """Explainer using the complex model (for rich explanations).""" | |
| return self._explainer_complex | |
| def analyze( | |
| self, | |
| session: TraceSession, | |
| progress_callback: Optional[Callable[[str, float], None]] = None, | |
| train_flow: bool = True, | |
| flow_mode: Optional[str] = None, | |
| n_clusters: Optional[int] = None, | |
| mdn_hidden: Optional[int] = None, | |
| mdn_iters: Optional[int] = None, | |
| velocity_grid_size: Optional[int] = None, | |
| cache_path: Optional[str] = None, | |
| rbf_kernel: Optional[str] = None, | |
| rbf_smoothing: Optional[float] = None, | |
| min_k: int = 3, | |
| param_range: Optional[list] = None, | |
| deterministic: Optional[bool] = None, | |
| seed: Optional[int] = None, | |
| ) -> AnalysisResult: | |
| """Run the full analysis pipeline. | |
| Args: | |
| session: TraceSession to analyze. | |
| progress_callback: Optional callback(stage_name, progress_0_to_1). | |
| train_flow: Whether to train flow models (slower but enables flow viz). | |
| flow_mode: "mdn" (default, matching Android), "gpvf", or "rbf". | |
| n_clusters: Force a specific number of clusters (None = auto). | |
| mdn_hidden: Hidden layer size for MDN (default 100, matching Android). | |
| Higher = more expressive flow but slower training. Range: 50-300. | |
| mdn_iters: Maximum training iterations for MDN (default 8000). | |
| Higher = more refined flow but slower. Range: 2000-20000. | |
| velocity_grid_size: Resolution of the 3D velocity grid (default 40). | |
| Higher = smoother flow but uses more memory. Range: 20-60. | |
| cache_path: Optional base path (no extension) for full result caching. | |
| If provided and a valid cache exists with matching fingerprint, | |
| the entire pipeline is skipped and the cached result is returned | |
| instantly. On completion, the result is saved to this path. | |
| rbf_kernel: RBF kernel type (default "thin_plate_spline"). Options: | |
| "thin_plate_spline", "cubic", "linear", "quintic". | |
| rbf_smoothing: RBF regularization (default 0.1). 0 = exact interpolation. | |
| min_k: Minimum number of clusters for auto-selection (default 3). | |
| The silhouette search starts from this value. Range: 2-10. | |
| param_range: Optional list of n_neighbors values to search during | |
| dimension reduction (e.g. [45, 50, 55]). Default None = full | |
| search [5, 10, 15, ..., 195]. Use this to speed up re-runs | |
| when you already know the approximate best parameter. | |
| deterministic: If set, overrides `config.deterministic` for this | |
| run only. True seeds every stage; False lets each stage | |
| pick its own seed (different flow topology each run). | |
| seed: If set, overrides `config.seed` for this run only. The | |
| global integer seed used by every randomized stage when | |
| `deterministic=True` β MDN training (torch + numpy), | |
| KMeans clustering, and UMAP/t-SNE dimension reduction. | |
| Change this to experiment with alternative flow shapes, | |
| cluster layouts, and axis alignments without losing | |
| reproducibility. Included in the cache fingerprint, so a | |
| different seed re-runs the pipeline instead of loading a | |
| stale cached result. | |
| Returns: | |
| AnalysisResult with all computed data. | |
| """ | |
| # ββ Input validation βββββββββββββββββββββββββββββββββββββββββ | |
| if len(session) == 0: | |
| raise ValueError("Session has no entries. Provide at least 5 texts.") | |
| if len(session) < 5: | |
| raise ValueError( | |
| f"Need at least 5 entries for analysis, got {len(session)}. " | |
| f"Clustering, dimension reduction, and flow models require " | |
| f"a minimum sample size of 5." | |
| ) | |
| # ββ Fall back to config values for unset parameters ββββββββββ | |
| if flow_mode is None: | |
| flow_mode = self.config.flow_mode | |
| if mdn_hidden is None: | |
| mdn_hidden = self.config.mdn_hidden | |
| if mdn_iters is None: | |
| mdn_iters = self.config.mdn_iters | |
| if velocity_grid_size is None: | |
| velocity_grid_size = self.config.velocity_grid_size | |
| if rbf_kernel is None: | |
| rbf_kernel = self.config.rbf_kernel | |
| if rbf_smoothing is None: | |
| rbf_smoothing = self.config.rbf_smoothing | |
| if deterministic is None: | |
| deterministic = self.config.deterministic | |
| if seed is None: | |
| seed = getattr(self.config, "seed", 42) | |
| # When deterministic is disabled, pass random_state=None to | |
| # sklearn/UMAP so every call picks a fresh entropy source. | |
| # `_effective_seed` is what we thread into stages that accept a | |
| # seed value (MDN always receives an int and consults | |
| # `deterministic` separately). | |
| _effective_random_state = seed if deterministic else None | |
| # ββ Check full-result cache βββββββββββββββββββββββββββββββββββ | |
| if cache_path is not None: | |
| import hashlib as _hl | |
| from pathlib import Path as _Path | |
| _Path(cache_path).parent.mkdir(parents=True, exist_ok=True) | |
| json_path = _Path(cache_path + ".json") | |
| npz_path = _Path(cache_path + ".npz") | |
| if json_path.exists() and npz_path.exists(): | |
| try: | |
| with open(str(json_path), "r", encoding="utf-8") as _f: | |
| _meta = json.load(_f) | |
| # Build fingerprint from current inputs. Include | |
| # score channel names so that changing the channel | |
| # set (e.g. dropping found_error, adding path_length) | |
| # invalidates the cache β otherwise the cached result | |
| # would be loaded with stale scores missing. | |
| # | |
| # Backward-compat: when running with the historical | |
| # defaults (deterministic=True AND seed=42) we emit | |
| # the *exact* old fingerprint format so existing | |
| # cached results stay valid bit-for-bit and do NOT | |
| # need to be recomputed. Any non-default seed (or | |
| # deterministic=False) appends a seed suffix, giving | |
| # alternate runs their own distinct cached results. | |
| _texts = sorted(e.text for e in session.entries) | |
| _score_channels = sorted(session.score_channels) | |
| _is_default_seed = bool(deterministic) and int(seed) == 42 | |
| if _is_default_seed: | |
| _blob = ( | |
| json.dumps(_texts, sort_keys=True) + "|" | |
| + self._embedding_provider.model_name() + "|" | |
| + flow_mode + "|" | |
| + json.dumps(_score_channels, sort_keys=True) | |
| ) | |
| else: | |
| _seed_key = ( | |
| f"{int(seed)}" if deterministic else "nondet" | |
| ) | |
| _blob = ( | |
| json.dumps(_texts, sort_keys=True) + "|" | |
| + self._embedding_provider.model_name() + "|" | |
| + flow_mode + "|" | |
| + json.dumps(_score_channels, sort_keys=True) + "|" | |
| + _seed_key | |
| ) | |
| _fp = _hl.sha256(_blob.encode("utf-8")).hexdigest() | |
| if _meta.get("fingerprint") == _fp: | |
| logger.info("Full result cache hit β skipping pipeline") | |
| if progress_callback: | |
| progress_callback("cache_hit", 1.0) | |
| _result = AnalysisResult.load_result(cache_path) | |
| _result.cache_path = cache_path | |
| return _result | |
| else: | |
| logger.info("Cache fingerprint mismatch β re-running pipeline") | |
| except Exception as e: | |
| logger.warning(f"Cache load failed, re-running pipeline: {e}") | |
| def _progress(stage: str, pct: float): | |
| if progress_callback: | |
| progress_callback(stage, pct) | |
| logger.info(f"[{stage}] {pct*100:.0f}%") | |
| # ββ Step 1: Embed ββββββββββββββββββββββββββββββββββββββββββββββ | |
| _progress("embedding", 0.0) | |
| model_name = self._embedding_provider.model_name() | |
| # Check vector store first | |
| stored = self._vector_store.load_embeddings(session.session_id, model_name) | |
| if stored is not None and len(stored) == len(session): | |
| logger.info("Using stored embeddings from ChromaDB") | |
| embeddings = stored | |
| else: | |
| texts = session.texts | |
| embeddings = self._embedding_provider.embed_batch(texts) | |
| # Store for future use | |
| self._vector_store.store_embeddings( | |
| session.session_id, | |
| model_name, | |
| texts, | |
| embeddings, | |
| metadatas=[ | |
| {"role": e.role, "step_index": e.step_index} | |
| for e in session.entries | |
| ], | |
| ) | |
| # Update entries with embeddings | |
| for i, entry in enumerate(session.entries): | |
| entry.embedding = embeddings[i] | |
| entry.model_name = model_name | |
| _progress("embedding", 1.0) | |
| # ββ Step 2: Cluster (cached by embeddings fingerprint) ββββββββ | |
| _progress("clustering", 0.0) | |
| from tracescope.analysis.clustering import main as cluster_main | |
| emb_fp = embeddings_fingerprint(embeddings) | |
| # Include seed in the per-stage fingerprint so flipping seeds | |
| # does not return a stale result from a previous seed. | |
| _cluster_fp = ( | |
| emb_fp + f"|seed={seed if deterministic else 'nondet'}" | |
| + f"|mink={min_k}" | |
| ) | |
| cached_clustering = self._result_cache.get_result( | |
| "clustering", _cluster_fp | |
| ) | |
| if cached_clustering is not None: | |
| logger.info("Using cached clustering result") | |
| cluster_raw = json.loads(cached_clustering.decode("utf-8")) | |
| else: | |
| embeddings_json = json.dumps(embeddings.tolist()) | |
| cluster_result_json = cluster_main( | |
| embeddings_json, n_clusters=n_clusters, min_k=min_k, | |
| random_state=_effective_random_state, | |
| ) | |
| cluster_raw = json.loads(cluster_result_json) | |
| self._result_cache.put_result( | |
| "clustering", _cluster_fp, json.dumps(cluster_raw).encode("utf-8") | |
| ) | |
| clusters = ClusterResult( | |
| n_clusters=cluster_raw["n_clusters"], | |
| labels=cluster_raw["labels"], | |
| clusters=cluster_raw["clusters"], | |
| ) | |
| _progress("clustering", 1.0) | |
| # ββ Step 3: Reduce to 3D (cached by fingerprint + labels) βββββ | |
| _progress("dimension_reduction", 0.0) | |
| from tracescope.analysis.dimension_reducer import optimize_reduction, _last_fitted_reducer as _get_reducer | |
| import tracescope.analysis.dimension_reducer as _dr_mod | |
| import hashlib as _hashlib | |
| cluster_labels_json = json.dumps(clusters.labels) | |
| # dr_key includes labels (so it changes with clustering), but we | |
| # also mix in the seed so alternate seeds don't return a stale | |
| # projection from a previous run. | |
| _seed_tag = f"seed={seed if deterministic else 'nondet'}" | |
| dr_key = emb_fp + "_" + _hashlib.sha256( | |
| (cluster_labels_json + "|" + _seed_tag).encode("utf-8") | |
| ).hexdigest()[:16] | |
| cached_reduction = self._result_cache.get_result("dim_reduction", dr_key) | |
| if cached_reduction is not None: | |
| logger.info("Using cached dimension reduction result") | |
| n_entries = len(session) | |
| projected_3d = np.frombuffer( | |
| cached_reduction, dtype=np.float32 | |
| ).reshape(n_entries, 3).copy() | |
| else: | |
| embeddings_json = json.dumps(embeddings.tolist()) | |
| reduction_json = optimize_reduction( | |
| embeddings_json, cluster_labels_json, | |
| param_range=param_range, | |
| random_state=_effective_random_state, | |
| ) | |
| reduction_raw = json.loads(reduction_json) | |
| projected_3d = np.array(reduction_raw["embedding"], dtype=np.float32) | |
| self._result_cache.put_result( | |
| "dim_reduction", dr_key, projected_3d.tobytes() | |
| ) | |
| # Grab the fitted reducer (UMAP object) for projecting new points | |
| fitted_reducer = _dr_mod._last_fitted_reducer | |
| _progress("dimension_reduction", 1.0) | |
| # ββ Step 4: Compute axes ββββββββββββββββββββββββββββββββββββββ | |
| _progress("axes", 0.0) | |
| from tracescope.analysis.compute_axes_info import compute_axes_info | |
| projected_json = json.dumps(projected_3d.tolist()) | |
| axes_json = compute_axes_info(projected_json, cluster_labels_json) | |
| axes_raw = json.loads(axes_json) | |
| axis_info = AxisInfo( | |
| axes=np.array(axes_raw["axes"]), | |
| lengths=axes_raw["lengths"], | |
| min_point_idx=axes_raw["min_point_idx"], | |
| max_point_idx=axes_raw["max_point_idx"], | |
| min_cluster_idx=axes_raw.get("min_cluster_idx", []), | |
| max_cluster_idx=axes_raw.get("max_cluster_idx", []), | |
| ) | |
| _progress("axes", 1.0) | |
| # ββ Step 4.5: Compute cluster centroids in 3D ββββββββββββββββ | |
| n_cls = clusters.n_clusters | |
| cluster_centroids_3d = np.zeros((n_cls, 3), dtype=np.float32) | |
| for c in range(n_cls): | |
| mask = [i for i, l in enumerate(clusters.labels) if l == c] | |
| if mask: | |
| cluster_centroids_3d[c] = projected_3d[mask].mean(axis=0) | |
| # Max pairwise distance between centroids | |
| max_cluster_distance = 0.0 | |
| for i in range(n_cls): | |
| for j in range(i + 1, n_cls): | |
| d = float(np.linalg.norm(cluster_centroids_3d[i] - cluster_centroids_3d[j])) | |
| if d > max_cluster_distance: | |
| max_cluster_distance = d | |
| if max_cluster_distance == 0: | |
| max_cluster_distance = 1.0 | |
| # ββ Step 5: Label axes via LLM (multi-part pipeline) βββββββββ | |
| _progress("axis_labeling", 0.0) | |
| axis_names = ["X", "Y", "Z"] | |
| axis_labels = [] | |
| texts = session.texts | |
| for d in range(3): | |
| label = self._explainer.explain_axis( | |
| axis_dim=d, | |
| axis_name=axis_names[d], | |
| texts=texts, | |
| embeddings=embeddings, | |
| projected_3d=projected_3d, | |
| axis_info=axis_info, | |
| clusters=clusters, | |
| cluster_summaries=[], # No cluster summaries yet during axis labeling | |
| previous_labels=axis_labels.copy(), # Avoid mechanism | |
| ) | |
| axis_labels.append(label) | |
| _progress("axis_labeling", (d + 1) / 3) | |
| axis_info.labels = axis_labels | |
| # ββ Step 6: Label clusters via LLM (avoidDesc + TF-IDF) ββββββ | |
| _progress("cluster_labeling", 0.0) | |
| # Build all_cluster_texts for the differentiation pipeline | |
| all_cluster_texts = [] | |
| for c in range(clusters.n_clusters): | |
| c_texts = [ | |
| session.entries[i].text | |
| for i, lbl in enumerate(clusters.labels) | |
| if lbl == c | |
| ] | |
| all_cluster_texts.append(c_texts) | |
| cluster_labels_list: List[Optional[str]] = [None] * clusters.n_clusters | |
| for c in range(clusters.n_clusters): | |
| desc = self._explainer.label_cluster( | |
| cluster_index=c, | |
| all_cluster_texts=all_cluster_texts, | |
| embeddings=embeddings, | |
| cluster_labels=clusters.labels, | |
| prior_summaries=cluster_labels_list, # Grows as we label | |
| ) | |
| cluster_labels_list[c] = desc | |
| _progress("cluster_labeling", (c + 1) / clusters.n_clusters) | |
| # Replace None with fallback | |
| cluster_labels_final = [ | |
| lbl if lbl is not None else f"Cluster {c}" | |
| for c, lbl in enumerate(cluster_labels_list) | |
| ] | |
| # ββ Step 7: Train flow model (MDN or GPVF) βββββββββββββββββββ | |
| flow_trained = False | |
| velocity_grid = None | |
| confidence_grid = None | |
| axis_min_arr = None | |
| axis_max_arr = None | |
| mdn_simulate = None | |
| if train_flow and len(session) >= 5: | |
| _progress("flow_model", 0.0) | |
| if flow_mode == "mdn": | |
| try: | |
| import torch # noqa: F401 | |
| except ImportError: | |
| import warnings | |
| warnings.warn( | |
| "PyTorch is not installed β MDN flow model cannot be trained. " | |
| "Install PyTorch (`pip install torch`) or use flow_mode='rbf' " | |
| "for a lightweight scipy-based alternative.", | |
| UserWarning, stacklevel=2, | |
| ) | |
| try: | |
| from tracescope.analysis.axes_nn import train_models, _state | |
| cloud_json = json.dumps(projected_3d.tolist()) | |
| # Extract path_ids for multi-path MDN training | |
| _path_ids = None | |
| if any(e.path_id is not None for e in session.entries): | |
| _path_ids = [e.path_id for e in session.entries] | |
| # Forward model-specific params | |
| _flow_kwargs = {} | |
| if flow_mode == "mdn": | |
| _flow_kwargs["hidden"] = mdn_hidden | |
| _flow_kwargs["iters"] = mdn_iters | |
| _flow_kwargs["deterministic"] = deterministic | |
| _flow_kwargs["seed"] = int(seed) | |
| elif flow_mode == "rbf": | |
| _flow_kwargs["kernel"] = rbf_kernel | |
| _flow_kwargs["smoothing"] = rbf_smoothing | |
| result_json = train_models( | |
| cloud_json, mode=flow_mode, path_ids=_path_ids, | |
| **_flow_kwargs) | |
| result_data = json.loads(result_json) | |
| axis_min_arr = np.array(result_data["axis_min"], dtype=np.float32) | |
| axis_max_arr = np.array(result_data["axis_max"], dtype=np.float32) | |
| # Get simulate function from state | |
| if "simulate" in _state: | |
| mdn_simulate = _state["simulate"] | |
| flow_trained = True | |
| _progress("flow_model", 0.5) | |
| # Build velocity grid (cached by projected_3d fingerprint). | |
| # Include (flow_mode, seed, deterministic) so two runs with | |
| # different seeds produce separately-cached grids. | |
| if flow_mode in ("mdn", "rbf") and flow_trained: | |
| _progress("velocity_grid", 0.0) | |
| _raw_grid_fp = embeddings_fingerprint( | |
| projected_3d.reshape(len(projected_3d), -1) | |
| ) | |
| grid_fp = ( | |
| _raw_grid_fp | |
| + f"|{flow_mode}" | |
| + f"|seed={seed if deterministic else 'nondet'}" | |
| ) | |
| cached_grid = self._result_cache.get_result( | |
| "velocity_grid", grid_fp | |
| ) | |
| if cached_grid is not None: | |
| logger.info("Using cached velocity grid") | |
| velocity_grid = np.frombuffer( | |
| cached_grid, dtype=np.float32 | |
| ).reshape( | |
| velocity_grid_size, velocity_grid_size, | |
| velocity_grid_size, 3 | |
| ).copy() | |
| # Try loading cached confidence grid | |
| cached_conf = self._result_cache.get_result( | |
| "confidence_grid", grid_fp | |
| ) | |
| if cached_conf is not None: | |
| confidence_grid = np.frombuffer( | |
| cached_conf, dtype=np.float32 | |
| ).reshape( | |
| velocity_grid_size, velocity_grid_size, | |
| velocity_grid_size | |
| ).copy() | |
| else: | |
| velocity_grid, confidence_grid = _build_velocity_grid( | |
| axis_min_arr, axis_max_arr, velocity_grid_size, | |
| data_points=projected_3d, | |
| path_ids=_path_ids, | |
| ) | |
| if velocity_grid is not None: | |
| self._result_cache.put_result( | |
| "velocity_grid", grid_fp, | |
| velocity_grid.tobytes() | |
| ) | |
| if confidence_grid is not None: | |
| self._result_cache.put_result( | |
| "confidence_grid", grid_fp, | |
| confidence_grid.tobytes() | |
| ) | |
| _progress("velocity_grid", 1.0) | |
| except Exception as e: | |
| logger.warning(f"Flow model training failed: {e}") | |
| _progress("flow_model", 1.0) | |
| # ββ Build result ββββββββββββββββββββββββββββββββββββββββββββββ | |
| result = AnalysisResult( | |
| session=session, | |
| embedding_model=model_name, | |
| embeddings=embeddings, | |
| clusters=clusters, | |
| projected_3d=projected_3d, | |
| axis_info=axis_info, | |
| cluster_labels=cluster_labels_final, | |
| flow_model_trained=flow_trained, | |
| velocity_grid=velocity_grid, | |
| confidence_grid=confidence_grid, | |
| axis_min=axis_min_arr, | |
| axis_max=axis_max_arr, | |
| mdn_simulate=mdn_simulate, | |
| cluster_centroids_3d=cluster_centroids_3d, | |
| max_cluster_distance=max_cluster_distance, | |
| fitted_reducer=fitted_reducer, | |
| cache_path=cache_path, | |
| flow_mode=flow_mode, | |
| seed=int(seed), | |
| deterministic=bool(deterministic), | |
| ) | |
| # ββ Save to full-result cache βββββββββββββββββββββββββββββββββ | |
| if cache_path is not None: | |
| try: | |
| from pathlib import Path as _Path | |
| _Path(cache_path).parent.mkdir(parents=True, exist_ok=True) | |
| result.save_result(cache_path) | |
| logger.info(f"Saved full result cache to {cache_path}") | |
| except Exception as e: | |
| logger.warning(f"Failed to save result cache: {e}") | |
| return result | |
| def get_vectors( | |
| self, session_id: str, model_name: str | |
| ) -> Optional[np.ndarray]: | |
| """Return stored vectors for a session+model pair.""" | |
| return self._vector_store.load_embeddings(session_id, model_name) | |
| def compare_models( | |
| self, session_id: str, model_a: str, model_b: str | |
| ) -> dict: | |
| """Compare two embedding models' vectors for the same session.""" | |
| vecs_a = self._vector_store.load_embeddings(session_id, model_a) | |
| vecs_b = self._vector_store.load_embeddings(session_id, model_b) | |
| if vecs_a is None or vecs_b is None: | |
| raise ValueError( | |
| f"Missing vectors: {model_a}={'found' if vecs_a is not None else 'missing'}, " | |
| f"{model_b}={'found' if vecs_b is not None else 'missing'}. " | |
| f"If you loaded results from cache, the vector store is not " | |
| f"repopulated β re-run pipeline.analyze() without cache_path " | |
| f"to populate the vector store before calling compare_models()." | |
| ) | |
| if len(vecs_a) != len(vecs_b): | |
| raise ValueError( | |
| f"Vector count mismatch: {model_a} has {len(vecs_a)}, " | |
| f"{model_b} has {len(vecs_b)}" | |
| ) | |
| if vecs_a.shape[1] != vecs_b.shape[1]: | |
| raise ValueError( | |
| f"Dimension mismatch: {model_a} is {vecs_a.shape[1]}D, " | |
| f"{model_b} is {vecs_b.shape[1]}D β cosine similarity is undefined " | |
| f"between different embedding spaces." | |
| ) | |
| norms_a = np.linalg.norm(vecs_a, axis=1, keepdims=True) | |
| norms_b = np.linalg.norm(vecs_b, axis=1, keepdims=True) | |
| normed_a = vecs_a / norms_a | |
| normed_b = vecs_b / norms_b | |
| cosine_sims = np.sum(normed_a * normed_b, axis=1) | |
| return { | |
| "model_a": model_a, | |
| "model_b": model_b, | |
| "n_entries": len(vecs_a), | |
| "dim_a": vecs_a.shape[1], | |
| "dim_b": vecs_b.shape[1], | |
| "mean_cosine_similarity": float(np.mean(cosine_sims)), | |
| "min_cosine_similarity": float(np.min(cosine_sims)), | |
| "max_cosine_similarity": float(np.max(cosine_sims)), | |
| "per_entry_cosine_similarity": cosine_sims.tolist(), | |
| } | |
| def list_stored_sessions(self) -> list: | |
| """List all stored session/model combinations.""" | |
| return self._vector_store.list_sessions() | |
| def clear_cache(self, what: str = "all"): | |
| """Clear cached data. | |
| Args: | |
| what: What to clear: | |
| - "all" β clear everything (embeddings, LLM responses, ML results) | |
| - "results" β clear clustering, dim reduction, velocity grid caches | |
| - "llm" β clear LLM response cache only | |
| - "embeddings" β clear stored embeddings only | |
| """ | |
| if what in ("all", "results"): | |
| self._result_cache.clear() | |
| logger.info("Cleared result cache (clustering, dim reduction, velocity grid)") | |
| if what in ("all", "llm"): | |
| self._cache.clear() | |
| logger.info("Cleared LLM response cache") | |
| if what in ("all", "embeddings"): | |
| self._vector_store.clear_all() | |
| logger.info("Cleared embedding store") | |