File size: 16,752 Bytes
03e863f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
"""ROI Flow mode: manifold-to-ROI mapping and LLM interpretation.

Maps probe positions in a learned neural manifold to ROI activation vectors
using k-nearest-neighbor interpolation, then analyzes how the ROI pattern
changes along the probe path.

This module is used by examples/roi_flow_mode.py for the dual-window
manifold + ROI visualization.
"""

import os
from pathlib import Path

import numpy as np
from scipy.spatial import cKDTree


# ---------------------------------------------------------------------------
# Manifold-to-ROI mapper
# ---------------------------------------------------------------------------

class ManifoldToROIKNN:
    """Map manifold positions to ROI activation vectors via kNN + Gaussian weighting.

    Given a library of (embedding_point, roi_vector) pairs, interpolates
    the ROI vector at any manifold position using Gaussian-weighted kNN.
    """

    def __init__(self, embed_points: np.ndarray, roi_vectors: np.ndarray,
                 k: int = 256, sigma: float = 0.0):
        """
        Args:
            embed_points: (N, D) embedding coordinates (typically D=3).
            roi_vectors: (N, R) ROI activation vectors.
            k: number of nearest neighbors for interpolation.
            sigma: Gaussian bandwidth. If 0, auto-computed from median kNN distance.
        """
        assert embed_points.shape[0] == roi_vectors.shape[0], \
            f"embed ({embed_points.shape[0]}) and roi ({roi_vectors.shape[0]}) must match"
        self.X = embed_points.astype(np.float32)
        self.Y = roi_vectors.astype(np.float32)
        self.k = int(max(8, k))
        self.sigma = float(max(0.0, sigma))
        self.n_rois = self.Y.shape[1]
        self.tree = cKDTree(self.X)

    def query(self, point: np.ndarray) -> np.ndarray:
        """Interpolate ROI vector at a manifold position.

        Args:
            point: (D,) or (1,D) position in manifold space.

        Returns:
            (R,) ROI activation vector.
        """
        p = point.reshape(1, -1).astype(np.float32)
        k_actual = min(self.k, len(self.X))
        d, idx = self.tree.query(p, k=k_actual)
        d = np.asarray(d).ravel().astype(np.float32)
        idx = np.asarray(idx).ravel()

        if len(idx) == 0:
            return np.zeros(self.n_rois, dtype=np.float32)

        sig = self.sigma if self.sigma > 0 else float(np.median(d) + 1e-9)
        w = np.exp(-(d ** 2) / (2.0 * sig ** 2)).astype(np.float64)
        sw = float(np.sum(w)) + 1e-12
        mu = (w[:, None] * self.Y[idx]).sum(axis=0) / sw
        return mu.astype(np.float32)


# ---------------------------------------------------------------------------
# ROI flow analyzer
# ---------------------------------------------------------------------------

class ROIFlowAnalyzer:
    """Analyze ROI delta vectors to extract flow patterns for LLM interpretation."""

    def __init__(self, roi_names: list[str], roi_centers: np.ndarray):
        """
        Args:
            roi_names: (R,) human-readable ROI names.
            roi_centers: (R, 3) MNI coordinates of ROI centers.
        """
        self.roi_names = roi_names
        self.roi_centers = roi_centers.astype(np.float32)
        self.n_rois = len(roi_names)

    def compute_delta(self, start_roi: np.ndarray, end_roi: np.ndarray) -> np.ndarray:
        """Compute change in ROI activation from start to end of path."""
        return (end_roi - start_roi).astype(np.float32)

    def analyze_flow_pattern(self, delta: np.ndarray) -> dict:
        """Extract structured information about the ROI flow pattern.

        Returns dict with:
            top_positive: list of (name, delta_val, center) for top increased ROIs
            top_negative: list of (name, delta_val, center) for top decreased ROIs
            direction: dict with anterior_posterior, left_right, superior_inferior scores
            pattern_type: "one_to_many", "many_to_one", "distributed", "bilateral_split"
            bulk_direction: human-readable description of dominant flow direction
        """
        abs_delta = np.abs(delta)
        threshold = np.percentile(abs_delta, 85)

        # Top changed ROIs
        significant = abs_delta > threshold
        pos_mask = (delta > 0) & significant
        neg_mask = (delta < 0) & significant

        pos_indices = np.where(pos_mask)[0]
        neg_indices = np.where(neg_mask)[0]

        # Sort by magnitude
        pos_sorted = pos_indices[np.argsort(-delta[pos_indices])]
        neg_sorted = neg_indices[np.argsort(delta[neg_indices])]

        top_positive = [(self.roi_names[i], float(delta[i]),
                         self.roi_centers[i].tolist()) for i in pos_sorted[:10]]
        top_negative = [(self.roi_names[i], float(delta[i]),
                         self.roi_centers[i].tolist()) for i in neg_sorted[:10]]

        # Compute directional bias using weighted centroids
        pos_weighted_center = np.zeros(3)
        neg_weighted_center = np.zeros(3)
        if len(pos_indices) > 0:
            w = delta[pos_indices]
            pos_weighted_center = np.average(self.roi_centers[pos_indices], axis=0,
                                             weights=w)
        if len(neg_indices) > 0:
            w = np.abs(delta[neg_indices])
            neg_weighted_center = np.average(self.roi_centers[neg_indices], axis=0,
                                             weights=w)

        # Direction: from negative (source) to positive (target) centroids
        flow_vec = pos_weighted_center - neg_weighted_center
        direction = {
            "left_right": float(flow_vec[0]),      # +X = right
            "anterior_posterior": float(flow_vec[1]),  # +Y = anterior
            "superior_inferior": float(flow_vec[2]),   # +Z = superior
        }

        # Pattern type
        n_pos = len(pos_indices)
        n_neg = len(neg_indices)
        if n_neg <= 3 and n_pos > 8:
            pattern_type = "one_to_many"
        elif n_pos <= 3 and n_neg > 8:
            pattern_type = "many_to_one"
        elif n_pos > 0 and n_neg > 0:
            # Check bilateral split
            pos_x = self.roi_centers[pos_indices, 0]
            neg_x = self.roi_centers[neg_indices, 0]
            pos_mean_x = float(np.mean(pos_x))
            neg_mean_x = float(np.mean(neg_x))
            if abs(pos_mean_x - neg_mean_x) > 20:  # significant L/R separation
                pattern_type = "bilateral_split"
            else:
                pattern_type = "distributed"
        else:
            pattern_type = "distributed"

        # Human-readable bulk direction
        parts = []
        if abs(direction["anterior_posterior"]) > 10:
            parts.append("anterior" if direction["anterior_posterior"] > 0 else "posterior")
        if abs(direction["left_right"]) > 10:
            parts.append("right" if direction["left_right"] > 0 else "left")
        if abs(direction["superior_inferior"]) > 10:
            parts.append("superior" if direction["superior_inferior"] > 0 else "inferior")
        bulk_direction = " and ".join(parts) if parts else "no dominant direction"

        return {
            "top_positive": top_positive,
            "top_negative": top_negative,
            "direction": direction,
            "pattern_type": pattern_type,
            "bulk_direction": bulk_direction,
            "n_significant_positive": n_pos,
            "n_significant_negative": n_neg,
        }

    def build_llm_context(self, delta: np.ndarray,
                          path_regions: list[str] | None = None) -> str:
        """Build a structured text context for LLM interpretation.

        Args:
            delta: (R,) ROI delta vector.
            path_regions: optional list of brain region names the probe traversed.

        Returns:
            Formatted context string for the LLM prompt.
        """
        analysis = self.analyze_flow_pattern(delta)

        lines = []
        lines.append("=== ROI FLOW ANALYSIS ===\n")

        # Pattern overview
        lines.append(f"Flow pattern type: {analysis['pattern_type']}")
        lines.append(f"Bulk information flow direction: {analysis['bulk_direction']}")
        lines.append(f"Significant ROIs with increased activation: "
                      f"{analysis['n_significant_positive']}")
        lines.append(f"Significant ROIs with decreased activation: "
                      f"{analysis['n_significant_negative']}")
        lines.append("")

        # Top receivers (positive delta)
        if analysis["top_positive"]:
            lines.append("TOP RECEIVING ROIs (activation INCREASED):")
            for name, val, center in analysis["top_positive"][:8]:
                side = "left" if center[0] < 0 else "right"
                depth = "anterior" if center[1] > 0 else "posterior"
                lines.append(f"  - {name} ({side}, {depth}): delta = +{val:.4f}, "
                              f"MNI = ({center[0]:.0f}, {center[1]:.0f}, {center[2]:.0f})")
            lines.append("")

        # Top donors (negative delta)
        if analysis["top_negative"]:
            lines.append("TOP DONOR ROIs (activation DECREASED):")
            for name, val, center in analysis["top_negative"][:8]:
                side = "left" if center[0] < 0 else "right"
                depth = "anterior" if center[1] > 0 else "posterior"
                lines.append(f"  - {name} ({side}, {depth}): delta = {val:.4f}, "
                              f"MNI = ({center[0]:.0f}, {center[1]:.0f}, {center[2]:.0f})")
            lines.append("")

        # Path context
        if path_regions:
            lines.append("MANIFOLD PATH TRAVERSED THROUGH THESE REGIONS:")
            for i, r in enumerate(path_regions, 1):
                lines.append(f"  {i}. {r}")
            lines.append("")

        # Directional summary
        d = analysis["direction"]
        lines.append("DIRECTIONAL ANALYSIS:")
        lines.append(f"  Left-Right shift: {d['left_right']:.1f} mm "
                      f"({'rightward' if d['left_right'] > 0 else 'leftward'})")
        lines.append(f"  Anterior-Posterior shift: {d['anterior_posterior']:.1f} mm "
                      f"({'anterior' if d['anterior_posterior'] > 0 else 'posterior'})")
        lines.append(f"  Superior-Inferior shift: {d['superior_inferior']:.1f} mm "
                      f"({'superior' if d['superior_inferior'] > 0 else 'inferior'})")

        return "\n".join(lines)


# ---------------------------------------------------------------------------
# ROI flow LLM interpreter
# ---------------------------------------------------------------------------

class ROIFlowLLM:
    """Send ROI flow analysis to LLM for interpretation."""

    def __init__(self, model: str = "gpt-5.4-mini", debug: bool = False):
        self.model = model
        self.debug = debug

    def interpret_roi_flow(self, context: str) -> str:
        """Interpret an ROI flow pattern using the LLM.

        Args:
            context: structured text from ROIFlowAnalyzer.build_llm_context()

        Returns:
            LLM interpretation text.
        """
        from src.region_analyzer import _ensure_ssl
        _ensure_ssl()

        from dotenv import load_dotenv
        load_dotenv()

        instructions = (
            "You are a neuroscientist interpreting brain state dynamics from a "
            "manifold flow simulation. The user traced a path through a learned "
            "neural manifold (a dimensionality-reduced representation of resting-state "
            "brain dynamics). You are given how each ROI's contribution changed along "
            "this path.\n\n"
            "IMPORTANT: The delta values do NOT mean regions became more or less "
            "'active' in a simple sense. They measure how each ROI's CONTRIBUTION "
            "to the overall brain state shifted — some regions contribute more to the "
            "new state, some less. A positive delta means the region became a stronger "
            "contributor; negative means it became a weaker contributor. This is a "
            "transition in the brain's dynamic state.\n\n"
            "Analyze what this particular state transition might mean. Do NOT list "
            "the individual ROI changes — instead, synthesize them into ONE coherent "
            "picture of what cognitive or neural process could underlie this specific "
            "shift in brain dynamics. Consider the spatial pattern (which networks "
            "gained vs lost contribution), the directionality, and what spontaneous "
            "resting-state process would produce this exact transition.\n\n"
            "Keep your response SHORT — 2-3 concise paragraphs maximum.\n\n"
            "Note: ROI deltas are interpolated from a learned resting-state manifold — "
            "interpret transitions as shifts in dynamic brain state, not literal activations."
        )

        question = context

        if self.debug:
            import sys
            print(f"\n{'='*60}")
            print(f"[DEBUG PROMPT] ROIFlowLLM.interpret_roi_flow")
            print(f"{'='*60}")
            print(f"INSTRUCTIONS:\n{instructions}")
            print(f"\nINPUT:\n{question}")
            print(f"{'='*60}\n")
            sys.stdout.flush()

        try:
            from openai import OpenAI
            client = OpenAI(timeout=60.0)

            resp = client.responses.create(
                model=self.model,
                instructions=instructions,
                input=question,
                reasoning={"effort": "low"},
                max_output_tokens=2500,
            )

            text = (resp.output_text or "").strip()
            if text:
                return text

            if getattr(resp, "status", None) == "incomplete":
                reason = getattr(resp.incomplete_details, "reason", "unknown")
                return f"[GPT ERROR] Responses API incomplete: {reason}"

            return "[GPT ERROR] Responses API returned no visible text."

        except Exception as e:
            import traceback
            traceback.print_exc()
            return f"[GPT ERROR] {e}"

    def compare_two_paths(self, context_a: str, context_b: str) -> str:
        """Compare two ROI flow patterns using the LLM.

        Args:
            context_a: structured text from path A analysis
            context_b: structured text from path B analysis

        Returns:
            LLM comparison interpretation text.
        """
        from src.region_analyzer import _ensure_ssl
        _ensure_ssl()

        from dotenv import load_dotenv
        load_dotenv()

        instructions = (
            "You are a neuroscientist comparing TWO different brain state "
            "transitions observed in a neural manifold flow simulation. "
            "Each path represents a different trajectory through the manifold, "
            "producing different shifts in ROI contributions.\n\n"
            "You are given the ROI flow analysis for Path A and Path B.\n\n"
            "Compare them: What is SIMILAR between the two transitions? "
            "What is DIFFERENT? Which cognitive or neural processes might "
            "explain the divergence? Which path represents a more dramatic "
            "state change? Are they transitions within the same network or "
            "do they involve fundamentally different networks?\n\n"
            "Keep your response SHORT — 2-3 concise paragraphs maximum.\n\n"
            "Note: both paths are trajectories through a learned resting-state manifold — "
            "compare them as different dynamic regimes, not literal biological events."
        )

        question = (
            "=== PATH A ===\n" + context_a + "\n\n"
            "=== PATH B ===\n" + context_b
        )

        if self.debug:
            import sys
            print(f"\n{'='*60}")
            print(f"[DEBUG PROMPT] ROIFlowLLM.compare_two_paths")
            print(f"{'='*60}")
            print(f"INSTRUCTIONS:\n{instructions}")
            print(f"\nINPUT:\n{question}")
            print(f"{'='*60}\n")
            sys.stdout.flush()

        try:
            from openai import OpenAI
            client = OpenAI(timeout=90.0)

            resp = client.responses.create(
                model=self.model,
                instructions=instructions,
                input=question,
                reasoning={"effort": "low"},
                max_output_tokens=3000,
            )

            text = (resp.output_text or "").strip()
            if text:
                return text
            return "[GPT ERROR] No response text."

        except Exception as e:
            import traceback
            traceback.print_exc()
            return f"[GPT ERROR] {e}"